|
@@ -18,7 +18,9 @@ import time
|
|
|
import traceback
|
|
import traceback
|
|
|
from datetime import datetime, timezone
|
|
from datetime import datetime, timezone
|
|
|
from logging.handlers import RotatingFileHandler
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
|
+from typing import Callable, Mapping
|
|
|
|
|
|
|
|
|
|
+import pipeline
|
|
|
from pps_client import PPSClient, PPSError
|
|
from pps_client import PPSClient, PPSError
|
|
|
|
|
|
|
|
log = logging.getLogger("pps.worker")
|
|
log = logging.getLogger("pps.worker")
|
|
@@ -50,11 +52,15 @@ def _now() -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
class JobQueue:
|
|
class JobQueue:
|
|
|
- def __init__(self, db_path: str, pps: PPSClient, qconfig: dict,
|
|
|
|
|
|
|
+ def __init__(self, db_path: str,
|
|
|
|
|
+ pps_provider: Callable[[], PPSClient],
|
|
|
|
|
+ cfg_provider: Callable[[], Mapping],
|
|
|
ops_log_path: str = "worker.log"):
|
|
ops_log_path: str = "worker.log"):
|
|
|
- self.pps = pps
|
|
|
|
|
- self.cfg = qconfig
|
|
|
|
|
- self.chunk_size = int(qconfig.get("chunk_size", 25))
|
|
|
|
|
|
|
+ # Providers, not frozen values: each job reads a fresh, consistent snapshot at
|
|
|
|
|
+ # start (see AGENTS.md "snapshot-per-job"), so a live admin edit affects the
|
|
|
|
|
+ # NEXT job, never a job mid-flight.
|
|
|
|
|
+ self._pps = pps_provider
|
|
|
|
|
+ self._cfg = cfg_provider
|
|
|
self._lock = threading.Lock()
|
|
self._lock = threading.Lock()
|
|
|
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.row_factory = sqlite3.Row
|
|
@@ -68,12 +74,18 @@ class JobQueue:
|
|
|
|
|
|
|
|
def _init_db(self) -> None:
|
|
def _init_db(self) -> None:
|
|
|
with self._lock:
|
|
with self._lock:
|
|
|
|
|
+ # WAL + busy_timeout so the prefs store (a second connection to this same
|
|
|
|
|
+ # file) never collides with per-chunk progress writes. WAL is a persistent
|
|
|
|
|
+ # property of the file; busy_timeout must be set on every connection.
|
|
|
|
|
+ self._conn.execute("PRAGMA journal_mode=WAL")
|
|
|
|
|
+ self._conn.execute("PRAGMA busy_timeout=5000")
|
|
|
self._conn.executescript(
|
|
self._conn.executescript(
|
|
|
"""
|
|
"""
|
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
action TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
|
folder TEXT NOT NULL,
|
|
folder TEXT NOT NULL,
|
|
|
|
|
+ user TEXT,
|
|
|
extra TEXT NOT NULL DEFAULT '{}',
|
|
extra TEXT NOT NULL DEFAULT '{}',
|
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
|
processed INTEGER NOT NULL DEFAULT 0,
|
|
processed INTEGER NOT NULL DEFAULT 0,
|
|
@@ -94,13 +106,17 @@ class JobQueue:
|
|
|
);
|
|
);
|
|
|
CREATE INDEX IF NOT EXISTS idx_job_items_folder ON job_items(folder);
|
|
CREATE INDEX IF NOT EXISTS idx_job_items_folder ON job_items(folder);
|
|
|
CREATE INDEX IF NOT EXISTS idx_job_items_job ON job_items(job_id);
|
|
CREATE INDEX IF NOT EXISTS idx_job_items_job ON job_items(job_id);
|
|
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_jobs_user ON jobs(user);
|
|
|
"""
|
|
"""
|
|
|
)
|
|
)
|
|
|
- # Migrate older DBs that lack the display columns.
|
|
|
|
|
- existing = {r["name"] for r in self._conn.execute("PRAGMA table_info(job_items)")}
|
|
|
|
|
|
|
+ # Migrate older DBs that lack later-added columns.
|
|
|
|
|
+ item_cols = {r["name"] for r in self._conn.execute("PRAGMA table_info(job_items)")}
|
|
|
for col in ("subject", "sender", "recipient"):
|
|
for col in ("subject", "sender", "recipient"):
|
|
|
- if col not in existing:
|
|
|
|
|
|
|
+ if col not in item_cols:
|
|
|
self._conn.execute(f"ALTER TABLE job_items ADD COLUMN {col} TEXT")
|
|
self._conn.execute(f"ALTER TABLE job_items ADD COLUMN {col} TEXT")
|
|
|
|
|
+ job_cols = {r["name"] for r in self._conn.execute("PRAGMA table_info(jobs)")}
|
|
|
|
|
+ if "user" not in job_cols:
|
|
|
|
|
+ self._conn.execute("ALTER TABLE jobs ADD COLUMN user TEXT")
|
|
|
self._conn.commit()
|
|
self._conn.commit()
|
|
|
|
|
|
|
|
def _recover(self) -> None:
|
|
def _recover(self) -> None:
|
|
@@ -116,17 +132,19 @@ class JobQueue:
|
|
|
|
|
|
|
|
# -------------------------------------------------------------- public API
|
|
# -------------------------------------------------------------- public API
|
|
|
|
|
|
|
|
- def enqueue(self, action: str, folder: str, items: list[dict], extra: dict) -> int:
|
|
|
|
|
|
|
+ def enqueue(self, action: str, folder: str, items: list[dict], extra: dict,
|
|
|
|
|
+ user: str | None = None) -> int:
|
|
|
"""Insert a job. `items` is a list of dicts with keys:
|
|
"""Insert a job. `items` is a list of dicts with keys:
|
|
|
- localguid (required), guid, subject, sender, recipient (all optional)."""
|
|
|
|
|
|
|
+ localguid (required), guid, subject, sender, recipient (all optional).
|
|
|
|
|
+ `user` is the acting user's email, recorded for attribution/visibility."""
|
|
|
if action not in VALID_ACTIONS:
|
|
if action not in VALID_ACTIONS:
|
|
|
raise ValueError(f"unknown action: {action}")
|
|
raise ValueError(f"unknown action: {action}")
|
|
|
now = _now()
|
|
now = _now()
|
|
|
with self._lock:
|
|
with self._lock:
|
|
|
cur = self._conn.execute(
|
|
cur = self._conn.execute(
|
|
|
- "INSERT INTO jobs (action, folder, extra, status, total, created_at, updated_at)"
|
|
|
|
|
- " VALUES (?,?,?,?,?,?,?)",
|
|
|
|
|
- (action, folder, json.dumps(extra or {}), "pending", len(items), now, now),
|
|
|
|
|
|
|
+ "INSERT INTO jobs (action, folder, user, extra, status, total, created_at, updated_at)"
|
|
|
|
|
+ " VALUES (?,?,?,?,?,?,?,?)",
|
|
|
|
|
+ (action, folder, user, json.dumps(extra or {}), "pending", len(items), now, now),
|
|
|
)
|
|
)
|
|
|
job_id = cur.lastrowid
|
|
job_id = cur.lastrowid
|
|
|
self._conn.executemany(
|
|
self._conn.executemany(
|
|
@@ -139,7 +157,8 @@ class JobQueue:
|
|
|
],
|
|
],
|
|
|
)
|
|
)
|
|
|
self._conn.commit()
|
|
self._conn.commit()
|
|
|
- log.info("enqueued job #%d: action=%s folder=%r items=%d", job_id, action, folder, len(items))
|
|
|
|
|
|
|
+ log.info("enqueued job #%d: action=%s folder=%r user=%s items=%d",
|
|
|
|
|
+ job_id, action, folder, user, len(items))
|
|
|
return job_id
|
|
return job_id
|
|
|
|
|
|
|
|
def acted_localguids(self, folder: str) -> set[str]:
|
|
def acted_localguids(self, folder: str) -> set[str]:
|
|
@@ -152,13 +171,20 @@ class JobQueue:
|
|
|
).fetchall()
|
|
).fetchall()
|
|
|
return {r["localguid"] for r in rows}
|
|
return {r["localguid"] for r in rows}
|
|
|
|
|
|
|
|
- def recent_jobs(self, limit: int = 25) -> list[dict]:
|
|
|
|
|
|
|
+ def recent_jobs(self, limit: int = 25, user: str | None = None) -> list[dict]:
|
|
|
|
|
+ """Recent jobs, newest first. Pass `user` to scope to one owner (None = all)."""
|
|
|
|
|
+ cols = ("id, action, folder, user, status, processed, total, error,"
|
|
|
|
|
+ " created_at, updated_at")
|
|
|
with self._lock:
|
|
with self._lock:
|
|
|
- rows = self._conn.execute(
|
|
|
|
|
- "SELECT id, action, folder, status, processed, total, error, created_at, updated_at"
|
|
|
|
|
- " FROM jobs ORDER BY id DESC LIMIT ?",
|
|
|
|
|
- (limit,),
|
|
|
|
|
- ).fetchall()
|
|
|
|
|
|
|
+ if user is None:
|
|
|
|
|
+ rows = self._conn.execute(
|
|
|
|
|
+ f"SELECT {cols} FROM jobs ORDER BY id DESC LIMIT ?", (limit,)
|
|
|
|
|
+ ).fetchall()
|
|
|
|
|
+ else:
|
|
|
|
|
+ rows = self._conn.execute(
|
|
|
|
|
+ f"SELECT {cols} FROM jobs WHERE user = ? ORDER BY id DESC LIMIT ?",
|
|
|
|
|
+ (user, limit),
|
|
|
|
|
+ ).fetchall()
|
|
|
return [dict(r) for r in rows]
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
|
|
def has_active_jobs(self) -> bool:
|
|
def has_active_jobs(self) -> bool:
|
|
@@ -168,6 +194,17 @@ class JobQueue:
|
|
|
).fetchone()
|
|
).fetchone()
|
|
|
return row is not None
|
|
return row is not None
|
|
|
|
|
|
|
|
|
|
+ def retry(self, job_id: int) -> bool:
|
|
|
|
|
+ """Requeue a failed job. Returns False if it isn't a failed job."""
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ cur = self._conn.execute(
|
|
|
|
|
+ "UPDATE jobs SET status='pending', processed=0, error=NULL, updated_at=?"
|
|
|
|
|
+ " WHERE id=? AND status='failed'",
|
|
|
|
|
+ (_now(), job_id),
|
|
|
|
|
+ )
|
|
|
|
|
+ self._conn.commit()
|
|
|
|
|
+ return cur.rowcount > 0
|
|
|
|
|
+
|
|
|
# -------------------------------------------------------------- worker loop
|
|
# -------------------------------------------------------------- worker loop
|
|
|
|
|
|
|
|
def start(self) -> None:
|
|
def start(self) -> None:
|
|
@@ -206,54 +243,57 @@ class JobQueue:
|
|
|
job_id = job["id"]
|
|
job_id = job["id"]
|
|
|
action = job["action"]
|
|
action = job["action"]
|
|
|
folder = job["folder"]
|
|
folder = job["folder"]
|
|
|
|
|
+ user = job["user"] if "user" in job.keys() else None
|
|
|
extra = json.loads(job["extra"] or "{}")
|
|
extra = json.loads(job["extra"] or "{}")
|
|
|
items = self._items_for(job_id)
|
|
items = self._items_for(job_id)
|
|
|
|
|
+
|
|
|
|
|
+ # Snapshot config + client ONCE at job start — a mid-job admin edit affects the
|
|
|
|
|
+ # next job, not this one (see AGENTS.md "snapshot-per-job").
|
|
|
|
|
+ cfg = self._cfg()
|
|
|
|
|
+ pps = self._pps()
|
|
|
|
|
+ chunk_size = int(cfg.get("chunk_size", 25))
|
|
|
log.info(
|
|
log.info(
|
|
|
- "job #%d start: action=%s folder=%r total=%d", job_id, action, folder, len(items)
|
|
|
|
|
|
|
+ "job #%d start: action=%s folder=%r user=%s total=%d",
|
|
|
|
|
+ job_id, action, folder, user, len(items),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- dst = self._destination(action, extra)
|
|
|
|
|
|
|
+ dst = _destination(action, extra, cfg)
|
|
|
errors: list[str] = []
|
|
errors: list[str] = []
|
|
|
processed = 0
|
|
processed = 0
|
|
|
- for chunk in _chunks(items, self.chunk_size):
|
|
|
|
|
|
|
+ started = time.monotonic()
|
|
|
|
|
+ for chunk in _chunks(items, chunk_size):
|
|
|
try:
|
|
try:
|
|
|
- self._run_action(action, folder, chunk, extra)
|
|
|
|
|
|
|
+ self._run_action(pps, cfg, action, folder, chunk, extra)
|
|
|
processed += len(chunk)
|
|
processed += len(chunk)
|
|
|
self._set_processed(job_id, processed)
|
|
self._set_processed(job_id, processed)
|
|
|
- self._log_ops(job_id, action, folder, dst, chunk, "ok", None)
|
|
|
|
|
|
|
+ self._log_ops(job_id, action, folder, dst, chunk, user, "ok", None)
|
|
|
except PPSError as exc:
|
|
except PPSError as exc:
|
|
|
log.error("job #%d chunk failed: %s", job_id, exc)
|
|
log.error("job #%d chunk failed: %s", job_id, exc)
|
|
|
errors.append(f"chunk failed: {exc}")
|
|
errors.append(f"chunk failed: {exc}")
|
|
|
- self._log_ops(job_id, action, folder, dst, chunk, "FAILED", str(exc))
|
|
|
|
|
|
|
+ self._log_ops(job_id, action, folder, dst, chunk, user, "FAILED", str(exc))
|
|
|
except Exception as exc: # noqa: BLE001
|
|
except Exception as exc: # noqa: BLE001
|
|
|
log.exception("job #%d chunk raised", job_id)
|
|
log.exception("job #%d chunk raised", job_id)
|
|
|
errors.append(f"chunk failed: {exc}")
|
|
errors.append(f"chunk failed: {exc}")
|
|
|
- self._log_ops(job_id, action, folder, dst, chunk, "FAILED", str(exc))
|
|
|
|
|
|
|
+ self._log_ops(job_id, action, folder, dst, chunk, user, "FAILED", str(exc))
|
|
|
|
|
|
|
|
|
|
+ took = _fmt_duration(time.monotonic() - started)
|
|
|
if errors:
|
|
if errors:
|
|
|
- log.error("job #%d FAILED: %d/%d processed, %d chunk error(s)",
|
|
|
|
|
- job_id, processed, len(items), len(errors))
|
|
|
|
|
|
|
+ log.error("job #%d FAILED: %d/%d processed, %d chunk error(s), took %s",
|
|
|
|
|
+ job_id, processed, len(items), len(errors), took)
|
|
|
self._mark_failed(job_id, "\n".join(errors))
|
|
self._mark_failed(job_id, "\n".join(errors))
|
|
|
else:
|
|
else:
|
|
|
- log.info("job #%d done: %d/%d processed", job_id, processed, len(items))
|
|
|
|
|
|
|
+ log.info("job #%d done: %d/%d processed, took %s",
|
|
|
|
|
+ job_id, processed, len(items), took)
|
|
|
self._mark_done(job_id)
|
|
self._mark_done(job_id)
|
|
|
|
|
|
|
|
- def _destination(self, action: str, extra: dict) -> str | None:
|
|
|
|
|
- """The folder messages end up in, for the ops log."""
|
|
|
|
|
- if action == "move":
|
|
|
|
|
- return extra.get("targetfolder")
|
|
|
|
|
- if action == "report_release":
|
|
|
|
|
- return self.cfg.get("report_release_folder")
|
|
|
|
|
- if action in ("release", "delete"):
|
|
|
|
|
- return self.cfg.get("deleted_folder")
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
def _log_ops(self, job_id: int, action: str, src: str, dst: str | None,
|
|
def _log_ops(self, job_id: int, action: str, src: str, dst: str | None,
|
|
|
- chunk: list[dict], result: str, error: str | None) -> None:
|
|
|
|
|
|
|
+ chunk: list[dict], user: str | None,
|
|
|
|
|
+ result: str, error: str | None) -> None:
|
|
|
"""Write one audit line per message in the chunk to the operations log."""
|
|
"""Write one audit line per message in the chunk to the operations log."""
|
|
|
for it in chunk:
|
|
for it in chunk:
|
|
|
fields = [
|
|
fields = [
|
|
|
f"job=#{job_id}",
|
|
f"job=#{job_id}",
|
|
|
|
|
+ f"user={user or '-'}",
|
|
|
f"action={action}",
|
|
f"action={action}",
|
|
|
f"result={result}",
|
|
f"result={result}",
|
|
|
f"src={src!r}",
|
|
f"src={src!r}",
|
|
@@ -270,72 +310,30 @@ class JobQueue:
|
|
|
|
|
|
|
|
# -------------------------------------------------------------- action logic
|
|
# -------------------------------------------------------------- action logic
|
|
|
|
|
|
|
|
- def _run_action(self, action: str, folder: str, chunk: list[dict], extra: dict) -> None:
|
|
|
|
|
|
|
+ def _run_action(self, pps: PPSClient, cfg: Mapping, action: str,
|
|
|
|
|
+ folder: str, chunk: list[dict], extra: dict) -> None:
|
|
|
localguids = [it["localguid"] for it in chunk]
|
|
localguids = [it["localguid"] for it in chunk]
|
|
|
- deleted_folder = self.cfg.get("deleted_folder")
|
|
|
|
|
|
|
+ deleted_folder = cfg.get("deleted_folder")
|
|
|
|
|
|
|
|
if action == "release":
|
|
if action == "release":
|
|
|
- self.pps.act("release", folder, localguids, deletedfolder=deleted_folder)
|
|
|
|
|
|
|
+ pps.act("release", folder, localguids, deletedfolder=deleted_folder)
|
|
|
|
|
|
|
|
elif action == "delete":
|
|
elif action == "delete":
|
|
|
- self.pps.act("delete", folder, localguids, deletedfolder=deleted_folder)
|
|
|
|
|
|
|
+ pps.act("delete", folder, localguids, deletedfolder=deleted_folder)
|
|
|
|
|
|
|
|
elif action == "move":
|
|
elif action == "move":
|
|
|
target = extra.get("targetfolder")
|
|
target = extra.get("targetfolder")
|
|
|
if not target:
|
|
if not target:
|
|
|
raise ValueError("move action requires targetfolder")
|
|
raise ValueError("move action requires targetfolder")
|
|
|
- self.pps.act("move", folder, localguids, targetfolder=target)
|
|
|
|
|
|
|
+ pps.act("move", folder, localguids, targetfolder=target)
|
|
|
|
|
|
|
|
elif action == "report_release":
|
|
elif action == "report_release":
|
|
|
- self._report_release(folder, chunk)
|
|
|
|
|
|
|
+ # Data-driven pipeline (release -> move -> delete, configurable subset).
|
|
|
|
|
+ pipeline.run_pipeline(pps, cfg, folder, chunk)
|
|
|
|
|
|
|
|
else:
|
|
else:
|
|
|
raise ValueError(f"unknown action: {action}")
|
|
raise ValueError(f"unknown action: {action}")
|
|
|
|
|
|
|
|
- def _report_release(self, folder: str, chunk: list[dict]) -> None:
|
|
|
|
|
- """Release in place (delivers, keeps message), then move a copy to the report folder.
|
|
|
|
|
-
|
|
|
|
|
- Fallback: if release relocated the message to the deleted folder (GUI-like
|
|
|
|
|
- behavior), the move from `folder` fails; re-find each message in the deleted
|
|
|
|
|
- folder by its stable guid and move it from there instead.
|
|
|
|
|
- """
|
|
|
|
|
- target = self.cfg.get("report_release_folder")
|
|
|
|
|
- localguids = [it["localguid"] for it in chunk]
|
|
|
|
|
-
|
|
|
|
|
- # Step 1: release without deletedfolder -> message stays put, localguid stable.
|
|
|
|
|
- self.pps.act("release", folder, localguids)
|
|
|
|
|
-
|
|
|
|
|
- # Step 2: move the (now released) copy into the report folder.
|
|
|
|
|
- try:
|
|
|
|
|
- self.pps.act("move", folder, localguids, targetfolder=target)
|
|
|
|
|
- except PPSError:
|
|
|
|
|
- self._move_from_deleted_by_guid(chunk, target)
|
|
|
|
|
-
|
|
|
|
|
- def _move_from_deleted_by_guid(self, chunk: list[dict], target: str) -> None:
|
|
|
|
|
- deleted_folder = self.cfg.get("deleted_folder")
|
|
|
|
|
- if not deleted_folder:
|
|
|
|
|
- raise PPSError("release relocated messages but no deleted_folder configured")
|
|
|
|
|
- wanted = {it["guid"]: it for it in chunk if it.get("guid")}
|
|
|
|
|
- if not wanted:
|
|
|
|
|
- raise PPSError("cannot recover messages: no guids stored for report_release")
|
|
|
|
|
-
|
|
|
|
|
- records = self.pps.search(
|
|
|
|
|
- deleted_folder,
|
|
|
|
|
- self.cfg.get("list_query", "from=*"),
|
|
|
|
|
- limit=1000,
|
|
|
|
|
- days_back=int(self.cfg.get("default_days_back", 7)),
|
|
|
|
|
- )
|
|
|
|
|
- found = [
|
|
|
|
|
- r["localguid"]
|
|
|
|
|
- for r in records
|
|
|
|
|
- if r.get("guid") in wanted and r.get("localguid")
|
|
|
|
|
- ]
|
|
|
|
|
- if not found:
|
|
|
|
|
- raise PPSError(
|
|
|
|
|
- "released messages not found in deleted folder to move to report folder"
|
|
|
|
|
- )
|
|
|
|
|
- self.pps.act("move", deleted_folder, found, targetfolder=target)
|
|
|
|
|
-
|
|
|
|
|
# -------------------------------------------------------------- db helpers
|
|
# -------------------------------------------------------------- db helpers
|
|
|
|
|
|
|
|
def _items_for(self, job_id: int) -> list[dict]:
|
|
def _items_for(self, job_id: int) -> list[dict]:
|
|
@@ -371,6 +369,17 @@ class JobQueue:
|
|
|
self._conn.commit()
|
|
self._conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _destination(action: str, extra: dict, cfg: Mapping) -> str | None:
|
|
|
|
|
+ """The folder messages end up in, for the ops log."""
|
|
|
|
|
+ if action == "move":
|
|
|
|
|
+ return extra.get("targetfolder")
|
|
|
|
|
+ if action == "report_release":
|
|
|
|
|
+ return pipeline.pipeline_destination(cfg)
|
|
|
|
|
+ if action in ("release", "delete"):
|
|
|
|
|
+ return cfg.get("deleted_folder")
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _chunks(seq: list, size: int):
|
|
def _chunks(seq: list, size: int):
|
|
|
for i in range(0, len(seq), size):
|
|
for i in range(0, len(seq), size):
|
|
|
yield seq[i : i + size]
|
|
yield seq[i : i + size]
|
|
@@ -380,3 +389,15 @@ def _clip(value, length: int = 120) -> str:
|
|
|
"""Collapse newlines and truncate a value for a single-line log field."""
|
|
"""Collapse newlines and truncate a value for a single-line log field."""
|
|
|
s = " ".join(str(value or "").split())
|
|
s = " ".join(str(value or "").split())
|
|
|
return s[:length] + ("…" if len(s) > length else "")
|
|
return s[:length] + ("…" if len(s) > length else "")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _fmt_duration(seconds: float) -> str:
|
|
|
|
|
+ """Human-readable elapsed time, e.g. '0.42s', '12.3s', '3m 07s', '1h 04m'."""
|
|
|
|
|
+ if seconds < 60:
|
|
|
|
|
+ return f"{seconds:.2f}s"
|
|
|
|
|
+ if seconds < 3600:
|
|
|
|
|
+ m, s = divmod(int(seconds), 60)
|
|
|
|
|
+ return f"{m}m {s:02d}s"
|
|
|
|
|
+ h, rem = divmod(int(seconds), 3600)
|
|
|
|
|
+ m = rem // 60
|
|
|
|
|
+ return f"{h}h {m:02d}m"
|