taskferry-cloudrun 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -4,6 +4,16 @@ All notable changes to `taskferry-cloudrun` are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
5
5
  this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.3.0] — 2026-09-19
8
+
9
+ ### Added
10
+
11
+ - **Live log streaming** through Cloud Logging. `stream_logs` pages an execution's
12
+ log entries in timestamp order, de-duplicating by insert id, and follows them
13
+ until the execution reaches a terminal state; `logs` returns the whole output
14
+ once. `logs_uri` (the console link) is unchanged.
15
+ - `CloudRunJobBackend` accepts an injected `logging_client`.
16
+
7
17
  ## [0.2.0] — 2026-07-26
8
18
 
9
19
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskferry-cloudrun
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Google Cloud Run Jobs backend for Taskferry — serverless batch workloads.
5
5
  Project-URL: Homepage, https://github.com/xiidigital/taskferry
6
6
  Project-URL: Documentation, https://taskferry.dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "taskferry-cloudrun"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Google Cloud Run Jobs backend for Taskferry — serverless batch workloads."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -37,6 +37,8 @@ Route GPU work at a backend that really allocates GPUs.
37
37
 
38
38
  from __future__ import annotations
39
39
 
40
+ import time
41
+ from collections.abc import Iterator
40
42
  from datetime import UTC, datetime
41
43
  from typing import Any
42
44
 
@@ -138,6 +140,7 @@ class CloudRunJobBackend(BaseBackend):
138
140
  location: str | None = None,
139
141
  jobs_client: Any = None,
140
142
  executions_client: Any = None,
143
+ logging_client: Any = None,
141
144
  name: str = "cloudrun",
142
145
  tracked_ids: int = 10_000,
143
146
  ) -> None:
@@ -150,6 +153,7 @@ class CloudRunJobBackend(BaseBackend):
150
153
  self._location = location
151
154
  self._jobs_client = jobs_client
152
155
  self._executions_client = executions_client
156
+ self._logging_client = logging_client
153
157
  self._name = name
154
158
  # Cloud Run names executions itself, so remember which of its names goes
155
159
  # with which Taskferry id; a full resource path is accepted directly.
@@ -186,6 +190,11 @@ class CloudRunJobBackend(BaseBackend):
186
190
  self._executions_client = _run_v2().ExecutionsClient()
187
191
  return self._executions_client
188
192
 
193
+ def _logging(self) -> Any:
194
+ if self._logging_client is None:
195
+ self._logging_client = _logging_v2().Client(project=self._project)
196
+ return self._logging_client
197
+
189
198
  # -- resource names ----------------------------------------------------------- #
190
199
  def job_resource(self, spec: JobSpec) -> str:
191
200
  """Fully-qualified Cloud Run Job resource this spec targets."""
@@ -318,6 +327,91 @@ class CloudRunJobBackend(BaseBackend):
318
327
  f"?project={self._project}&query=resource.labels.location%3D%22{self._location}%22"
319
328
  )
320
329
 
330
+ # -- logs ----------------------------------------------------------------------- #
331
+ def logs(self, execution_id: ExecutionId) -> str:
332
+ """Read the execution's Cloud Logging output once. Requires ``Capability.LOGS``."""
333
+ self.capabilities.require(Capability.LOGS)
334
+ return "\n".join(self.stream_logs(execution_id, follow=False))
335
+
336
+ def stream_logs(
337
+ self,
338
+ execution_id: ExecutionId,
339
+ *,
340
+ follow: bool = True,
341
+ poll_interval: float = 2.0,
342
+ timeout: float | None = None,
343
+ ) -> Iterator[str]:
344
+ """Yield the execution's Cloud Logging entries, following them live by default.
345
+
346
+ Cloud Logging is queried by filter rather than truly streamed, so this
347
+ pages entries in timestamp order, de-duplicating by insert id, and — with
348
+ ``follow=True`` — keeps polling for new entries until the execution reaches
349
+ a terminal state. With ``follow=False`` it returns what is there and stops.
350
+ ``timeout`` bounds the total stream. Requires ``Capability.LOGS``.
351
+ """
352
+ self.capabilities.require(Capability.LOGS)
353
+ external_id = self._ids.resolve(str(execution_id))
354
+ if external_id is None:
355
+ raise ExecutionNotFound(
356
+ f"{execution_id!r} was not submitted by this backend instance; pass the "
357
+ "Cloud Run execution name to stream its logs from another process",
358
+ backend=self._name,
359
+ )
360
+ execution_name = external_id.rsplit("/executions/", 1)[-1]
361
+ base_filter = (
362
+ 'resource.type="cloud_run_job" '
363
+ f'AND resource.labels.location="{self._location}" '
364
+ f'AND labels."run.googleapis.com/execution_name"="{execution_name}"'
365
+ )
366
+ deadline = None if timeout is None else time.monotonic() + timeout
367
+ client = self._logging()
368
+ seen: set[str] = set()
369
+ since: str | None = None
370
+ while True:
371
+ log_filter = base_filter if since is None else f'{base_filter} AND timestamp>="{since}"'
372
+ try:
373
+ entries = list(
374
+ client.list_entries(
375
+ resource_names=[f"projects/{self._project}"],
376
+ filter_=log_filter,
377
+ order_by="timestamp asc",
378
+ page_size=1000,
379
+ )
380
+ )
381
+ except Exception as exc:
382
+ if _is_not_found(exc):
383
+ return
384
+ raise BackendError(
385
+ f"Cloud Run could not read logs for {external_id!r}: {exc}", backend=self._name
386
+ ) from exc
387
+ new = 0
388
+ for entry in entries:
389
+ insert_id = str(getattr(entry, "insert_id", "") or "")
390
+ if insert_id and insert_id in seen:
391
+ continue
392
+ if insert_id:
393
+ seen.add(insert_id)
394
+ timestamp = getattr(entry, "timestamp", None)
395
+ if timestamp is not None:
396
+ since = _rfc3339(timestamp)
397
+ text = _entry_text(entry)
398
+ if text is not None:
399
+ yield text
400
+ new += 1
401
+ if new == 0:
402
+ if not follow or self._is_terminal(external_id):
403
+ return
404
+ if deadline is not None and time.monotonic() > deadline:
405
+ return
406
+ time.sleep(poll_interval)
407
+
408
+ def _is_terminal(self, external_id: str) -> bool:
409
+ try:
410
+ remote = self._executions().get_execution(name=external_id)
411
+ except Exception: # a vanished execution is, for our purposes, done
412
+ return True
413
+ return map_execution_state(remote)[0].is_terminal
414
+
321
415
 
322
416
  def _run_v2() -> Any:
323
417
  """Import ``google.cloud.run_v2`` lazily, with an actionable error."""
@@ -330,6 +424,39 @@ def _run_v2() -> Any:
330
424
  return run_v2
331
425
 
332
426
 
427
+ def _logging_v2() -> Any:
428
+ """Import ``google.cloud.logging_v2`` lazily, with an actionable error."""
429
+ try:
430
+ from google.cloud import logging_v2
431
+ except ImportError as exc: # pragma: no cover - depends on the environment
432
+ raise ConfigurationError(
433
+ "the Cloud Run backend needs the Google logging SDK: "
434
+ "pip install 'taskferry-cloudrun[gcp]'"
435
+ ) from exc
436
+ return logging_v2
437
+
438
+
439
+ def _entry_text(entry: Any) -> str | None:
440
+ """Extract a line of text from a Cloud Logging entry (text or struct payload)."""
441
+ payload = getattr(entry, "payload", None)
442
+ if payload is None:
443
+ return None
444
+ if isinstance(payload, str):
445
+ return payload.rstrip("\n")
446
+ if isinstance(payload, dict):
447
+ message = payload.get("message")
448
+ return str(message if message is not None else payload).rstrip("\n")
449
+ return str(payload).rstrip("\n")
450
+
451
+
452
+ def _rfc3339(timestamp: Any) -> str:
453
+ """Format a timestamp for a Cloud Logging ``timestamp>=`` filter."""
454
+ if isinstance(timestamp, datetime):
455
+ moment = timestamp if timestamp.tzinfo else timestamp.replace(tzinfo=UTC)
456
+ return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
457
+ return str(timestamp)
458
+
459
+
333
460
  def _execution_name(operation: Any) -> str | None:
334
461
  """Pull the execution resource name out of the long-running operation."""
335
462
  metadata = getattr(operation, "metadata", None)
@@ -373,6 +500,7 @@ def make_backend(**options: Any) -> CloudRunJobBackend:
373
500
  location=options.get("location"),
374
501
  jobs_client=options.get("jobs_client"),
375
502
  executions_client=options.get("executions_client"),
503
+ logging_client=options.get("logging_client"),
376
504
  name=str(options.get("name", "cloudrun")),
377
505
  tracked_ids=int(options.get("tracked_ids", 10_000)),
378
506
  )