#!/usr/bin/env python3 """有界分页列出今天或最近几天收到的日志摘要。 脚本只读取列表投影,不再为每条日志调用 ``entry get``。需要正文时,调用方应 从结果中选择明确的 reportId,再单独读取那一条,避免列表任务退化成 N+1。 """ from __future__ import annotations import argparse import json import subprocess import sys import time from datetime import datetime, timedelta from typing import Any, NamedTuple from zoneinfo import ZoneInfo SHANGHAI = ZoneInfo("Asia/Shanghai") PAGE_SIZE = 20 DEFAULT_MAX_PAGES = 10 HARD_MAX_PAGES = 10 MAX_REPORTS = PAGE_SIZE * HARD_MAX_PAGES DEFAULT_DISPLAY_LIMIT = 20 PER_COMMAND_TIMEOUT_SECONDS = 60 TOTAL_TIMEOUT_SECONDS = 120 MAX_ERROR_DETAIL_CHARS = 4096 class ReportCommandError(RuntimeError): """DWS 执行或响应契约失败,不能降级成合法空结果。""" class InboxScanResult(NamedTuple): """完整扫描证据与受展示上限约束的摘要。""" total_count: int visible_items: list[dict[str, Any]] def query_window(days: int, now: datetime | None = None) -> tuple[datetime, datetime]: """冻结查询时间窗,并保证午夜调度也得到严格递增的范围。""" current = now or datetime.now(SHANGHAI) start = (current - timedelta(days=days - 1)).replace( hour=0, minute=0, second=0, microsecond=0 ) end = current.replace(microsecond=0) if end <= start: end = start + timedelta(seconds=1) return start, end def format_create_time(value: Any) -> str: """把服务端 epoch 毫秒转换为带时区的可读时间,未知形态如实保留。""" if isinstance(value, (int, float)) and not isinstance(value, bool): return datetime.fromtimestamp(value / 1000, SHANGHAI).strftime( "%Y-%m-%d %H:%M:%S %z" ) return str(value or "") def clip_detail(value: Any, limit: int = MAX_ERROR_DETAIL_CHARS) -> str: """把诊断压到固定上限,避免响应正文进入错误日志或模型上下文。""" if isinstance(value, (dict, list)): text = json.dumps(value, ensure_ascii=False, separators=(",", ":")) else: text = str(value or "") text = text.strip() if len(text) <= limit: return text return text[: limit - 14] + "…[已截断]" def process_error_detail(result: subprocess.CompletedProcess[str]) -> str: """优先保留结构化错误与 stderr;原始 stdout 仅做有界兜底。""" parts: list[str] = [] try: payload = json.loads(result.stdout) except (json.JSONDecodeError, TypeError): payload = None if isinstance(payload, dict): structured = payload.get("error") or payload.get("message") if structured: parts.append("error=" + clip_detail(structured, 2048)) stderr = clip_detail(result.stderr, 2048) if stderr: parts.append("stderr=" + stderr) if not parts: stdout = clip_detail(result.stdout, 2048) if stdout: parts.append("stdout=" + stdout) return clip_detail("; ".join(parts)) or "无错误详情" def run_dws( args: list[str], *, dry_run: bool = False, timeout_seconds: float = 60 ) -> Any | None: cmd = ["dws", *args] if dry_run: print("[dry-run] " + " ".join(cmd)) return None try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout_seconds ) except (subprocess.TimeoutExpired, FileNotFoundError) as exc: raise ReportCommandError(f"DWS 执行失败: {exc}") from exc if result.returncode != 0: raise ReportCommandError( f"DWS 返回非零状态 exit={result.returncode}: " f"{process_error_detail(result)}" ) try: return json.loads(result.stdout) except json.JSONDecodeError as exc: raise ReportCommandError(f"DWS 返回的不是合法 JSON: {exc}") from exc def parse_inbox_page( payload: Any, current_cursor: int ) -> tuple[list[dict[str, Any]], int | None]: if not isinstance(payload, dict): raise ReportCommandError( f"收件箱响应应为对象,实际为 {type(payload).__name__}" ) if payload.get("ok") is not True or payload.get("outcome") != "success": error = payload.get("error") raise ReportCommandError( "收件箱调用未成功: " + clip_detail(error or payload) ) data = payload.get("data") if not isinstance(data, dict): raise ReportCommandError("收件箱成功响应缺少 data 对象") reports = data.get("reports") if not isinstance(reports, list) or any( not isinstance(item, dict) for item in reports ): raise ReportCommandError("收件箱 data.reports 必须是对象数组") count = data.get("count") if ( not isinstance(count, int) or isinstance(count, bool) or count != len(reports) ): raise ReportCommandError("收件箱 count 与 reports 数量不一致") complete = data.get("complete") if not isinstance(complete, bool): raise ReportCommandError("收件箱响应缺少布尔 complete") meta = payload.get("meta") pagination = meta.get("pagination") if isinstance(meta, dict) else None if not isinstance(pagination, dict): raise ReportCommandError("收件箱响应缺少 meta.pagination") exhausted = pagination.get("endpoint_exhausted") if not isinstance(exhausted, bool) or exhausted != complete: raise ReportCommandError("收件箱 data.complete 与分页终止证据冲突") if exhausted: return reports, None raw_next = pagination.get("next_token") try: next_cursor = int(raw_next) except (TypeError, ValueError) as exc: raise ReportCommandError("收件箱续页缺少整数 next_token") from exc if next_cursor <= current_cursor: raise ReportCommandError("收件箱 continuation cursor 没有严格前进") return reports, next_cursor def scan_inbox( start: datetime, end: datetime, max_pages: int, *, display_limit: int = DEFAULT_DISPLAY_LIMIT, total_timeout_seconds: float = TOTAL_TIMEOUT_SECONDS, ) -> InboxScanResult: if not 1 <= display_limit <= MAX_REPORTS: raise ReportCommandError( f"展示上限必须在 1..{MAX_REPORTS} 之间" ) cursor = 0 total_count = 0 visible_items: list[dict[str, Any]] = [] seen: dict[str, Any] = {} deadline = time.monotonic() + total_timeout_seconds for _ in range(max_pages): remaining = deadline - time.monotonic() if remaining <= 0: raise ReportCommandError( f"收件箱分页超过总时限 {total_timeout_seconds:g} 秒" ) payload = run_dws([ "report", "+inbox-list", "--start", start.isoformat(timespec="seconds"), "--end", end.isoformat(timespec="seconds"), "--cursor", str(cursor), "--size", str(PAGE_SIZE), "--format", "json", ], timeout_seconds=max( 0.1, min(PER_COMMAND_TIMEOUT_SECONDS, remaining) )) page, next_cursor = parse_inbox_page(payload, cursor) for item in page: report_id = item.get("reportId") if not isinstance(report_id, str) or not report_id.strip(): raise ReportCommandError("收件箱条目缺少稳定 reportId") created = item.get("createTime") if report_id in seen: if seen[report_id] != created: raise ReportCommandError( f"收件箱重复 reportId 的 createTime 冲突: {report_id}" ) continue if total_count >= MAX_REPORTS: raise ReportCommandError( f"收件箱结果超过有界条数上限 {MAX_REPORTS}" ) seen[report_id] = created total_count += 1 if len(visible_items) < display_limit: visible_items.append(item) if next_cursor is None: return InboxScanResult(total_count, visible_items) cursor = next_cursor raise ReportCommandError( f"达到 --max-pages={max_pages} 时收件箱仍有后续页;" "拒绝把部分结果伪装成完整列表" ) def main() -> int: parser = argparse.ArgumentParser(description="查看收到的日志摘要") parser.add_argument( "--days", type=int, default=1, help="查询天数(默认 1)" ) parser.add_argument( "--max-pages", type=int, default=DEFAULT_MAX_PAGES, help=f"最大分页数(默认 {DEFAULT_MAX_PAGES},范围 1..{HARD_MAX_PAGES})", ) parser.add_argument( "--display-limit", type=int, default=DEFAULT_DISPLAY_LIMIT, help=f"最多展开的摘要数(默认 {DEFAULT_DISPLAY_LIMIT},范围 1..{MAX_REPORTS})", ) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() if args.days < 1: parser.error("--days must be >= 1") if not 1 <= args.max_pages <= HARD_MAX_PAGES: parser.error( f"--max-pages must be between 1 and {HARD_MAX_PAGES}" ) if not 1 <= args.display_limit <= MAX_REPORTS: parser.error( f"--display-limit must be between 1 and {MAX_REPORTS}" ) # 以调用开始时刻冻结查询窗,避免分页过程中把未来新增条目插进结果集。 start, end = query_window(args.days) label = "今天" if args.days == 1 else f"最近 {args.days} 天" if args.dry_run: run_dws([ "report", "+inbox-list", "--start", start.isoformat(timespec="seconds"), "--end", end.isoformat(timespec="seconds"), "--cursor", "0", "--size", str(PAGE_SIZE), "--format", "json", ], dry_run=True) return 0 scan = scan_inbox( start, end, args.max_pages, display_limit=args.display_limit ) if scan.total_count == 0: print(f"{label}暂无收到的日志") return 0 print(f"{label}收到的日志({scan.total_count} 条,已完成分页)") for item in scan.visible_items: creator = ( item.get("creatorName") or item.get("creatorUserId") or "未知创建人" ) template = item.get("templateName") or "日志" print( f"- {template} | {creator} | " f"{format_create_time(item.get('createTime'))} | {item['reportId']}" ) if len(scan.visible_items) < scan.total_count: print( f"另有 {scan.total_count - len(scan.visible_items)} 条未展开;" f"需要时用 --display-limit {min(scan.total_count, MAX_REPORTS)} 显示。" ) print( "需要正文时,请选择上面的明确 reportId " "再执行 dws report entry get。" ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except ReportCommandError as exc: print(f"错误:{exc}", file=sys.stderr) raise SystemExit(2) from exc