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.
- tangle_cli/__init__.py +19 -0
- tangle_cli/api_cli.py +787 -0
- tangle_cli/api_schema.py +633 -0
- tangle_cli/api_transport.py +461 -0
- tangle_cli/args_container.py +244 -0
- tangle_cli/artifacts.py +293 -0
- tangle_cli/artifacts_cli.py +108 -0
- tangle_cli/cli.py +57 -0
- tangle_cli/cli_helpers.py +116 -0
- tangle_cli/cli_options.py +52 -0
- tangle_cli/client.py +677 -0
- tangle_cli/component_from_func.py +1856 -0
- tangle_cli/component_generator.py +298 -0
- tangle_cli/component_inspector.py +494 -0
- tangle_cli/component_publisher.py +921 -0
- tangle_cli/components_cli.py +269 -0
- tangle_cli/dynamic_discovery_client.py +296 -0
- tangle_cli/generated_model_extensions.py +405 -0
- tangle_cli/generated_runtime.py +43 -0
- tangle_cli/handler.py +96 -0
- tangle_cli/hydration_trust.py +222 -0
- tangle_cli/logger.py +166 -0
- tangle_cli/models.py +407 -0
- tangle_cli/module_bundler.py +662 -0
- tangle_cli/openapi/__init__.py +0 -0
- tangle_cli/openapi/codegen.py +1090 -0
- tangle_cli/openapi/parser.py +77 -0
- tangle_cli/pipeline_dehydrator.py +720 -0
- tangle_cli/pipeline_hydrator.py +1785 -0
- tangle_cli/pipeline_run_annotations.py +41 -0
- tangle_cli/pipeline_run_details.py +203 -0
- tangle_cli/pipeline_run_manager.py +1994 -0
- tangle_cli/pipeline_run_search.py +712 -0
- tangle_cli/pipeline_runner.py +620 -0
- tangle_cli/pipeline_runs_cli.py +584 -0
- tangle_cli/pipelines.py +581 -0
- tangle_cli/pipelines_cli.py +271 -0
- tangle_cli/published_components_cli.py +373 -0
- tangle_cli/py.typed +0 -0
- tangle_cli/quickstart.py +110 -0
- tangle_cli/secrets.py +156 -0
- tangle_cli/secrets_cli.py +269 -0
- tangle_cli/utils.py +942 -0
- tangle_cli/version_manager.py +470 -0
- tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
- tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
- tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
- 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
|
+
}
|