@josephyan/qingflow-cli 0.2.0-beta.1008 → 0.2.0-beta.1011

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.
@@ -0,0 +1,1177 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from copy import deepcopy
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from time import monotonic, sleep
9
+ from typing import Any, cast
10
+ from uuid import uuid4
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
15
+ from ..errors import QingflowApiError
16
+ from ..export_store import ExportJobStore
17
+ from ..json_types import JSONObject
18
+ from .base import ToolBase, tool_cn_name
19
+ from .record_tools import (
20
+ AccessibleViewRoute,
21
+ LAYOUT_ONLY_QUE_TYPES,
22
+ RecordTools,
23
+ _normalize_public_column_selectors,
24
+ )
25
+
26
+
27
+ EXPORT_STATUS_BY_PROCESS_STATUS = {
28
+ 1: "queued",
29
+ 2: "running",
30
+ 3: "succeeded",
31
+ 4: "failed",
32
+ 5: "failed",
33
+ }
34
+ DEFAULT_EXPORT_TIMEOUT_SECONDS = 60.0
35
+ EXPORT_POLL_INTERVAL_SECONDS = 1.5
36
+ EXPORT_ROWS_LIMIT = 20_000
37
+ EXPORT_CELL_LIMIT = 4_000_000
38
+ _SAFE_FILE_CHARS = re.compile(r"[^0-9A-Za-z._-]+")
39
+ LOCAL_TIMEZONE = datetime.now().astimezone().tzinfo or timezone.utc
40
+
41
+
42
+ class ExportTools(ToolBase):
43
+ def __init__(
44
+ self,
45
+ sessions,
46
+ backend,
47
+ *,
48
+ job_store: ExportJobStore | None = None,
49
+ ) -> None:
50
+ super().__init__(sessions, backend)
51
+ self._record_tools = RecordTools(sessions, backend)
52
+ self._job_store = job_store or ExportJobStore()
53
+
54
+ def register(self, mcp: FastMCP) -> None:
55
+ @mcp.tool(description="Start an asynchronous xlsx export for a Qingflow record view and return an opaque export_handle.")
56
+ def record_export_start(
57
+ profile: str = DEFAULT_PROFILE,
58
+ app_key: str = "",
59
+ view_id: str = "system:all",
60
+ columns: list[JSONObject | int] | None = None,
61
+ record_ids: list[str | int] | None = None,
62
+ include_workflow_log: bool = False,
63
+ ) -> dict[str, Any]:
64
+ return self.record_export_start(
65
+ profile=profile,
66
+ app_key=app_key,
67
+ view_id=view_id,
68
+ columns=columns or [],
69
+ record_ids=record_ids or [],
70
+ include_workflow_log=include_workflow_log,
71
+ )
72
+
73
+ @mcp.tool(description="Get asynchronous record export status by opaque export_handle.")
74
+ def record_export_status_get(
75
+ profile: str = DEFAULT_PROFILE,
76
+ export_handle: str = "",
77
+ ) -> dict[str, Any]:
78
+ return self.record_export_status_get(
79
+ profile=profile,
80
+ export_handle=export_handle,
81
+ )
82
+
83
+ @mcp.tool(description="Get completed record export result, returning remote file links and optionally downloading files locally.")
84
+ def record_export_get(
85
+ profile: str = DEFAULT_PROFILE,
86
+ export_handle: str = "",
87
+ download_to_path: str | None = None,
88
+ ) -> dict[str, Any]:
89
+ return self.record_export_get(
90
+ profile=profile,
91
+ export_handle=export_handle,
92
+ download_to_path=download_to_path,
93
+ )
94
+
95
+ @mcp.tool(description="Start a record export, wait for completion, and download the xlsx locally while also returning remote download links.")
96
+ def record_export_direct(
97
+ profile: str = DEFAULT_PROFILE,
98
+ app_key: str = "",
99
+ view_id: str = "system:all",
100
+ columns: list[JSONObject | int] | None = None,
101
+ record_ids: list[str | int] | None = None,
102
+ include_workflow_log: bool = False,
103
+ download_to_path: str | None = None,
104
+ wait_timeout_seconds: float | None = None,
105
+ ) -> dict[str, Any]:
106
+ return self.record_export_direct(
107
+ profile=profile,
108
+ app_key=app_key,
109
+ view_id=view_id,
110
+ columns=columns or [],
111
+ record_ids=record_ids or [],
112
+ include_workflow_log=include_workflow_log,
113
+ download_to_path=download_to_path,
114
+ wait_timeout_seconds=wait_timeout_seconds,
115
+ )
116
+
117
+ @tool_cn_name("记录导出启动")
118
+ def record_export_start(
119
+ self,
120
+ *,
121
+ profile: str = DEFAULT_PROFILE,
122
+ app_key: str,
123
+ view_id: str = "system:all",
124
+ columns: list[JSONObject | int] | None = None,
125
+ record_ids: list[str | int] | None = None,
126
+ include_workflow_log: bool = False,
127
+ ) -> dict[str, Any]:
128
+ normalized_app_key = str(app_key or "").strip()
129
+ normalized_view_id = str(view_id or "").strip() or "system:all"
130
+ normalized_columns = _normalize_export_columns(columns or [])
131
+ normalized_record_ids = _normalize_export_record_ids(record_ids or [])
132
+ if not normalized_app_key:
133
+ return self._failed_export_result(
134
+ error_code="EXPORT_START_FAILED",
135
+ message="app_key is required",
136
+ extra={"view_id": normalized_view_id, "status": "failed"},
137
+ )
138
+
139
+ def runner(session_profile, context):
140
+ resolved_view, compatibility_warnings = self._record_tools._resolve_accessible_view_route(
141
+ profile,
142
+ context,
143
+ normalized_app_key,
144
+ view_id=normalized_view_id,
145
+ list_type=None,
146
+ view_key=None,
147
+ view_name=None,
148
+ allow_default=True,
149
+ )
150
+ export_config, export_config_warnings = self._build_export_config(
151
+ profile=profile,
152
+ context=context,
153
+ app_key=normalized_app_key,
154
+ resolved_view=resolved_view,
155
+ column_selectors=normalized_columns,
156
+ )
157
+ result_amount = self._estimate_export_result_amount(
158
+ context,
159
+ app_key=normalized_app_key,
160
+ resolved_view=resolved_view,
161
+ selected_record_ids=normalized_record_ids,
162
+ )
163
+ self._validate_export_limits(
164
+ result_amount=result_amount,
165
+ selected_field_count=len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
166
+ include_workflow_log=include_workflow_log,
167
+ )
168
+ filter_bean = self._build_export_filter_bean(
169
+ resolved_view,
170
+ selected_record_ids=normalized_record_ids,
171
+ include_workflow_log=include_workflow_log,
172
+ )
173
+ started_at = _utc_now().replace(microsecond=0).isoformat()
174
+ socket_result = self.backend.start_socket_record_export(
175
+ context,
176
+ app_key=normalized_app_key,
177
+ view_id=resolved_view.view_id,
178
+ view_key=resolved_view.view_selection.view_key if resolved_view.view_selection is not None else None,
179
+ filter_bean=filter_bean,
180
+ export_config=export_config,
181
+ result_amount=result_amount,
182
+ )
183
+ row_scope = "selected" if normalized_record_ids else "all"
184
+ column_scope = "selected" if normalized_columns else "all"
185
+ export_handle = uuid4().hex
186
+ self._job_store.put(
187
+ export_handle,
188
+ {
189
+ "created_at": started_at,
190
+ "profile": profile,
191
+ "app_key": normalized_app_key,
192
+ "view_id": resolved_view.view_id,
193
+ "backend_export_id": str(socket_result.get("backend_export_id") or ""),
194
+ "started_at": started_at,
195
+ "uid": session_profile.uid,
196
+ "row_scope": row_scope,
197
+ "selected_record_count": len(normalized_record_ids),
198
+ "field_scope": column_scope,
199
+ "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
200
+ "include_workflow_log": bool(include_workflow_log),
201
+ },
202
+ )
203
+ warnings = [
204
+ *deepcopy(compatibility_warnings),
205
+ *deepcopy(export_config_warnings),
206
+ *deepcopy(cast(list[JSONObject], socket_result.get("warnings") or [])),
207
+ ]
208
+ return {
209
+ "ok": True,
210
+ "status": "accepted",
211
+ "app_key": normalized_app_key,
212
+ "view_id": resolved_view.view_id,
213
+ "export_handle": export_handle,
214
+ "row_scope": row_scope,
215
+ "selected_record_count": len(normalized_record_ids),
216
+ "field_scope": column_scope,
217
+ "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
218
+ "include_workflow_log": bool(include_workflow_log),
219
+ "file_urls": [],
220
+ "file_names": [],
221
+ "downloaded_files": [],
222
+ "warnings": warnings,
223
+ "verification": {
224
+ "export_acknowledged": bool(socket_result.get("backend_export_id")),
225
+ "view_route_supported": True,
226
+ "row_selection_applied": bool(normalized_record_ids),
227
+ "field_selection_applied": bool(normalized_columns),
228
+ },
229
+ "request_route": self.backend.describe_route(context),
230
+ }
231
+
232
+ try:
233
+ return self._run(profile, runner)
234
+ except RuntimeError as exc:
235
+ return self._runtime_error_as_result(
236
+ exc,
237
+ error_code="EXPORT_START_FAILED",
238
+ extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
239
+ )
240
+
241
+ @tool_cn_name("记录导出状态")
242
+ def record_export_status_get(
243
+ self,
244
+ *,
245
+ profile: str = DEFAULT_PROFILE,
246
+ export_handle: str,
247
+ ) -> dict[str, Any]:
248
+ normalized_handle = str(export_handle or "").strip()
249
+ if not normalized_handle:
250
+ return self._failed_export_result(
251
+ error_code="CONFIG_ERROR",
252
+ message="export_handle is required",
253
+ extra={"status": "failed"},
254
+ )
255
+
256
+ def runner(_session_profile, context):
257
+ local_job = self._job_store.get(normalized_handle)
258
+ if local_job is None:
259
+ return self._failed_export_result(
260
+ error_code="EXPORT_HANDLE_UNKNOWN",
261
+ message="export_handle is missing or expired",
262
+ extra={"export_handle": normalized_handle, "status": "failed"},
263
+ )
264
+ snapshot = self._resolve_export_snapshot(context, local_job)
265
+ return self._status_payload_from_snapshot(local_job, normalized_handle, snapshot)
266
+
267
+ try:
268
+ return self._run(profile, runner)
269
+ except RuntimeError as exc:
270
+ return self._runtime_error_as_result(
271
+ exc,
272
+ error_code="EXPORT_STATUS_FAILED",
273
+ extra={"export_handle": normalized_handle},
274
+ )
275
+
276
+ @tool_cn_name("记录导出结果")
277
+ def record_export_get(
278
+ self,
279
+ *,
280
+ profile: str = DEFAULT_PROFILE,
281
+ export_handle: str,
282
+ download_to_path: str | None = None,
283
+ ) -> dict[str, Any]:
284
+ normalized_handle = str(export_handle or "").strip()
285
+ if not normalized_handle:
286
+ return self._failed_export_result(
287
+ error_code="CONFIG_ERROR",
288
+ message="export_handle is required",
289
+ extra={"status": "failed"},
290
+ )
291
+
292
+ def runner(_session_profile, context):
293
+ local_job = self._job_store.get(normalized_handle)
294
+ if local_job is None:
295
+ return self._failed_export_result(
296
+ error_code="EXPORT_HANDLE_UNKNOWN",
297
+ message="export_handle is missing or expired",
298
+ extra={"export_handle": normalized_handle, "status": "failed"},
299
+ )
300
+ snapshot = self._resolve_export_snapshot(context, local_job)
301
+ normalized_status = str(snapshot.get("status") or "unknown")
302
+ if normalized_status not in {"succeeded", "failed"}:
303
+ return self._failed_export_result(
304
+ error_code="EXPORT_NOT_READY",
305
+ message="export is not ready yet",
306
+ extra={
307
+ "status": "blocked",
308
+ "export_handle": normalized_handle,
309
+ "app_key": str(local_job.get("app_key") or ""),
310
+ "view_id": str(local_job.get("view_id") or ""),
311
+ "row_scope": str(local_job.get("row_scope") or "all"),
312
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
313
+ "field_scope": str(local_job.get("field_scope") or "all"),
314
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
315
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
316
+ "file_urls": snapshot.get("file_urls") or [],
317
+ "file_names": snapshot.get("file_names") or [],
318
+ "downloaded_files": [],
319
+ "warnings": snapshot.get("warnings") or [],
320
+ "verification": snapshot.get("verification") or {},
321
+ },
322
+ )
323
+ if normalized_status == "failed":
324
+ return {
325
+ **self._failed_export_result(
326
+ error_code=str(snapshot.get("error_code") or "EXPORT_FAILED"),
327
+ message=str(snapshot.get("message") or "export failed"),
328
+ extra={
329
+ "status": "failed",
330
+ "export_handle": normalized_handle,
331
+ "app_key": str(local_job.get("app_key") or ""),
332
+ "view_id": str(local_job.get("view_id") or ""),
333
+ "row_scope": str(local_job.get("row_scope") or "all"),
334
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
335
+ "field_scope": str(local_job.get("field_scope") or "all"),
336
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
337
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
338
+ "file_urls": snapshot.get("file_urls") or [],
339
+ "file_names": snapshot.get("file_names") or [],
340
+ "downloaded_files": [],
341
+ "warnings": snapshot.get("warnings") or [],
342
+ "verification": snapshot.get("verification") or {},
343
+ "num": snapshot.get("num"),
344
+ "process_status": snapshot.get("process_status"),
345
+ "audit_record_status": snapshot.get("audit_record_status"),
346
+ },
347
+ ),
348
+ }
349
+ file_infos = cast(list[JSONObject], snapshot.get("file_infos") or [])
350
+ if not file_infos:
351
+ return self._failed_export_result(
352
+ error_code="EXPORT_FILE_UNAVAILABLE",
353
+ message="export completed but did not return downloadable files",
354
+ extra={
355
+ "status": "failed",
356
+ "export_handle": normalized_handle,
357
+ "app_key": str(local_job.get("app_key") or ""),
358
+ "view_id": str(local_job.get("view_id") or ""),
359
+ "warnings": snapshot.get("warnings") or [],
360
+ "verification": snapshot.get("verification") or {},
361
+ },
362
+ )
363
+ downloaded_files = self._download_export_files(
364
+ file_infos=file_infos,
365
+ download_to_path=download_to_path,
366
+ default_directory=None,
367
+ app_key=str(local_job.get("app_key") or ""),
368
+ view_id=str(local_job.get("view_id") or ""),
369
+ )
370
+ return {
371
+ "ok": True,
372
+ "status": "succeeded",
373
+ "export_handle": normalized_handle,
374
+ "app_key": str(local_job.get("app_key") or ""),
375
+ "view_id": str(local_job.get("view_id") or ""),
376
+ "row_scope": str(local_job.get("row_scope") or "all"),
377
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
378
+ "field_scope": str(local_job.get("field_scope") or "all"),
379
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
380
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
381
+ "num": snapshot.get("num"),
382
+ "process_status": snapshot.get("process_status"),
383
+ "error_code": snapshot.get("error_code"),
384
+ "audit_record_status": snapshot.get("audit_record_status"),
385
+ "file_urls": snapshot.get("file_urls") or [],
386
+ "file_names": snapshot.get("file_names") or [],
387
+ "downloaded_files": downloaded_files,
388
+ "warnings": snapshot.get("warnings") or [],
389
+ "verification": snapshot.get("verification") or {},
390
+ "request_route": self.backend.describe_route(context),
391
+ }
392
+
393
+ try:
394
+ return self._run(profile, runner)
395
+ except RuntimeError as exc:
396
+ return self._runtime_error_as_result(
397
+ exc,
398
+ error_code="EXPORT_GET_FAILED",
399
+ extra={"export_handle": normalized_handle},
400
+ )
401
+
402
+ @tool_cn_name("记录直接导出")
403
+ def record_export_direct(
404
+ self,
405
+ *,
406
+ profile: str = DEFAULT_PROFILE,
407
+ app_key: str,
408
+ view_id: str = "system:all",
409
+ columns: list[JSONObject | int] | None = None,
410
+ record_ids: list[str | int] | None = None,
411
+ include_workflow_log: bool = False,
412
+ download_to_path: str | None = None,
413
+ wait_timeout_seconds: float | None = None,
414
+ ) -> dict[str, Any]:
415
+ normalized_app_key = str(app_key or "").strip()
416
+ normalized_view_id = str(view_id or "").strip() or "system:all"
417
+ if not normalized_app_key:
418
+ return self._failed_export_result(
419
+ error_code="EXPORT_START_FAILED",
420
+ message="app_key is required",
421
+ extra={"status": "failed", "view_id": normalized_view_id},
422
+ )
423
+ timeout_seconds = self._normalize_timeout_seconds(wait_timeout_seconds)
424
+
425
+ def runner(_session_profile, context):
426
+ start_result = self.record_export_start(
427
+ profile=profile,
428
+ app_key=normalized_app_key,
429
+ view_id=normalized_view_id,
430
+ columns=columns or [],
431
+ record_ids=record_ids or [],
432
+ include_workflow_log=include_workflow_log,
433
+ )
434
+ if not bool(start_result.get("ok")):
435
+ return start_result
436
+ export_handle = str(start_result.get("export_handle") or "")
437
+ deadline = monotonic() + timeout_seconds
438
+ last_snapshot: dict[str, Any] | None = None
439
+ while monotonic() < deadline:
440
+ local_job = self._job_store.get(export_handle)
441
+ if local_job is None:
442
+ return self._failed_export_result(
443
+ error_code="EXPORT_HANDLE_UNKNOWN",
444
+ message="export_handle is missing or expired",
445
+ extra={"status": "failed", "export_handle": export_handle},
446
+ )
447
+ snapshot = self._resolve_export_snapshot(context, local_job)
448
+ last_snapshot = snapshot
449
+ normalized_status = str(snapshot.get("status") or "unknown")
450
+ if normalized_status == "succeeded":
451
+ effective_download_path = download_to_path or str(Path.cwd())
452
+ get_result = self.record_export_get(
453
+ profile=profile,
454
+ export_handle=export_handle,
455
+ download_to_path=effective_download_path,
456
+ )
457
+ if bool(get_result.get("ok")):
458
+ return get_result
459
+ return {
460
+ **get_result,
461
+ "export_handle": export_handle,
462
+ "file_urls": snapshot.get("file_urls") or [],
463
+ "file_names": snapshot.get("file_names") or [],
464
+ }
465
+ if normalized_status == "failed":
466
+ return {
467
+ "ok": False,
468
+ "status": "failed",
469
+ "error_code": str(snapshot.get("error_code") or "EXPORT_FAILED"),
470
+ "message": str(snapshot.get("message") or "export failed"),
471
+ "export_handle": export_handle,
472
+ "app_key": str(local_job.get("app_key") or ""),
473
+ "view_id": str(local_job.get("view_id") or ""),
474
+ "row_scope": str(local_job.get("row_scope") or "all"),
475
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
476
+ "field_scope": str(local_job.get("field_scope") or "all"),
477
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
478
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
479
+ "num": snapshot.get("num"),
480
+ "process_status": snapshot.get("process_status"),
481
+ "audit_record_status": snapshot.get("audit_record_status"),
482
+ "file_urls": snapshot.get("file_urls") or [],
483
+ "file_names": snapshot.get("file_names") or [],
484
+ "downloaded_files": [],
485
+ "warnings": snapshot.get("warnings") or [],
486
+ "verification": snapshot.get("verification") or {},
487
+ }
488
+ if normalized_status == "unknown":
489
+ warning_codes = {
490
+ str(item.get("code") or "")
491
+ for item in cast(list[JSONObject], snapshot.get("warnings") or [])
492
+ if isinstance(item, dict)
493
+ }
494
+ if "EXPORT_HISTORY_AMBIGUOUS" in warning_codes:
495
+ return {
496
+ "ok": True,
497
+ "status": "unknown",
498
+ "export_handle": export_handle,
499
+ "app_key": str(local_job.get("app_key") or ""),
500
+ "view_id": str(local_job.get("view_id") or ""),
501
+ "row_scope": str(local_job.get("row_scope") or "all"),
502
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
503
+ "field_scope": str(local_job.get("field_scope") or "all"),
504
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
505
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
506
+ "file_urls": snapshot.get("file_urls") or [],
507
+ "file_names": snapshot.get("file_names") or [],
508
+ "downloaded_files": [],
509
+ "warnings": snapshot.get("warnings") or [],
510
+ "verification": snapshot.get("verification") or {},
511
+ "message": "export result could not be matched uniquely",
512
+ }
513
+ remaining = deadline - monotonic()
514
+ if remaining <= 0:
515
+ break
516
+ sleep(min(EXPORT_POLL_INTERVAL_SECONDS, remaining))
517
+ timeout_status = "running"
518
+ if isinstance(last_snapshot, dict) and str(last_snapshot.get("status") or "").strip():
519
+ timeout_status = str(last_snapshot.get("status") or timeout_status)
520
+ warnings = list(cast(list[JSONObject], start_result.get("warnings") or []))
521
+ warnings.append(
522
+ {
523
+ "code": "EXPORT_WAIT_TIMEOUT",
524
+ "message": "export is still running; reuse export_handle with record_export_status_get or record_export_get",
525
+ }
526
+ )
527
+ return {
528
+ "ok": True,
529
+ "status": timeout_status,
530
+ "export_handle": str(start_result.get("export_handle") or ""),
531
+ "app_key": normalized_app_key,
532
+ "view_id": str(start_result.get("view_id") or normalized_view_id),
533
+ "row_scope": start_result.get("row_scope") or ("selected" if record_ids else "all"),
534
+ "selected_record_count": start_result.get("selected_record_count") or 0,
535
+ "field_scope": start_result.get("field_scope") or ("selected" if columns else "all"),
536
+ "selected_field_count": start_result.get("selected_field_count"),
537
+ "include_workflow_log": start_result.get("include_workflow_log"),
538
+ "file_urls": (last_snapshot or {}).get("file_urls") or [],
539
+ "file_names": (last_snapshot or {}).get("file_names") or [],
540
+ "downloaded_files": [],
541
+ "warnings": warnings,
542
+ "verification": (last_snapshot or {}).get("verification") or start_result.get("verification") or {},
543
+ "message": "export did not finish before wait_timeout_seconds",
544
+ }
545
+
546
+ try:
547
+ return self._run(profile, runner)
548
+ except RuntimeError as exc:
549
+ return self._runtime_error_as_result(
550
+ exc,
551
+ error_code="EXPORT_DIRECT_FAILED",
552
+ extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
553
+ )
554
+
555
+ def _build_export_filter_bean(
556
+ self,
557
+ resolved_view: AccessibleViewRoute,
558
+ *,
559
+ selected_record_ids: list[int],
560
+ include_workflow_log: bool,
561
+ ) -> JSONObject:
562
+ filter_payload: JSONObject = {}
563
+ if resolved_view.kind == "system" and resolved_view.list_type is not None:
564
+ filter_payload["type"] = resolved_view.list_type
565
+ if selected_record_ids:
566
+ filter_payload["applyIds"] = selected_record_ids
567
+ return {
568
+ "filter": filter_payload,
569
+ # Backend export code later auto-unboxes this field to primitive boolean.
570
+ # Always send an explicit boolean to avoid a null -> NPE -> 41100 failure path.
571
+ "auditRecordStatus": bool(include_workflow_log),
572
+ }
573
+
574
+ def _build_export_config(
575
+ self,
576
+ *,
577
+ profile: str,
578
+ context,
579
+ app_key: str,
580
+ resolved_view: AccessibleViewRoute,
581
+ column_selectors: list[int],
582
+ ) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
583
+ browse_scope = self._record_tools._build_browse_write_scope(
584
+ profile,
585
+ context,
586
+ app_key,
587
+ resolved_view,
588
+ force_refresh=False,
589
+ )
590
+ index = browse_scope["index"]
591
+ visible_question_ids = cast(set[int], browse_scope.get("visible_question_ids") or set())
592
+ ordered_visible_fields = [
593
+ field
594
+ for field in self._record_tools._schema_fields_for_mode(
595
+ profile,
596
+ context,
597
+ app_key,
598
+ index,
599
+ schema_mode="browse",
600
+ resolved_view=resolved_view,
601
+ )
602
+ if field.que_id in visible_question_ids and field.que_type not in LAYOUT_ONLY_QUE_TYPES
603
+ ]
604
+ if not ordered_visible_fields:
605
+ ordered_visible_fields = [
606
+ field
607
+ for field in cast(Any, index).by_id.values()
608
+ if field.que_type not in LAYOUT_ONLY_QUE_TYPES
609
+ ]
610
+ exportable_by_id = {field.que_id: field for field in ordered_visible_fields}
611
+ selected_fields: list[Any]
612
+ if column_selectors:
613
+ selected_fields = []
614
+ seen: set[int] = set()
615
+ for selector in column_selectors:
616
+ field = self._record_tools._resolve_field_selector(selector, index, location="export_columns")
617
+ if field.que_id in seen:
618
+ continue
619
+ exportable_field = exportable_by_id.get(field.que_id)
620
+ if exportable_field is None:
621
+ raise QingflowApiError.config_error(
622
+ f"field '{field.que_title}' is not exportable in the selected view",
623
+ details={
624
+ "error_code": "EXPORT_FIELD_NOT_VISIBLE",
625
+ "field_id": field.que_id,
626
+ "view_id": resolved_view.view_id,
627
+ },
628
+ )
629
+ selected_fields.append(exportable_field)
630
+ seen.add(field.que_id)
631
+ else:
632
+ selected_fields = ordered_visible_fields
633
+ question_export_config_list: list[JSONObject] = []
634
+ warnings: list[JSONObject] = []
635
+ for field in selected_fields:
636
+ if field.que_type is None:
637
+ warnings.append(
638
+ {
639
+ "code": "EXPORT_FIELD_TYPE_UNAVAILABLE",
640
+ "message": f"Skipped field '{field.que_title}' because its field type could not be resolved.",
641
+ }
642
+ )
643
+ continue
644
+ question_export_config_list.append(
645
+ {
646
+ "queId": field.que_id,
647
+ "queTitle": field.que_title,
648
+ "queType": field.que_type,
649
+ "exportStyle": "default",
650
+ }
651
+ )
652
+ if not question_export_config_list:
653
+ raise QingflowApiError.config_error(
654
+ "record export could not determine exportable fields for the selected view",
655
+ details={"error_code": "EXPORT_CONFIG_UNAVAILABLE"},
656
+ )
657
+ return {"questionExportConfigList": question_export_config_list}, warnings
658
+
659
+ def _estimate_export_result_amount(
660
+ self,
661
+ context,
662
+ *,
663
+ app_key: str,
664
+ resolved_view: AccessibleViewRoute,
665
+ selected_record_ids: list[int],
666
+ ) -> int:
667
+ if selected_record_ids:
668
+ return len(selected_record_ids)
669
+ page = self._record_tools._search_page(
670
+ context,
671
+ app_key=app_key,
672
+ view_selection=resolved_view.view_selection,
673
+ page_num=1,
674
+ page_size=1,
675
+ query_key=None,
676
+ match_rules=[],
677
+ sorts=[],
678
+ search_que_ids=None,
679
+ list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
680
+ )
681
+ result_amount = _effective_total(page, page_size=1)
682
+ if result_amount < 0:
683
+ result_amount = 0
684
+ return result_amount
685
+
686
+ def _validate_export_limits(
687
+ self,
688
+ *,
689
+ result_amount: int,
690
+ selected_field_count: int,
691
+ include_workflow_log: bool,
692
+ ) -> None:
693
+ if result_amount > EXPORT_ROWS_LIMIT:
694
+ raise QingflowApiError.config_error(
695
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
696
+ details={
697
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
698
+ "result_amount": result_amount,
699
+ "row_limit": EXPORT_ROWS_LIMIT,
700
+ },
701
+ )
702
+ estimated_cells = max(result_amount, 0) * max(selected_field_count, 0)
703
+ if estimated_cells > EXPORT_CELL_LIMIT:
704
+ raise QingflowApiError.config_error(
705
+ f"record export exceeds the native cell limit of {EXPORT_CELL_LIMIT}",
706
+ details={
707
+ "error_code": "EXPORT_CELLS_LIMIT_EXCEEDED",
708
+ "estimated_cells": estimated_cells,
709
+ "cell_limit": EXPORT_CELL_LIMIT,
710
+ "include_workflow_log": include_workflow_log,
711
+ },
712
+ )
713
+
714
+ def _resolve_export_snapshot(
715
+ self,
716
+ context,
717
+ local_job: dict[str, Any],
718
+ ) -> dict[str, Any]: # type: ignore[no-untyped-def]
719
+ app_key = str(local_job.get("app_key") or "").strip()
720
+ process_payload = self._lookup_process_details(context, app_key=app_key)
721
+ history_page = self.backend.request(
722
+ "GET",
723
+ context,
724
+ "/app/apply/dataExport/record",
725
+ params={"appKey": app_key, "pageNum": 1, "pageSize": 100},
726
+ )
727
+ history_records = _extract_export_records(history_page)
728
+ matched_record, matched_by = _match_export_history_record(history_records, local_job=local_job)
729
+ if process_payload is not None:
730
+ normalized_status = _normalize_export_status(process_payload.get("processStatus") or process_payload.get("status"))
731
+ if normalized_status in {"queued", "running"}:
732
+ return {
733
+ "status": normalized_status,
734
+ "process_status": _coerce_int(process_payload.get("processStatus") or process_payload.get("status")),
735
+ "num": _coerce_int(process_payload.get("num")),
736
+ "error_code": process_payload.get("errorCode"),
737
+ "audit_record_status": process_payload.get("auditRecordStatus"),
738
+ "file_infos": _normalize_export_file_infos(process_payload.get("fileUrls")),
739
+ "file_urls": _extract_export_file_urls(process_payload.get("fileUrls")),
740
+ "file_names": _extract_export_file_names(process_payload.get("fileUrls")),
741
+ "warnings": [],
742
+ "verification": {
743
+ "current_process_visible": True,
744
+ "history_match_resolved": matched_record is not None,
745
+ },
746
+ "message": None,
747
+ }
748
+ if matched_record is None:
749
+ warning_code = "EXPORT_HISTORY_PENDING"
750
+ warning_message = "export has not appeared in export history yet"
751
+ if matched_by == "ambiguous":
752
+ warning_code = "EXPORT_HISTORY_AMBIGUOUS"
753
+ warning_message = "export result could not be matched uniquely in export history"
754
+ return {
755
+ "status": "unknown",
756
+ "process_status": None,
757
+ "num": None,
758
+ "error_code": None,
759
+ "audit_record_status": None,
760
+ "file_infos": [],
761
+ "file_urls": [],
762
+ "file_names": [],
763
+ "warnings": [{"code": warning_code, "message": warning_message}],
764
+ "verification": {
765
+ "current_process_visible": process_payload is not None,
766
+ "history_match_resolved": False,
767
+ },
768
+ "message": warning_message,
769
+ }
770
+ file_infos = _normalize_export_file_infos(matched_record.get("fileUrls"))
771
+ raw_process_status = _coerce_int(matched_record.get("processStatus"))
772
+ normalized_status = _normalize_export_status(raw_process_status)
773
+ message = None
774
+ if normalized_status == "failed":
775
+ message = "export failed"
776
+ return {
777
+ "status": normalized_status,
778
+ "process_status": raw_process_status,
779
+ "num": _coerce_int(matched_record.get("num")),
780
+ "error_code": matched_record.get("errorCode"),
781
+ "audit_record_status": matched_record.get("auditRecordStatus"),
782
+ "file_infos": file_infos,
783
+ "file_urls": [item.get("url") for item in file_infos if isinstance(item.get("url"), str)],
784
+ "file_names": [item.get("name") for item in file_infos if isinstance(item.get("name"), str)],
785
+ "warnings": [],
786
+ "verification": {
787
+ "current_process_visible": process_payload is not None,
788
+ "history_match_resolved": True,
789
+ "matched_by": matched_by,
790
+ },
791
+ "message": message,
792
+ }
793
+
794
+ def _lookup_process_details(self, context, *, app_key: str) -> JSONObject | None: # type: ignore[no-untyped-def]
795
+ try:
796
+ payload = self.backend.request(
797
+ "GET",
798
+ context,
799
+ f"/process/{app_key}/details",
800
+ params={"taskType": 2},
801
+ )
802
+ except QingflowApiError as exc:
803
+ if exc.backend_code in {40002, 40027}:
804
+ return None
805
+ raise
806
+ if isinstance(payload, dict):
807
+ nested = payload.get("data")
808
+ if isinstance(nested, dict):
809
+ payload = nested
810
+ if isinstance(payload.get("detail"), dict):
811
+ return cast(JSONObject, payload.get("detail"))
812
+ if any(key in payload for key in ("processStatus", "progress", "num", "fileUrls", "errorCode")):
813
+ return cast(JSONObject, payload)
814
+ return None
815
+
816
+ def _status_payload_from_snapshot(
817
+ self,
818
+ local_job: dict[str, Any],
819
+ export_handle: str,
820
+ snapshot: dict[str, Any],
821
+ ) -> dict[str, Any]:
822
+ normalized_status = str(snapshot.get("status") or "unknown")
823
+ ok = normalized_status not in {"failed"} or snapshot.get("error_code") in (None, "", 0)
824
+ if normalized_status == "failed":
825
+ ok = False
826
+ return {
827
+ "ok": ok,
828
+ "status": normalized_status,
829
+ "export_handle": export_handle,
830
+ "app_key": str(local_job.get("app_key") or ""),
831
+ "view_id": str(local_job.get("view_id") or ""),
832
+ "row_scope": str(local_job.get("row_scope") or "all"),
833
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
834
+ "field_scope": str(local_job.get("field_scope") or "all"),
835
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
836
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
837
+ "num": snapshot.get("num"),
838
+ "process_status": snapshot.get("process_status"),
839
+ "error_code": snapshot.get("error_code"),
840
+ "audit_record_status": snapshot.get("audit_record_status"),
841
+ "file_urls": snapshot.get("file_urls") or [],
842
+ "file_names": snapshot.get("file_names") or [],
843
+ "downloaded_files": [],
844
+ "warnings": snapshot.get("warnings") or [],
845
+ "verification": snapshot.get("verification") or {},
846
+ "message": snapshot.get("message"),
847
+ }
848
+
849
+ def _download_export_files(
850
+ self,
851
+ *,
852
+ file_infos: list[JSONObject],
853
+ download_to_path: str | None,
854
+ default_directory: str | None,
855
+ app_key: str,
856
+ view_id: str,
857
+ ) -> list[JSONObject]:
858
+ if download_to_path is None and default_directory is None:
859
+ return []
860
+ effective_hint = download_to_path or default_directory
861
+ assert effective_hint is not None
862
+ targets = _resolve_download_targets(
863
+ effective_hint,
864
+ file_infos=file_infos,
865
+ app_key=app_key,
866
+ view_id=view_id,
867
+ )
868
+ downloaded_files: list[JSONObject] = []
869
+ for file_info, target in zip(file_infos, targets, strict=False):
870
+ url = str(file_info.get("url") or "").strip()
871
+ if not url:
872
+ continue
873
+ content = self.backend.download_binary(url)
874
+ target.parent.mkdir(parents=True, exist_ok=True)
875
+ target.write_bytes(content)
876
+ downloaded_files.append(
877
+ {
878
+ "file_name": str(file_info.get("name") or target.name),
879
+ "path": str(target),
880
+ "url": url,
881
+ }
882
+ )
883
+ return downloaded_files
884
+
885
+ def _normalize_timeout_seconds(self, wait_timeout_seconds: float | None) -> float:
886
+ if wait_timeout_seconds is None:
887
+ return DEFAULT_EXPORT_TIMEOUT_SECONDS
888
+ try:
889
+ value = float(wait_timeout_seconds)
890
+ except (TypeError, ValueError):
891
+ return DEFAULT_EXPORT_TIMEOUT_SECONDS
892
+ return value if value > 0 else DEFAULT_EXPORT_TIMEOUT_SECONDS
893
+
894
+ def _failed_export_result(
895
+ self,
896
+ *,
897
+ error_code: str,
898
+ message: str,
899
+ extra: dict[str, Any] | None = None,
900
+ ) -> dict[str, Any]:
901
+ payload = {
902
+ "ok": False,
903
+ "status": "failed",
904
+ "error_code": error_code,
905
+ "export_handle": None,
906
+ "app_key": None,
907
+ "view_id": None,
908
+ "num": None,
909
+ "process_status": None,
910
+ "audit_record_status": None,
911
+ "file_urls": [],
912
+ "file_names": [],
913
+ "downloaded_files": [],
914
+ "warnings": [],
915
+ "verification": {},
916
+ "message": message,
917
+ }
918
+ if extra:
919
+ payload.update(extra)
920
+ return payload
921
+
922
+ def _runtime_error_as_result(
923
+ self,
924
+ error: RuntimeError,
925
+ *,
926
+ error_code: str,
927
+ extra: dict[str, Any] | None = None,
928
+ ) -> dict[str, Any]:
929
+ try:
930
+ payload = json.loads(str(error))
931
+ except json.JSONDecodeError:
932
+ payload = {"message": str(error)}
933
+ response = self._failed_export_result(
934
+ error_code=((payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}).get("error_code") or error_code,
935
+ message=str(payload.get("message") or str(error)),
936
+ )
937
+ if extra:
938
+ response.update(extra)
939
+ return response
940
+
941
+
942
+ def _extract_export_records(payload: Any) -> list[JSONObject]:
943
+ if isinstance(payload, dict):
944
+ for key in ("list", "records", "items"):
945
+ value = payload.get(key)
946
+ if isinstance(value, list):
947
+ return [item for item in value if isinstance(item, dict)]
948
+ if isinstance(payload, list):
949
+ return [item for item in payload if isinstance(item, dict)]
950
+ return []
951
+
952
+
953
+ def _match_export_history_record(
954
+ records: list[JSONObject],
955
+ *,
956
+ local_job: dict[str, Any],
957
+ ) -> tuple[JSONObject | None, str | None]:
958
+ uid = _extract_operate_user_uid(local_job)
959
+ started_at = _parse_utc(local_job.get("started_at"))
960
+ candidates = records
961
+ if uid is not None:
962
+ candidates = [item for item in candidates if _extract_operate_user_uid(item.get("operateUser")) == uid]
963
+ if started_at is not None:
964
+ started_at = started_at.replace(microsecond=0)
965
+ parsed_candidates: list[tuple[JSONObject, datetime]] = []
966
+ for item in candidates:
967
+ operate_time = _parse_utc(item.get("operateTime")) or _parse_utc(item.get("operate_time"))
968
+ if operate_time is None:
969
+ continue
970
+ parsed_candidates.append((item, operate_time))
971
+ exact_or_after = [item for item, operate_time in parsed_candidates if operate_time >= started_at]
972
+ if len(exact_or_after) == 1:
973
+ return exact_or_after[0], "operate_user_started_at"
974
+ if len(exact_or_after) > 1:
975
+ return None, "ambiguous"
976
+ skew_tolerant = [
977
+ item
978
+ for item, operate_time in parsed_candidates
979
+ if operate_time >= (started_at - timedelta(seconds=5))
980
+ ]
981
+ if len(skew_tolerant) == 1:
982
+ return skew_tolerant[0], "operate_user_started_at_skew"
983
+ if len(skew_tolerant) > 1:
984
+ return None, "ambiguous"
985
+ candidates = []
986
+ if len(candidates) == 1:
987
+ return candidates[0], "operate_user_started_at"
988
+ if len(candidates) > 1:
989
+ return None, "ambiguous"
990
+ return None, None
991
+
992
+
993
+ def _normalize_export_status(value: Any) -> str:
994
+ status_code = _coerce_int(value)
995
+ if status_code is not None:
996
+ return EXPORT_STATUS_BY_PROCESS_STATUS.get(status_code, "unknown")
997
+ text = str(value or "").strip().lower()
998
+ if text in {"queued", "running", "succeeded", "failed", "unknown"}:
999
+ return text
1000
+ if text in {"line_up", "lineup"}:
1001
+ return "queued"
1002
+ if text in {"execute", "executing", "processing"}:
1003
+ return "running"
1004
+ if text in {"success", "completed"}:
1005
+ return "succeeded"
1006
+ if text in {"fail", "partly_fail", "partial_fail"}:
1007
+ return "failed"
1008
+ return "unknown"
1009
+
1010
+
1011
+ def _normalize_export_file_infos(value: Any) -> list[JSONObject]:
1012
+ if not isinstance(value, list):
1013
+ return []
1014
+ items: list[JSONObject] = []
1015
+ for item in value:
1016
+ if not isinstance(item, dict):
1017
+ continue
1018
+ url = str(item.get("url") or "").strip()
1019
+ name = str(item.get("name") or item.get("fileName") or "").strip()
1020
+ payload: JSONObject = {}
1021
+ if url:
1022
+ payload["url"] = url
1023
+ if name:
1024
+ payload["name"] = name
1025
+ if payload:
1026
+ items.append(payload)
1027
+ return items
1028
+
1029
+
1030
+ def _extract_export_file_urls(value: Any) -> list[str]:
1031
+ return [str(item.get("url") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("url") or "").strip()]
1032
+
1033
+
1034
+ def _extract_export_file_names(value: Any) -> list[str]:
1035
+ return [str(item.get("name") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("name") or "").strip()]
1036
+
1037
+
1038
+ def _extract_operate_user_uid(value: Any) -> int | None:
1039
+ if isinstance(value, dict):
1040
+ for key in ("uid", "userId", "id"):
1041
+ uid = _coerce_int(value.get(key))
1042
+ if uid is not None:
1043
+ return uid
1044
+ return _coerce_int(value)
1045
+
1046
+
1047
+ def _parse_utc(value: Any) -> datetime | None:
1048
+ text = str(value or "").strip()
1049
+ if not text:
1050
+ return None
1051
+ normalized = text.replace("Z", "+00:00")
1052
+ try:
1053
+ parsed = datetime.fromisoformat(normalized)
1054
+ except ValueError:
1055
+ return None
1056
+ if parsed.tzinfo is None:
1057
+ return parsed.replace(tzinfo=LOCAL_TIMEZONE).astimezone(timezone.utc)
1058
+ return parsed.astimezone(timezone.utc)
1059
+
1060
+
1061
+ def _coerce_int(value: Any) -> int | None:
1062
+ if value is None or value == "":
1063
+ return None
1064
+ try:
1065
+ return int(value)
1066
+ except (TypeError, ValueError):
1067
+ return None
1068
+
1069
+
1070
+ def _normalize_export_columns(columns: list[JSONObject | int]) -> list[int]:
1071
+ normalized: list[int] = []
1072
+ for field_id in _normalize_public_column_selectors(columns):
1073
+ if field_id not in normalized:
1074
+ normalized.append(field_id)
1075
+ return normalized
1076
+
1077
+
1078
+ def _normalize_export_record_ids(record_ids: list[str | int]) -> list[int]:
1079
+ normalized: list[int] = []
1080
+ seen: set[int] = set()
1081
+ for item in record_ids:
1082
+ record_id = _coerce_int(item)
1083
+ if record_id is None or record_id <= 0 or record_id in seen:
1084
+ continue
1085
+ normalized.append(record_id)
1086
+ seen.add(record_id)
1087
+ return normalized
1088
+
1089
+
1090
+ def _effective_total(page: JSONObject, *, page_size: int) -> int:
1091
+ rows = page.get("list")
1092
+ returned_rows = len(rows) if isinstance(rows, list) else 0
1093
+ reported = _coerce_int(page.get("total"))
1094
+ if reported is None:
1095
+ reported = _coerce_int(page.get("count"))
1096
+ if reported is not None:
1097
+ return max(reported, returned_rows)
1098
+ page_amount = _coerce_int(page.get("pageAmount"))
1099
+ if page_amount is not None:
1100
+ return page_amount * page_size
1101
+ return returned_rows
1102
+
1103
+
1104
+ def _resolve_download_targets(
1105
+ destination_hint: str,
1106
+ *,
1107
+ file_infos: list[JSONObject],
1108
+ app_key: str,
1109
+ view_id: str,
1110
+ ) -> list[Path]:
1111
+ path = Path(destination_hint).expanduser()
1112
+ timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
1113
+ if len(file_infos) > 1:
1114
+ if path.exists() and not path.is_dir():
1115
+ raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
1116
+ if not path.exists() and path.suffix:
1117
+ raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
1118
+ path.mkdir(parents=True, exist_ok=True)
1119
+ return [
1120
+ path / _choose_export_file_name(
1121
+ file_info,
1122
+ app_key=app_key,
1123
+ view_id=view_id,
1124
+ timestamp=timestamp,
1125
+ index=index,
1126
+ )
1127
+ for index, file_info in enumerate(file_infos, start=1)
1128
+ ]
1129
+ if path.exists() and path.is_dir():
1130
+ return [
1131
+ path / _choose_export_file_name(
1132
+ file_infos[0],
1133
+ app_key=app_key,
1134
+ view_id=view_id,
1135
+ timestamp=timestamp,
1136
+ index=1,
1137
+ )
1138
+ ]
1139
+ if path.suffix or path.exists():
1140
+ return [path]
1141
+ path.mkdir(parents=True, exist_ok=True)
1142
+ return [
1143
+ path / _choose_export_file_name(
1144
+ file_infos[0],
1145
+ app_key=app_key,
1146
+ view_id=view_id,
1147
+ timestamp=timestamp,
1148
+ index=1,
1149
+ )
1150
+ ]
1151
+
1152
+
1153
+ def _choose_export_file_name(
1154
+ file_info: JSONObject,
1155
+ *,
1156
+ app_key: str,
1157
+ view_id: str,
1158
+ timestamp: str,
1159
+ index: int,
1160
+ ) -> str:
1161
+ remote_name = str(file_info.get("name") or "").strip()
1162
+ if remote_name:
1163
+ sanitized = _sanitize_filename(remote_name)
1164
+ if sanitized:
1165
+ return sanitized
1166
+ suffix = ".xlsx"
1167
+ safe_view = _sanitize_filename(view_id.replace(":", "_")) or "view"
1168
+ return f"{_sanitize_filename(app_key) or 'app'}_{safe_view}_{timestamp}_{index}{suffix}"
1169
+
1170
+
1171
+ def _sanitize_filename(value: str) -> str:
1172
+ base = _SAFE_FILE_CHARS.sub("_", value).strip("._")
1173
+ return base or ""
1174
+
1175
+
1176
+ def _utc_now() -> datetime:
1177
+ return datetime.now(timezone.utc)