tangle-cli 0.0.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. tangle_cli/__init__.py +19 -0
  2. tangle_cli/api_cli.py +787 -0
  3. tangle_cli/api_schema.py +633 -0
  4. tangle_cli/api_transport.py +461 -0
  5. tangle_cli/args_container.py +244 -0
  6. tangle_cli/artifacts.py +293 -0
  7. tangle_cli/artifacts_cli.py +108 -0
  8. tangle_cli/cli.py +57 -0
  9. tangle_cli/cli_helpers.py +116 -0
  10. tangle_cli/cli_options.py +52 -0
  11. tangle_cli/client.py +677 -0
  12. tangle_cli/component_from_func.py +1856 -0
  13. tangle_cli/component_generator.py +298 -0
  14. tangle_cli/component_inspector.py +494 -0
  15. tangle_cli/component_publisher.py +921 -0
  16. tangle_cli/components_cli.py +269 -0
  17. tangle_cli/dynamic_discovery_client.py +296 -0
  18. tangle_cli/generated_model_extensions.py +405 -0
  19. tangle_cli/generated_runtime.py +43 -0
  20. tangle_cli/handler.py +96 -0
  21. tangle_cli/hydration_trust.py +222 -0
  22. tangle_cli/logger.py +166 -0
  23. tangle_cli/models.py +407 -0
  24. tangle_cli/module_bundler.py +662 -0
  25. tangle_cli/openapi/__init__.py +0 -0
  26. tangle_cli/openapi/codegen.py +1090 -0
  27. tangle_cli/openapi/parser.py +77 -0
  28. tangle_cli/pipeline_dehydrator.py +720 -0
  29. tangle_cli/pipeline_hydrator.py +1785 -0
  30. tangle_cli/pipeline_run_annotations.py +41 -0
  31. tangle_cli/pipeline_run_details.py +203 -0
  32. tangle_cli/pipeline_run_manager.py +1994 -0
  33. tangle_cli/pipeline_run_search.py +712 -0
  34. tangle_cli/pipeline_runner.py +620 -0
  35. tangle_cli/pipeline_runs_cli.py +584 -0
  36. tangle_cli/pipelines.py +581 -0
  37. tangle_cli/pipelines_cli.py +271 -0
  38. tangle_cli/published_components_cli.py +373 -0
  39. tangle_cli/py.typed +0 -0
  40. tangle_cli/quickstart.py +110 -0
  41. tangle_cli/secrets.py +156 -0
  42. tangle_cli/secrets_cli.py +269 -0
  43. tangle_cli/utils.py +942 -0
  44. tangle_cli/version_manager.py +470 -0
  45. tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
  46. tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
  47. tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
  48. tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,712 @@
1
+ """Rich pipeline-run search/filter helpers.
2
+
3
+ This module is native-free and API-client agnostic. It builds Tangle search
4
+ ``filter_query`` payloads, resolves ``created_by=me`` via ``users_me()``, and
5
+ formats results for CLI/MCP consumers. Downstreams such as tangle-deploy can
6
+ subclass ``PipelineRunSearch`` with provider-authenticated client creation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import urllib.parse
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from typing import Any
17
+
18
+ from .handler import TangleCliHandler
19
+ from .logger import Logger
20
+
21
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
22
+ _MAX_PIPELINE_NAME_WIDTH = 50
23
+ _IDX_WIDTH = 3
24
+ _CREATED_AT_WIDTH = 16
25
+
26
+
27
+ @dataclass
28
+ class PageChunk:
29
+ """Metadata for a single page of search results.
30
+
31
+ Defined locally to keep this module importable without the native
32
+ ``tangle-api`` extra; ``tangle_cli.models`` re-exports an equivalent
33
+ dataclass when native models are available.
34
+ """
35
+
36
+ rows: list[dict[str, Any]]
37
+ page_token: str | None
38
+ next_page_token: str | None
39
+ ui_filter_url: str
40
+ next_ui_filter_url: str | None
41
+
42
+
43
+ class PipelineRunSearch(TangleCliHandler):
44
+ """Resource manager for pipeline-run search/filter behavior.
45
+
46
+ The class is intentionally native-free. Downstream packages can inject an
47
+ authenticated client or lazy ``client_factory`` and subclass the formatting
48
+ or predicate builders.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ client: Any = None,
54
+ *,
55
+ client_factory: Any | None = None,
56
+ logger: Logger | None = None,
57
+ **kwargs: Any,
58
+ ) -> None:
59
+ super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs)
60
+ self.logger = self.log
61
+
62
+ @staticmethod
63
+ def build_predicate(*, predicate_type: str, **fields: Any) -> dict[str, Any]:
64
+ return build_predicate(predicate_type=predicate_type, **fields)
65
+
66
+ @staticmethod
67
+ def build_value_contains(*, key: str, value_substring: str) -> dict[str, Any]:
68
+ return build_value_contains(key=key, value_substring=value_substring)
69
+
70
+ @staticmethod
71
+ def build_value_equals(*, key: str, value: str) -> dict[str, Any]:
72
+ return build_value_equals(key=key, value=value)
73
+
74
+ @staticmethod
75
+ def build_key_exists(*, key: str) -> dict[str, Any]:
76
+ return build_key_exists(key=key)
77
+
78
+ @staticmethod
79
+ def build_time_range(
80
+ *,
81
+ key: str,
82
+ start_time: str | None = None,
83
+ end_time: str | None = None,
84
+ ) -> dict[str, Any]:
85
+ return build_time_range(key=key, start_time=start_time, end_time=end_time)
86
+
87
+ def validate_created_by(self, *, value: str) -> str:
88
+ return validate_created_by(value=value, logger=self.logger)
89
+
90
+ @staticmethod
91
+ def parse_annotation(text: str) -> tuple[str, str | None]:
92
+ return parse_annotation(text)
93
+
94
+ @staticmethod
95
+ def normalize_query_input(text: str) -> dict[str, Any]:
96
+ return normalize_query_input(text)
97
+
98
+ @staticmethod
99
+ def build_ui_filter_url(
100
+ *,
101
+ base_url: str,
102
+ name: str | None = None,
103
+ created_by: str | None = None,
104
+ start_date: str | None = None,
105
+ end_date: str | None = None,
106
+ page_token: str | None = None,
107
+ ) -> str:
108
+ return build_ui_filter_url(
109
+ base_url=base_url,
110
+ name=name,
111
+ created_by=created_by,
112
+ start_date=start_date,
113
+ end_date=end_date,
114
+ page_token=page_token,
115
+ )
116
+
117
+ @staticmethod
118
+ def build_filter_query(
119
+ *,
120
+ name: str | None = None,
121
+ created_by: str | None = None,
122
+ annotations: dict[str, str | None] | None = None,
123
+ start_date: str | None = None,
124
+ end_date: str | None = None,
125
+ ) -> dict[str, Any] | None:
126
+ return build_filter_query(
127
+ name=name,
128
+ created_by=created_by,
129
+ annotations=annotations,
130
+ start_date=start_date,
131
+ end_date=end_date,
132
+ )
133
+
134
+ def resolve_created_by(self, *, created_by: str | None) -> tuple[str | None, dict[str, Any] | None]:
135
+ return resolve_created_by(created_by=created_by, client=self._require_client(), logger=self.logger)
136
+
137
+ def resolve_dates(
138
+ self,
139
+ *,
140
+ start_date: str | None,
141
+ end_date: str | None,
142
+ local_time: bool,
143
+ ) -> tuple[str | None, str | None]:
144
+ return resolve_dates(start_date=start_date, end_date=end_date, local_time=local_time, logger=self.logger)
145
+
146
+ @staticmethod
147
+ def format_mcp_table(*, rows: list[dict[str, Any]], next_page_token: str | None, ui_filter_url: str) -> str:
148
+ return _format_mcp_table(rows=rows, next_page_token=next_page_token, ui_filter_url=ui_filter_url)
149
+
150
+ @staticmethod
151
+ def format_cli_table(*, page_chunks: list[PageChunk], total_count: int) -> str:
152
+ return _format_cli_table(page_chunks=page_chunks, total_count=total_count)
153
+
154
+ def fetch_pages(
155
+ self,
156
+ *,
157
+ filter_query_str: str | None,
158
+ limit: int,
159
+ page_token: str | None,
160
+ base_url: str,
161
+ name: str | None,
162
+ created_by: str | None,
163
+ start_date: str | None,
164
+ end_date: str | None,
165
+ ) -> tuple[list[dict[str, Any]], list[PageChunk], str | None]:
166
+ return fetch_pipeline_run_search_pages(
167
+ client=self._require_client(),
168
+ filter_query_str=filter_query_str,
169
+ limit=limit,
170
+ page_token=page_token,
171
+ base_url=base_url,
172
+ name=name,
173
+ created_by=created_by,
174
+ start_date=start_date,
175
+ end_date=end_date,
176
+ )
177
+
178
+ @staticmethod
179
+ def build_result(
180
+ *,
181
+ all_rows: list[dict[str, Any]],
182
+ page_chunks: list[PageChunk],
183
+ final_next_token: str | None,
184
+ first_ui_url: str,
185
+ ) -> dict[str, Any]:
186
+ return build_pipeline_run_search_result(
187
+ all_rows=all_rows,
188
+ page_chunks=page_chunks,
189
+ final_next_token=final_next_token,
190
+ first_ui_url=first_ui_url,
191
+ )
192
+
193
+ def search(
194
+ self,
195
+ *,
196
+ name: str | None = None,
197
+ created_by: str | None = None,
198
+ annotations: dict[str, str | None] | None = None,
199
+ start_date: str | None = None,
200
+ end_date: str | None = None,
201
+ local_time: bool = False,
202
+ query: dict[str, Any] | None = None,
203
+ limit: int = 10,
204
+ page_token: str | None = None,
205
+ ) -> dict[str, Any]:
206
+ """Search pipeline runs and return rows, page metadata, and tables."""
207
+
208
+ limit = max(1, min(limit, 100))
209
+ resolved_created_by, err = self.resolve_created_by(created_by=created_by)
210
+ if err is not None:
211
+ return err
212
+ resolved_start, resolved_end = self.resolve_dates(
213
+ start_date=start_date,
214
+ end_date=end_date,
215
+ local_time=local_time,
216
+ )
217
+ filter_query_dict = query or self.build_filter_query(
218
+ name=name,
219
+ created_by=resolved_created_by,
220
+ annotations=annotations,
221
+ start_date=resolved_start,
222
+ end_date=resolved_end,
223
+ )
224
+ filter_query_str = json.dumps(filter_query_dict, separators=(",", ":")) if filter_query_dict else None
225
+ self.logger.info(f"Searching pipeline runs (limit={limit})...")
226
+ base_url = getattr(self._require_client(), "base_url", "").rstrip("/")
227
+ all_rows, page_chunks, final_next_token = self.fetch_pages(
228
+ filter_query_str=filter_query_str,
229
+ limit=limit,
230
+ page_token=page_token,
231
+ base_url=base_url,
232
+ name=name,
233
+ created_by=resolved_created_by,
234
+ start_date=resolved_start,
235
+ end_date=resolved_end,
236
+ )
237
+ if len(page_chunks) > 1:
238
+ self.logger.info(f"Fetched {len(page_chunks)} pages to collect {len(all_rows)} results.")
239
+ first_ui_url = (
240
+ page_chunks[0].ui_filter_url
241
+ if page_chunks
242
+ else self.build_ui_filter_url(
243
+ base_url=base_url,
244
+ name=name,
245
+ created_by=resolved_created_by,
246
+ start_date=resolved_start,
247
+ end_date=resolved_end,
248
+ page_token=page_token,
249
+ )
250
+ )
251
+ return self.build_result(
252
+ all_rows=all_rows,
253
+ page_chunks=page_chunks,
254
+ final_next_token=final_next_token,
255
+ first_ui_url=first_ui_url,
256
+ )
257
+
258
+
259
+ def build_predicate(*, predicate_type: str, **fields: Any) -> dict[str, Any]:
260
+ schemas: dict[str, tuple[str, ...]] = {
261
+ "value_contains": ("key", "value_substring"),
262
+ "value_equals": ("key", "value"),
263
+ "key_exists": ("key",),
264
+ "time_range": ("key", "start_time", "end_time"),
265
+ }
266
+ schema = schemas.get(predicate_type)
267
+ if schema is None:
268
+ raise ValueError(f"Unknown predicate type: {predicate_type!r}")
269
+ return {predicate_type: {key: fields[key] for key in schema if key in fields}}
270
+
271
+
272
+ def build_value_contains(*, key: str, value_substring: str) -> dict[str, Any]:
273
+ return build_predicate(predicate_type="value_contains", key=key, value_substring=value_substring)
274
+
275
+
276
+ def build_value_equals(*, key: str, value: str) -> dict[str, Any]:
277
+ return build_predicate(predicate_type="value_equals", key=key, value=value)
278
+
279
+
280
+ def build_key_exists(*, key: str) -> dict[str, Any]:
281
+ return build_predicate(predicate_type="key_exists", key=key)
282
+
283
+
284
+ def build_time_range(
285
+ *,
286
+ key: str,
287
+ start_time: str | None = None,
288
+ end_time: str | None = None,
289
+ ) -> dict[str, Any]:
290
+ fields: dict[str, Any] = {"key": key}
291
+ if start_time is not None:
292
+ fields["start_time"] = start_time
293
+ if end_time is not None:
294
+ fields["end_time"] = end_time
295
+ return build_predicate(predicate_type="time_range", **fields)
296
+
297
+
298
+ def validate_created_by(*, value: str, logger: Logger) -> str:
299
+ """Warn (but do not reject) if *value* is not ``me`` and not an email."""
300
+
301
+ if value != "me" and not _EMAIL_RE.match(value):
302
+ logger.warn(
303
+ f"โš ๏ธ created_by '{value}' does not look like a valid email"
304
+ " โ€” results may be empty or the API may return an error."
305
+ )
306
+ return value
307
+
308
+
309
+ def has_timezone(*, value: str) -> bool:
310
+ """Return True if *value* already includes a timezone offset or ``Z``."""
311
+
312
+ return value.endswith("Z") or "+" in value or value.count("-") >= 3
313
+
314
+
315
+ def apply_local_timezone(*, value: str, logger: Logger, suppress_log: bool = False) -> str:
316
+ """Append the system's local UTC offset to a naive datetime string."""
317
+
318
+ now = datetime.now(tz=timezone.utc).astimezone()
319
+ tz_name = now.tzname() or "UTC"
320
+ offset_str = now.strftime("%z")
321
+ offset_formatted = f"{offset_str[:3]}:{offset_str[3:]}"
322
+
323
+ try:
324
+ import time as _time
325
+
326
+ iana_name = _time.tzname[0] if _time.daylight == 0 else _time.tzname[1]
327
+ except Exception:
328
+ iana_name = tz_name
329
+
330
+ if not suppress_log:
331
+ logger.info("")
332
+ logger.info(f"๐Ÿ• Timezone: {iana_name} (UTC{offset_formatted})")
333
+ logger.info(f" Dates will be interpreted as {iana_name} time.")
334
+ logger.info("")
335
+ return f"{value}{offset_formatted}"
336
+
337
+
338
+ def parse_annotation(text: str) -> tuple[str, str | None]:
339
+ """Parse ``key=value`` or ``key`` annotation filters."""
340
+
341
+ if "=" in text:
342
+ key, value = text.split("=", 1)
343
+ return key, value
344
+ return text, None
345
+
346
+
347
+ def normalize_query_input(text: str) -> dict[str, Any]:
348
+ """Parse raw ``--query`` input, auto-detecting URL-encoding."""
349
+
350
+ try:
351
+ loaded = json.loads(text)
352
+ except (json.JSONDecodeError, ValueError):
353
+ loaded = None
354
+ if isinstance(loaded, dict):
355
+ return loaded
356
+
357
+ try:
358
+ decoded = urllib.parse.unquote(text)
359
+ loaded = json.loads(decoded)
360
+ except (json.JSONDecodeError, ValueError) as exc:
361
+ raise ValueError(
362
+ "Invalid --query input: not valid JSON (plain or URL-encoded). "
363
+ f"Parse error: {exc}"
364
+ ) from exc
365
+ if not isinstance(loaded, dict):
366
+ raise ValueError("Invalid --query input: JSON value must be an object")
367
+ return loaded
368
+
369
+
370
+ def build_ui_filter_url(
371
+ *,
372
+ base_url: str,
373
+ name: str | None = None,
374
+ created_by: str | None = None,
375
+ start_date: str | None = None,
376
+ end_date: str | None = None,
377
+ page_token: str | None = None,
378
+ ) -> str:
379
+ """Build a Tangle UI URL with a friendly ``?filter=`` query parameter."""
380
+
381
+ filter_obj: dict[str, str] = {}
382
+ if name:
383
+ filter_obj["pipeline_name"] = name
384
+ if created_by:
385
+ filter_obj["created_by"] = created_by
386
+ if start_date:
387
+ filter_obj["created_after"] = start_date
388
+ if end_date:
389
+ filter_obj["created_before"] = end_date
390
+ if not filter_obj:
391
+ return base_url
392
+ params: dict[str, str] = {"filter": json.dumps(filter_obj)}
393
+ if page_token:
394
+ params["page_token"] = page_token
395
+ return f"{base_url}/?{urllib.parse.urlencode(params)}"
396
+
397
+
398
+ def build_filter_query(
399
+ *,
400
+ name: str | None = None,
401
+ created_by: str | None = None,
402
+ annotations: dict[str, str | None] | None = None,
403
+ start_date: str | None = None,
404
+ end_date: str | None = None,
405
+ ) -> dict[str, Any] | None:
406
+ """Translate friendly search params into a Tangle ``filter_query`` object."""
407
+
408
+ predicates: list[dict[str, Any]] = []
409
+ if name:
410
+ predicates.append(build_value_contains(key="system/pipeline_run.name", value_substring=name))
411
+ if created_by:
412
+ predicates.append(build_value_equals(key="system/pipeline_run.created_by", value=created_by))
413
+ if annotations:
414
+ for key, value in annotations.items():
415
+ if value is None:
416
+ predicates.append(build_key_exists(key=key))
417
+ elif value == "":
418
+ predicates.append(build_value_equals(key=key, value=""))
419
+ else:
420
+ predicates.append(build_value_contains(key=key, value_substring=value))
421
+ if start_date or end_date:
422
+ predicates.append(
423
+ build_time_range(
424
+ key="system/pipeline_run.date.created_at",
425
+ start_time=start_date,
426
+ end_time=end_date,
427
+ )
428
+ )
429
+ return {"and": predicates} if predicates else None
430
+
431
+
432
+ def resolve_created_by(
433
+ *,
434
+ created_by: str | None,
435
+ client: Any,
436
+ logger: Logger,
437
+ ) -> tuple[str | None, dict[str, Any] | None]:
438
+ """Resolve ``created_by=me`` to the current user's id/email if requested."""
439
+
440
+ if not created_by:
441
+ return created_by, None
442
+ resolved = created_by
443
+ if created_by.lower() == "me":
444
+ user_info = client.users_me()
445
+ if user_info:
446
+ resolved = str(getattr(user_info, "id", None) or user_info.get("id"))
447
+ logger.info(f"Resolved 'me' to: {resolved}")
448
+ else:
449
+ return None, {"error": "Could not resolve 'me': authentication failed or user not found."}
450
+ validate_created_by(value=resolved, logger=logger)
451
+ return resolved, None
452
+
453
+
454
+ def resolve_dates(
455
+ *,
456
+ start_date: str | None,
457
+ end_date: str | None,
458
+ local_time: bool,
459
+ logger: Logger,
460
+ ) -> tuple[str | None, str | None]:
461
+ """Apply local timezone to naive datetimes."""
462
+
463
+ resolved_start = start_date
464
+ resolved_end = end_date
465
+ tz_logged = False
466
+ for label, date_val, attr in (
467
+ ("start-date", resolved_start, "start"),
468
+ ("end-date", resolved_end, "end"),
469
+ ):
470
+ if not date_val:
471
+ continue
472
+ if not has_timezone(value=date_val):
473
+ if not local_time:
474
+ logger.warn(
475
+ f"โš ๏ธ --{label} '{date_val}' has no timezone โ€” assuming local time."
476
+ " Pass an explicit timezone (e.g. 'Z' or '+00:00')"
477
+ " or --local-time to silence this warning."
478
+ )
479
+ date_val = apply_local_timezone(value=date_val, logger=logger, suppress_log=tz_logged)
480
+ tz_logged = True
481
+ if attr == "start":
482
+ resolved_start = date_val
483
+ else:
484
+ resolved_end = date_val
485
+ return resolved_start, resolved_end
486
+
487
+
488
+ def _format_datetime(value: str) -> str:
489
+ if not value:
490
+ return ""
491
+ try:
492
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
493
+ return dt.strftime("%Y-%m-%d %H:%M")
494
+ except (ValueError, TypeError):
495
+ return value[:16] if len(value) > 16 else value
496
+
497
+
498
+ def _format_mcp_table(*, rows: list[dict[str, Any]], next_page_token: str | None, ui_filter_url: str) -> str:
499
+ if not rows:
500
+ return "No pipeline runs found matching the search criteria."
501
+ lines = [
502
+ f"| {'#':>3} | Run ID | Pipeline Name | Created By | Created At (UTC) |",
503
+ "|-----|--------|--------------|------------|------------|",
504
+ ]
505
+ for row in rows:
506
+ run_id = row["run_id"]
507
+ short_id = f"{run_id[:7]}...{run_id[-3:]}" if len(run_id) > 12 else run_id
508
+ created_at = _format_datetime(row["created_at"])
509
+ lines.append(
510
+ f"| {row['index']:>3} | [{short_id}]({row['run_url']}) | "
511
+ f"{row['pipeline_name']} | {row['created_by']} | {created_at} |"
512
+ )
513
+ lines.append("")
514
+ lines.append(f"Showing {len(rows)} results.")
515
+ if ui_filter_url:
516
+ lines.append(f"[View filtered results in Tangle UI]({ui_filter_url})")
517
+ if next_page_token:
518
+ lines.append(f"Next page token: `{next_page_token}`")
519
+ return "\n".join(lines)
520
+
521
+
522
+ def _truncate(text: str, width: int) -> str:
523
+ return text if len(text) <= width else text[: width - 1] + "โ€ฆ"
524
+
525
+
526
+ def _compute_column_widths(all_rows: list[dict[str, Any]]) -> tuple[int, int, int]:
527
+ name_w = min(
528
+ max((len(r["pipeline_name"]) for r in all_rows), default=_MAX_PIPELINE_NAME_WIDTH),
529
+ _MAX_PIPELINE_NAME_WIDTH,
530
+ )
531
+ name_w = max(name_w, len("Pipeline Name"))
532
+ email_w = max((len(r["created_by"]) for r in all_rows), default=len("Created By"))
533
+ email_w = max(email_w, len("Created By"))
534
+ url_w = max((len(r["run_url"]) for r in all_rows), default=len("Tangle Link"))
535
+ url_w = max(url_w, len("Tangle Link"))
536
+ return name_w, email_w, url_w
537
+
538
+
539
+ def _cli_header_and_sep(*, name_w: int, email_w: int, url_w: int) -> tuple[str, str]:
540
+ hdr = (
541
+ f"| {'#':>{_IDX_WIDTH}} "
542
+ f"| {'Pipeline Name':<{name_w}} "
543
+ f"| {'Created By':<{email_w}} "
544
+ f"| {'Created At (UTC)':<{_CREATED_AT_WIDTH}} "
545
+ f"| {'Tangle Link':<{url_w}} |"
546
+ )
547
+ sep = (
548
+ f"|{'โ”€' * (_IDX_WIDTH + 2)}"
549
+ f"|{'โ”€' * (name_w + 2)}"
550
+ f"|{'โ”€' * (email_w + 2)}"
551
+ f"|{'โ”€' * (_CREATED_AT_WIDTH + 2)}"
552
+ f"|{'โ”€' * (url_w + 2)}|"
553
+ )
554
+ return hdr, sep
555
+
556
+
557
+ def _format_cli_table(*, page_chunks: list[PageChunk], total_count: int) -> str:
558
+ if not page_chunks or total_count == 0:
559
+ return "\n๐Ÿ” No pipeline runs found matching the search criteria.\n"
560
+ all_rows = [row for chunk in page_chunks for row in chunk.rows]
561
+ name_w, email_w, url_w = _compute_column_widths(all_rows)
562
+ hdr, sep = _cli_header_and_sep(name_w=name_w, email_w=email_w, url_w=url_w)
563
+ lines: list[str] = ["", "๐Ÿ” Pipeline Run Search Results", "โ”€" * len(sep)]
564
+ for chunk_idx, chunk in enumerate(page_chunks):
565
+ page_num = chunk_idx + 1
566
+ first_idx = chunk.rows[0]["index"]
567
+ last_idx = chunk.rows[-1]["index"]
568
+ lines.append("")
569
+ if len(page_chunks) > 1:
570
+ lines.append(f"๐Ÿ“„ Page {page_num} (rows {first_idx}โ€“{last_idx})")
571
+ lines.append("")
572
+ lines.append(hdr)
573
+ lines.append(sep)
574
+ for row in chunk.rows:
575
+ name_val = _truncate(row["pipeline_name"], name_w)
576
+ created_at = _format_datetime(row["created_at"])
577
+ lines.append(
578
+ f"| {row['index']:>{_IDX_WIDTH}} "
579
+ f"| {name_val:<{name_w}} "
580
+ f"| {row['created_by']:<{email_w}} "
581
+ f"| {created_at:<{_CREATED_AT_WIDTH}} "
582
+ f"| {row['run_url']:<{url_w}} |"
583
+ )
584
+ lines.append(sep)
585
+ footer_label = f"Page {page_num} ยท Rows {first_idx}โ€“{last_idx} of {total_count}"
586
+ lines.extend(["", f" โ”€โ”€ {footer_label} โ”€โ”€", ""])
587
+ if chunk.ui_filter_url:
588
+ lines.extend([" ๐Ÿ”— View this page in UI:", f" {chunk.ui_filter_url}", ""])
589
+ if chunk.next_page_token:
590
+ lines.extend([" ๐Ÿ“„ Page token:", f" {chunk.next_page_token}", ""])
591
+ if chunk.next_ui_filter_url:
592
+ lines.extend([" โžก๏ธ Next page in UI:", f" {chunk.next_ui_filter_url}", ""])
593
+ lines.append(f" {'โ”€' * (len(footer_label) + 6)}")
594
+ lines.append("")
595
+ lines.append("โ”€" * len(sep))
596
+ lines.append(f"โœ… Total: {total_count} results across {len(page_chunks)} page(s).")
597
+ lines.append("")
598
+ return "\n".join(lines)
599
+
600
+
601
+ def fetch_pipeline_run_search_pages(
602
+ *,
603
+ client: Any,
604
+ filter_query_str: str | None,
605
+ limit: int,
606
+ page_token: str | None,
607
+ base_url: str,
608
+ name: str | None,
609
+ created_by: str | None,
610
+ start_date: str | None,
611
+ end_date: str | None,
612
+ ) -> tuple[list[dict[str, Any]], list[PageChunk], str | None]:
613
+ """Paginate through the API collecting up to ``limit`` rows."""
614
+
615
+ all_rows: list[dict[str, Any]] = []
616
+ page_chunks: list[PageChunk] = []
617
+ current_token = page_token
618
+ running_index = 0
619
+ while running_index < limit:
620
+ response = client.pipeline_runs_list(
621
+ filter_query=filter_query_str,
622
+ page_token=current_token,
623
+ include_pipeline_names=True,
624
+ )
625
+ page_runs = response.get("pipeline_runs", [])
626
+ next_token = response.get("next_page_token")
627
+ if not page_runs:
628
+ break
629
+ page_runs = page_runs[: limit - running_index]
630
+ chunk_rows: list[dict[str, Any]] = []
631
+ for run in page_runs:
632
+ running_index += 1
633
+ run_id = run.get("id", "")
634
+ chunk_rows.append(
635
+ {
636
+ "index": running_index,
637
+ "run_id": run_id,
638
+ "pipeline_name": run.get("pipeline_name", ""),
639
+ "created_by": run.get("created_by", ""),
640
+ "created_at": run.get("created_at", ""),
641
+ "run_url": f"{base_url}/runs/{run_id}",
642
+ }
643
+ )
644
+ ui_url_for_page = build_ui_filter_url(
645
+ base_url=base_url,
646
+ name=name,
647
+ created_by=created_by,
648
+ start_date=start_date,
649
+ end_date=end_date,
650
+ page_token=current_token,
651
+ )
652
+ next_ui_url = (
653
+ build_ui_filter_url(
654
+ base_url=base_url,
655
+ name=name,
656
+ created_by=created_by,
657
+ start_date=start_date,
658
+ end_date=end_date,
659
+ page_token=next_token,
660
+ )
661
+ if next_token
662
+ else None
663
+ )
664
+ page_chunks.append(
665
+ PageChunk(
666
+ rows=chunk_rows,
667
+ page_token=current_token,
668
+ next_page_token=next_token,
669
+ ui_filter_url=ui_url_for_page,
670
+ next_ui_filter_url=next_ui_url,
671
+ )
672
+ )
673
+ all_rows.extend(chunk_rows)
674
+ current_token = next_token
675
+ if not current_token:
676
+ break
677
+ final_next_token = current_token if running_index >= limit and current_token else None
678
+ return all_rows, page_chunks, final_next_token
679
+
680
+
681
+ def build_pipeline_run_search_result(
682
+ *,
683
+ all_rows: list[dict[str, Any]],
684
+ page_chunks: list[PageChunk],
685
+ final_next_token: str | None,
686
+ first_ui_url: str,
687
+ ) -> dict[str, Any]:
688
+ pages_meta: list[dict[str, Any]] = []
689
+ for idx, chunk in enumerate(page_chunks):
690
+ pages_meta.append(
691
+ {
692
+ "page": idx + 1,
693
+ "rows": f"{chunk.rows[0]['index']}โ€“{chunk.rows[-1]['index']}",
694
+ "ui_url": chunk.ui_filter_url,
695
+ "page_token": chunk.page_token,
696
+ "next_page_token": chunk.next_page_token,
697
+ "next_ui_url": chunk.next_ui_filter_url,
698
+ }
699
+ )
700
+ return {
701
+ "runs": all_rows,
702
+ "count": len(all_rows),
703
+ "pages": pages_meta,
704
+ "markdown_table": _format_mcp_table(
705
+ rows=all_rows,
706
+ next_page_token=final_next_token,
707
+ ui_filter_url=first_ui_url,
708
+ ),
709
+ "cli_table": _format_cli_table(page_chunks=page_chunks, total_count=len(all_rows)),
710
+ "next_page_token": final_next_token,
711
+ "ui_filter_url": first_ui_url,
712
+ }