fastprocesses 0.18.2__tar.gz → 0.18.3__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.
Files changed (26) hide show
  1. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/PKG-INFO +2 -2
  2. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/README.md +1 -1
  3. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/pyproject.toml +1 -1
  4. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/processes/process_registry.py +1 -1
  5. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/worker/celery_app.py +149 -76
  6. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/AUTHORS.md +0 -0
  7. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/__init__.py +0 -0
  8. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/api/__init__.py +0 -0
  9. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/api/manager.py +0 -0
  10. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/api/router.py +0 -0
  11. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/api/server.py +0 -0
  12. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/common.py +0 -0
  13. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/__init__.py +0 -0
  14. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/base_process.py +0 -0
  15. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/cache.py +0 -0
  16. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/config.py +0 -0
  17. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/exceptions.py +0 -0
  18. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/logging.py +0 -0
  19. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/models.py +0 -0
  20. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/redis_connection.py +0 -0
  21. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/core/types.py +0 -0
  22. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/processes/__init__.py +0 -0
  23. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/py.typed +0 -0
  24. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/static/style.css +0 -0
  25. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/templates/landing.html +0 -0
  26. {fastprocesses-0.18.2 → fastprocesses-0.18.3}/src/fastprocesses/worker/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fastprocesses
3
- Version: 0.18.2
3
+ Version: 0.18.3
4
4
  Summary: A library to create a FastAPI-based OGC API Processes wrapper around existing projects.
5
5
  Author: Stefan Schuhart
6
6
  Author-email: stefan.schuhart@gv.hamburg.de
@@ -38,7 +38,7 @@ A library to create a FastAPI-based OGC API Processes wrapper around existing pr
38
38
 
39
39
  AI helped to create this code.
40
40
 
41
- ## Version: 0.18.2
41
+ ## Version: 0.18.3
42
42
 
43
43
  ### Description
44
44
 
@@ -4,7 +4,7 @@ A library to create a FastAPI-based OGC API Processes wrapper around existing pr
4
4
 
5
5
  AI helped to create this code.
6
6
 
7
- ## Version: 0.18.2
7
+ ## Version: 0.18.3
8
8
 
9
9
  ### Description
10
10
 
@@ -79,7 +79,7 @@ exclude = [
79
79
 
80
80
  [tool.poetry]
81
81
  name = "fastprocesses"
82
- version = "0.18.2"
82
+ version = "0.18.3"
83
83
  description = "A library to create a FastAPI-based OGC API Processes wrapper around existing projects."
84
84
  authors = ["Stefan Schuhart <stefan.schuhart@gv.hamburg.de>"]
85
85
  readme = "README.md"
@@ -115,7 +115,7 @@ class ProcessRegistry:
115
115
 
116
116
  process_info = json.loads(process_data) # type: ignore
117
117
  logger.debug(
118
- f"Process data retrieved from Redis:\n{json.dumps(process_info, indent=4)}"
118
+ f"Process data retrieved from Redis for {process_id}."
119
119
  )
120
120
 
121
121
  process_class = cast(Type[BaseProcess], locate(process_info["class_path"]))
@@ -5,9 +5,9 @@ import json
5
5
  import signal
6
6
  import traceback
7
7
  from datetime import datetime, timezone
8
- from typing import Any, Dict
8
+ from typing import Any, Dict, cast
9
9
 
10
- from celery import Task, chord, group
10
+ from celery import Task, chord
11
11
  from celery.exceptions import SoftTimeLimitExceeded
12
12
  from fastapi.encoders import jsonable_encoder
13
13
  from pydantic import BaseModel, ValidationError
@@ -80,6 +80,7 @@ def update_job_status(
80
80
  message: str | None = None,
81
81
  status: str | None = None,
82
82
  started: datetime | None = None,
83
+ process_id: str | None = None,
83
84
  ) -> None:
84
85
  """
85
86
  Updates the progress of a job.
@@ -96,9 +97,50 @@ def update_job_status(
96
97
  if not raw_job_info:
97
98
  logger.warning(
98
99
  "Job status for job_id={} not found in cache when updating; "
99
- "skipping status update.",
100
+ "initializing a fallback job status entry.",
100
101
  job_id,
101
102
  )
103
+ fallback_status = status or JobStatusCode.RUNNING
104
+ now = datetime.now(timezone.utc)
105
+ job_info = JobStatusInfo.model_validate(
106
+ {
107
+ "jobID": job_id,
108
+ "status": fallback_status,
109
+ "type": "process",
110
+ "processID": process_id,
111
+ "created": now,
112
+ "updated": now,
113
+ "progress": progress,
114
+ "message": message,
115
+ "links": [
116
+ Link.model_validate(
117
+ {
118
+ "href": f"/jobs/{job_id}",
119
+ "rel": "self",
120
+ "type": "application/json",
121
+ }
122
+ )
123
+ ],
124
+ }
125
+ )
126
+ if fallback_status == JobStatusCode.SUCCESSFUL:
127
+ job_info.finished = now
128
+ job_info.links.append(
129
+ Link.model_validate(
130
+ {
131
+ "href": f"/jobs/{job_id}/results",
132
+ "rel": "results",
133
+ "type": "application/json",
134
+ }
135
+ )
136
+ )
137
+ job_status_cache.put(job_key, job_info)
138
+ logger.debug(
139
+ "Initialized fallback job status for job {}: {} at {}%",
140
+ job_id,
141
+ fallback_status,
142
+ progress,
143
+ )
102
144
  return
103
145
 
104
146
  try:
@@ -128,6 +170,8 @@ def update_job_status(
128
170
 
129
171
  if message:
130
172
  job_info.message = message
173
+ if process_id and not job_info.processID:
174
+ job_info.processID = process_id
131
175
 
132
176
  job_status_cache.put(job_key, job_info)
133
177
  logger.debug(f"Updated progress for job {job_id}: {progress}%, {message}")
@@ -170,7 +214,7 @@ def _increment_and_report_progress(job_id: str, total: int) -> None:
170
214
  )
171
215
  except Exception as exc: # pragma: no cover
172
216
  logger.warning(
173
- "Could not update subtask progress for job %s: %r", job_id, exc
217
+ "Could not update subtask progress for job {}: {!r}", job_id, exc
174
218
  )
175
219
 
176
220
 
@@ -181,7 +225,7 @@ def _cleanup_progress_counter(job_id: str) -> None:
181
225
  job_status_cache.redis_connection._execute_redis_command("delete", counter_key)
182
226
  except Exception as exc: # pragma: no cover
183
227
  logger.warning(
184
- "Could not clean up progress counter for job %s: %r", job_id, exc
228
+ "Could not clean up progress counter for job {}: {!r}", job_id, exc
185
229
  )
186
230
 
187
231
 
@@ -201,21 +245,36 @@ def execute_parallel_item(
201
245
  counter shared by all sibling tasks and updates the parent job's progress
202
246
  to ``(n_done / total) * 90 %``.
203
247
  """
204
- item: dict = json.loads(serialized_item)
205
- service = get_process_registry().get_process(process_id)
248
+ try:
249
+ item: dict = json.loads(serialized_item)
250
+ service = cast(
251
+ BaseParallelProcess,
252
+ get_process_registry().get_process(process_id),
253
+ )
206
254
 
207
- partial = service.execute_single(item)
208
- if inspect.isawaitable(partial):
209
- if asyncio.iscoroutine(partial):
210
- partial = asyncio.run(partial)
211
- else:
212
- async def _await(p=partial):
213
- return await p
214
- partial = asyncio.run(_await())
255
+ partial = service.execute_single(item)
256
+ if inspect.isawaitable(partial):
257
+ if asyncio.iscoroutine(partial):
258
+ partial = asyncio.run(partial)
259
+ else:
215
260
 
216
- result = jsonable_encoder(partial)
217
- _increment_and_report_progress(job_id, total)
218
- return result
261
+ async def _await(p=partial):
262
+ return await p
263
+
264
+ partial = asyncio.run(_await())
265
+
266
+ result = jsonable_encoder(partial)
267
+ _increment_and_report_progress(job_id, total)
268
+ return result
269
+ except Exception as exc:
270
+ update_job_status(
271
+ job_id,
272
+ 0,
273
+ f"Parallel subtask failed: {exc}",
274
+ JobStatusCode.FAILED,
275
+ process_id=process_id,
276
+ )
277
+ raise
219
278
 
220
279
 
221
280
  def _run_parallel(
@@ -252,9 +311,19 @@ def _run_parallel(
252
311
  f"{total} subtask(s)."
253
312
  )
254
313
 
314
+ execute_parallel_item_task = cast(Any, execute_parallel_item)
315
+ finalize_parallel_task = cast(Any, finalize_parallel)
255
316
  chord(
256
- [execute_parallel_item.s(process_id, job_id, total, json.dumps(item)) for item in items]
257
- )(finalize_parallel.s(process_id, job_id, serialized_data))
317
+ [
318
+ execute_parallel_item_task.s(
319
+ process_id,
320
+ job_id,
321
+ total,
322
+ json.dumps(item),
323
+ )
324
+ for item in items
325
+ ]
326
+ )(finalize_parallel_task.s(process_id, job_id, serialized_data))
258
327
 
259
328
 
260
329
  @celery_app.task(name="fastprocesses.finalize_parallel")
@@ -274,7 +343,7 @@ def finalize_parallel(
274
343
  2. Caches the merged result under the same key used by ``CacheResultTask``.
275
344
  3. Updates the job status to SUCCESSFUL (or FAILED on error).
276
345
  """
277
- service = get_process_registry().get_process(process_id)
346
+ service = cast(BaseParallelProcess, get_process_registry().get_process(process_id))
278
347
  try:
279
348
  update_job_status(
280
349
  job_id, 95, "Merging parallel results.", JobStatusCode.RUNNING
@@ -329,28 +398,43 @@ def execute_scatter_step(
329
398
  counter shared by all sibling tasks and updates the parent job's progress
330
399
  to ``(n_done / total) * 90 %``.
331
400
  """
332
- data: dict = json.loads(serialized_data)
333
- service = get_process_registry().get_process(process_id)
334
-
335
- steps = get_parallel_steps(service)
336
- if step_name not in steps:
337
- raise ValueError(
338
- f"Step '{step_name}' not found on process '{process_id}'. "
339
- f"Available steps: {list(steps)}"
401
+ try:
402
+ data: dict = json.loads(serialized_data)
403
+ service = cast(
404
+ BaseScatterProcess,
405
+ get_process_registry().get_process(process_id),
340
406
  )
341
407
 
342
- partial = steps[step_name](data)
343
- if inspect.isawaitable(partial):
344
- if asyncio.iscoroutine(partial):
345
- partial = asyncio.run(partial)
346
- else:
347
- async def _await(p=partial):
348
- return await p
349
- partial = asyncio.run(_await())
408
+ steps = get_parallel_steps(service)
409
+ if step_name not in steps:
410
+ raise ValueError(
411
+ f"Step '{step_name}' not found on process '{process_id}'. "
412
+ f"Available steps: {list(steps)}"
413
+ )
350
414
 
351
- result = jsonable_encoder(partial)
352
- _increment_and_report_progress(job_id, total)
353
- return result
415
+ partial = steps[step_name](data)
416
+ if inspect.isawaitable(partial):
417
+ if asyncio.iscoroutine(partial):
418
+ partial = asyncio.run(partial)
419
+ else:
420
+
421
+ async def _await(p=partial):
422
+ return await p
423
+
424
+ partial = asyncio.run(_await())
425
+
426
+ result = jsonable_encoder(partial)
427
+ _increment_and_report_progress(job_id, total)
428
+ return result
429
+ except Exception as exc:
430
+ update_job_status(
431
+ job_id,
432
+ 0,
433
+ f"Scatter step '{step_name}' failed: {exc}",
434
+ JobStatusCode.FAILED,
435
+ process_id=process_id,
436
+ )
437
+ raise
354
438
 
355
439
 
356
440
  def _run_scatter(
@@ -385,12 +469,20 @@ def _run_scatter(
385
469
  f"{total} step(s): {step_names}."
386
470
  )
387
471
 
472
+ execute_scatter_step_task = cast(Any, execute_scatter_step)
473
+ finalize_scatter_task = cast(Any, finalize_scatter)
388
474
  chord(
389
475
  [
390
- execute_scatter_step.s(process_id, job_id, total, name, serialized_input)
476
+ execute_scatter_step_task.s(
477
+ process_id,
478
+ job_id,
479
+ total,
480
+ name,
481
+ serialized_input,
482
+ )
391
483
  for name in step_names
392
484
  ]
393
- )(finalize_scatter.s(step_names, process_id, job_id, serialized_data))
485
+ )(finalize_scatter_task.s(step_names, process_id, job_id, serialized_data))
394
486
 
395
487
 
396
488
  @celery_app.task(name="fastprocesses.finalize_scatter")
@@ -412,7 +504,7 @@ def finalize_scatter(
412
504
  2. Caches the merged result under the same key used by ``CacheResultTask``.
413
505
  3. Updates the job status to SUCCESSFUL (or FAILED on error).
414
506
  """
415
- service = get_process_registry().get_process(process_id)
507
+ service = cast(BaseScatterProcess, get_process_registry().get_process(process_id))
416
508
  try:
417
509
  named_results = dict(zip(step_names, step_results))
418
510
  exec_body: dict = json.loads(serialized_data)
@@ -465,42 +557,23 @@ def execute_process(self, process_id: str, serialized_data: str | bytes):
465
557
  message (str): A message describing the current progress.
466
558
  status (str | None): The current status (e.g., "RUNNING", "SUCCESSFUL").
467
559
  """
468
- job_key = f"job:{job_id}"
469
- raw_job_info = job_status_cache.get(job_key)
470
-
471
- if not raw_job_info:
472
- logger.warning(
473
- "Job status for job_id={} not found in cache during progress "
474
- "callback; skipping progress update.",
475
- job_id,
476
- )
477
- return
478
-
479
- try:
480
- job_info = JobStatusInfo.model_validate(raw_job_info)
481
- except Exception as exc:
482
- logger.error(
483
- "Failed to validate cached job status in progress callback for "
484
- "job_id={}: {!r}",
485
- job_id,
486
- exc,
487
- )
488
- return
489
-
490
- job_info.progress = progress
491
- job_info.updated = datetime.now(timezone.utc)
492
-
493
- if message:
494
- job_info.message = message
495
-
496
- job_status_cache.put(job_key, job_info)
497
- logger.debug(f"Updated progress for job {job_id}: {progress}%, {message}")
560
+ update_job_status(
561
+ job_id,
562
+ progress,
563
+ message,
564
+ JobStatusCode.RUNNING,
565
+ process_id=process_id,
566
+ )
498
567
 
499
568
  chord_dispatched = False # True when a parallel/scatter chord is dispatched
500
569
  result = None
501
570
  job_status = JobStatusCode.RUNNING
502
571
  job_message = ""
503
- data: dict = json.loads(serialized_data)
572
+ if isinstance(serialized_data, str):
573
+ serialized_data_str: str = serialized_data
574
+ else:
575
+ serialized_data_str = bytes(serialized_data).decode("utf-8")
576
+ data: dict = json.loads(serialized_data_str)
504
577
 
505
578
  logger.info(f"Executing process {process_id} with data {serialized_data[:80]}")
506
579
  job_id = self.request.id # Get the task/job ID
@@ -560,12 +633,12 @@ def execute_process(self, process_id: str, serialized_data: str | bytes):
560
633
  if isinstance(service, BaseParallelProcess):
561
634
  # Data fan-out: split → N parallel subtasks (same op) → merge.
562
635
  # Chord dispatched; this task returns immediately.
563
- _run_parallel(service, process_id, data, job_id, serialized_data)
636
+ _run_parallel(service, process_id, data, job_id, serialized_data_str)
564
637
  chord_dispatched = True
565
638
  elif isinstance(service, BaseScatterProcess):
566
639
  # Operation fan-out: N different steps on same input → merge.
567
640
  # Chord dispatched; this task returns immediately.
568
- _run_scatter(service, process_id, data, job_id, serialized_data)
641
+ _run_scatter(service, process_id, data, job_id, serialized_data_str)
569
642
  chord_dispatched = True
570
643
  else:
571
644
  result = service.run_execute(