fastprocesses 0.19.2__tar.gz → 0.19.4__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.19.2 → fastprocesses-0.19.4}/PKG-INFO +2 -2
  2. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/README.md +1 -1
  3. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/pyproject.toml +1 -1
  4. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/api/manager.py +20 -0
  5. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/common.py +1 -0
  6. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/base_process.py +121 -21
  7. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/config.py +4 -0
  8. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/processes/process_registry.py +1 -1
  9. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/worker/celery_app.py +57 -27
  10. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/AUTHORS.md +0 -0
  11. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/__init__.py +0 -0
  12. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/api/__init__.py +0 -0
  13. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/api/router.py +0 -0
  14. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/api/server.py +0 -0
  15. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/__init__.py +0 -0
  16. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/cache.py +0 -0
  17. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/exceptions.py +0 -0
  18. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/logging.py +0 -0
  19. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/models.py +0 -0
  20. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/redis_connection.py +0 -0
  21. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/core/types.py +0 -0
  22. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/processes/__init__.py +0 -0
  23. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/py.typed +0 -0
  24. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/static/style.css +0 -0
  25. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/templates/landing.html +0 -0
  26. {fastprocesses-0.19.2 → fastprocesses-0.19.4}/src/fastprocesses/worker/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fastprocesses
3
- Version: 0.19.2
3
+ Version: 0.19.4
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.19.2
41
+ ## Version: 0.19.4
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.19.2
7
+ ## Version: 0.19.4
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.19.2"
82
+ version = "0.19.4"
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"
@@ -94,6 +94,12 @@ class AsyncExecutionStrategy(ExecutionStrategy):
94
94
  )
95
95
  raise BrokerUnavailableError(str(exc)) from exc
96
96
  send_elapsed = time.monotonic() - send_start
97
+ logger.info(
98
+ "Task queued: process_id={}, job_id={}, mode=async, send_time={:.3f}s",
99
+ process_id,
100
+ task.id,
101
+ send_elapsed,
102
+ )
97
103
  if send_elapsed > 1.0:
98
104
  logger.warning(
99
105
  "Celery async send_task for process_id={} took {:.2f}s",
@@ -155,6 +161,13 @@ class AsyncExecutionStrategy(ExecutionStrategy):
155
161
  )
156
162
  raise BrokerUnavailableError(str(exc)) from exc
157
163
  send_elapsed = time.monotonic() - send_start
164
+ logger.info(
165
+ "Task queued: process_id={}, job_id={},"
166
+ " mode=async (raw), send_time={:.3f}s",
167
+ process_id,
168
+ task.id,
169
+ send_elapsed,
170
+ )
158
171
  if send_elapsed > 1.0:
159
172
  logger.warning(
160
173
  "Celery async send_task for process_id={} took {:.2f}s",
@@ -218,6 +231,13 @@ class SyncExecutionStrategy(ExecutionStrategy):
218
231
  exc,
219
232
  )
220
233
  raise BrokerUnavailableError(str(exc)) from exc
234
+ send_elapsed_sync = time.monotonic() - send_start
235
+ logger.info(
236
+ "Task queued: process_id={}, job_id={}, mode=sync, send_time={:.3f}s",
237
+ process_id,
238
+ task.id,
239
+ send_elapsed_sync,
240
+ )
221
241
  send_elapsed = time.monotonic() - send_start
222
242
  if send_elapsed > 1.0:
223
243
  logger.warning(
@@ -65,6 +65,7 @@ celery_app = Celery(
65
65
  )
66
66
 
67
67
  celery_app.conf.update(
68
+ task_default_queue=settings.FP_CELERY_QUEUE,
68
69
  task_serializer="custom_json",
69
70
  result_serializer="custom_json",
70
71
  accept_content=["custom_json", "json"], # Accept only the custom serializer
@@ -5,6 +5,7 @@ from typing import Any, Awaitable, ClassVar, Dict, List
5
5
 
6
6
  from jsonschema import ValidationError as JSONSchemaValidationError
7
7
  from jsonschema import validate as jsonschema_validate
8
+ from jsonschema.exceptions import best_match as _jsonschema_best_match
8
9
  from pydantic import BaseModel
9
10
  from referencing import Registry
10
11
  from referencing.exceptions import Unresolvable as _UnresolvableRef
@@ -13,6 +14,18 @@ from fastprocesses.core.logging import logger
13
14
  from fastprocesses.core.models import OutputControl, ProcessDescription
14
15
  from fastprocesses.core.types import JobProgressCallback
15
16
 
17
+ # Maps JSON Schema primitive type names to their Python equivalents.
18
+ # Used by quick_validate_inputs for a cheap top-level type check.
19
+ _JS_TYPE_TO_PYTHON: Dict[str, Any] = {
20
+ "string": str,
21
+ "number": (int, float),
22
+ "integer": int,
23
+ "boolean": bool,
24
+ "array": list,
25
+ "object": dict,
26
+ "null": type(None),
27
+ }
28
+
16
29
 
17
30
  class BaseProcess(ABC):
18
31
  process_description: ClassVar[ProcessDescription]
@@ -98,9 +111,10 @@ class BaseProcess(ABC):
98
111
  unchanged, so existing processes are unaffected.
99
112
 
100
113
  Override this in subclasses whose inputs may be supplied as URIs
101
- instead of inline data. The worker calls this method *before*
102
- ``validate_inputs``, so the resolved data is what gets validated
103
- and passed to ``execute``.
114
+ instead of inline data. The worker calls this method *after*
115
+ ``validate_inputs`` so that the process description schema is validated
116
+ against the wire-format input (the URI reference object), and *before*
117
+ ``late_validate`` which validates the resolved data.
104
118
 
105
119
  The HTTP fetch, authentication, redirect policy, and SSRF checks are
106
120
  the responsibility of the overriding application — fastprocesses does
@@ -128,21 +142,99 @@ class BaseProcess(ABC):
128
142
  """
129
143
  return exec_body
130
144
 
145
+ def late_validate(self, inputs: Dict[str, Any]) -> bool:
146
+ """
147
+ Process-specific validation of **resolved** inputs, called after
148
+ :meth:`resolve_remote_inputs` and before ``execute``.
149
+
150
+ The default implementation is a **no-op** that always returns ``True``,
151
+ so existing processes are unaffected.
152
+
153
+ Override this in subclasses that need to validate data that was
154
+ fetched or transformed by ``resolve_remote_inputs`` — for example,
155
+ validating a downloaded GeoJSON FeatureCollection against a
156
+ process-specific Pydantic model, or spot-checking the first feature
157
+ to detect structural problems early.
158
+
159
+ Unlike :meth:`validate_inputs`, which uses the generic process
160
+ description schema, ``late_validate`` has full access to the resolved
161
+ data and can apply arbitrary business logic. Raise ``ValueError`` to
162
+ fail the job with a user-facing message.
163
+
164
+ Example::
165
+
166
+ from mypackage.models import BuildingFeatureCollection
167
+
168
+ def late_validate(self, inputs: dict) -> bool:
169
+ # Validate the fetched collection against a Pydantic model.
170
+ # model_validate raises ValidationError on structural problems.
171
+ BuildingFeatureCollection.model_validate(
172
+ inputs["buildings"]
173
+ )
174
+ return True
175
+ """
176
+ return True
177
+
131
178
  def quick_validate_inputs(self, inputs: Dict[str, Any]) -> bool:
132
179
  """
133
- Quickly checks that all required input fields are present.
134
- Does NOT perform deep schema validation.
180
+ Cheap structural check run at the API boundary (before the job is queued).
181
+
182
+ Catches the three most common user errors without any recursion:
183
+ 1. Unknown field names (e.g. a typo like ``"buldings"`` instead of
184
+ ``"buildings"``)
185
+ 2. Missing required fields
186
+ 3. Top-level type mismatch (e.g. passing a string where an object is
187
+ expected)
188
+
189
+ Deep schema validation (nested properties, ``oneOf``, ``enum``,
190
+ ``const``, …) is left to :meth:`validate_inputs` which runs on the
191
+ worker after the job has been accepted.
135
192
  """
136
193
  description: ProcessDescription = self.get_description()
137
- required_inputs = description.inputs
194
+ defined_inputs = description.inputs
138
195
 
139
- # Check for missing required inputs only
140
- for input_name, input_desc in required_inputs.items():
141
- if input_desc.minOccurs > 0 and input_name not in inputs:
196
+ # 1. Unknown fields catches typos before a job is even queued
197
+ unknown = sorted(k for k in inputs if k not in defined_inputs)
198
+ if unknown:
199
+ raise ValueError(
200
+ f"Unknown input(s): {', '.join(unknown)}. "
201
+ f"Expected: {', '.join(sorted(defined_inputs))}."
202
+ )
203
+
204
+ # 2. Missing required fields
205
+ for name, desc in defined_inputs.items():
206
+ if desc.minOccurs > 0 and name not in inputs:
207
+ raise ValueError(
208
+ f"Missing required input '{name}'. "
209
+ f"Description: {desc.description}"
210
+ )
211
+
212
+ # 3. Top-level type check — O(n inputs), no recursion into nested data
213
+ for name, value in inputs.items():
214
+ schema_type = defined_inputs[name].scheme.type
215
+ if schema_type is None:
216
+ continue # no top-level type declared (e.g. pure oneOf/allOf)
217
+ declared = [schema_type] if isinstance(schema_type, str) else schema_type
218
+ expected = tuple(
219
+ py_t
220
+ for js_t in declared
221
+ if (py_t := _JS_TYPE_TO_PYTHON.get(js_t)) is not None
222
+ )
223
+ if not expected:
224
+ continue
225
+ # bool is a subclass of int; reject it unless "boolean" is declared
226
+ if isinstance(value, bool) and "boolean" not in declared:
142
227
  raise ValueError(
143
- f"Missing required input '{input_name}'. "
144
- f"Description: {input_desc.description}"
228
+ f"Input '{name}' has wrong type: "
229
+ f"expected {', '.join(declared)}, got boolean."
145
230
  )
231
+ if not isinstance(value, expected):
232
+ raise ValueError(
233
+ f"Input '{name}' has wrong type: "
234
+ f"expected {', '.join(declared)}, "
235
+ f"got {type(value).__name__}."
236
+ )
237
+
146
238
  return True
147
239
 
148
240
  def validate_inputs(self, inputs: Dict[str, Any]) -> bool:
@@ -163,6 +255,8 @@ class BaseProcess(ABC):
163
255
 
164
256
  # First, check all provided inputs
165
257
  for input_name, input_value in inputs.items():
258
+ logger.info("Validating input '{}'", input_name)
259
+
166
260
  if input_name not in required_inputs:
167
261
  raise ValueError(
168
262
  f"Provided input '{input_name}' is "
@@ -179,9 +273,23 @@ class BaseProcess(ABC):
179
273
  registry=self.schema_registry,
180
274
  )
181
275
  except JSONSchemaValidationError as e:
276
+ # For oneOf/anyOf/allOf, best_match picks the deepest,
277
+ # most specific sub-error instead of the generic top-level one.
278
+ leaf = _jsonschema_best_match(e.context) if e.context else e
279
+ # Build a readable path: features[42].properties.area_m2
280
+ path_parts: list[str] = []
281
+ for step in leaf.absolute_path:
282
+ if isinstance(step, int):
283
+ if path_parts:
284
+ path_parts[-1] += f"[{step}]"
285
+ else:
286
+ path_parts.append(f"[{step}]")
287
+ else:
288
+ path_parts.append(str(step))
289
+ location = ".".join(path_parts) or "(root)"
182
290
  raise ValueError(
183
- f"Input '{input_name}' validation failed: {e.message}. "
184
- f"Description: {input_desc.scheme.model_dump(exclude_unset=True, by_alias=True)}"
291
+ f"Input '{input_name}' validation failed: "
292
+ f"{leaf.message} (at: {location})."
185
293
  )
186
294
  except Exception as e:
187
295
  # jsonschema wraps referencing.exceptions.Unresolvable as
@@ -203,14 +311,6 @@ class BaseProcess(ABC):
203
311
  else:
204
312
  raise
205
313
 
206
- # Then, check for missing required inputs
207
- for input_name, input_desc in required_inputs.items():
208
- if input_desc.minOccurs > 0 and input_name not in inputs:
209
- raise ValueError(
210
- f"Missing required input '{input_name}'. "
211
- f"Description: {input_desc.description}"
212
- )
213
-
214
314
  return True
215
315
 
216
316
  def validate_outputs(
@@ -74,6 +74,10 @@ class OGCProcessesSettings(BaseSettings):
74
74
  FP_CELERY_RESULTS_TTL_DAYS: int = 365
75
75
  FP_CELERY_TASK_TLIMIT_HARD: int = 900 # seconds
76
76
  FP_CELERY_TASK_TLIMIT_SOFT: int = 600 # seconds
77
+ FP_CELERY_QUEUE: str = Field(
78
+ default="celery",
79
+ description="Celery queue name for task routing. Must match the worker -Q flag.",
80
+ )
77
81
  FP_CELERY_JOB_MODE: bool = Field(
78
82
  default=False,
79
83
  description="Enable job mode for graceful shutdown after task completion"
@@ -15,7 +15,7 @@ class ProcessRegistry:
15
15
  """Manages the registration and retrieval of available processs (processes)."""
16
16
 
17
17
  def __init__(self, redis_connection: RedisConnection | None = None):
18
- self.registry_key = "process_registry"
18
+ self.registry_key = f"process_registry:{settings.FP_CELERY_QUEUE}"
19
19
  if redis_connection is None:
20
20
  redis_connection = RedisConnection(str(settings.results_cache.connection))
21
21
  self.redis_connection = redis_connection
@@ -3,6 +3,8 @@ import asyncio
3
3
  import inspect
4
4
  import json
5
5
  import signal
6
+ import time
7
+ import traceback
6
8
  from datetime import datetime, timezone
7
9
  from typing import Any, Dict, cast
8
10
 
@@ -581,12 +583,16 @@ def execute_process(self, process_id: str, serialized_data: str | bytes):
581
583
  serialized_data_str = bytes(serialized_data).decode("utf-8")
582
584
  data: dict = json.loads(serialized_data_str)
583
585
 
584
- logger.info(f"Executing process {process_id} with data {serialized_data[:80]}")
585
586
  job_id = self.request.id # Get the task/job ID
587
+ task_start = time.monotonic()
588
+ logger.info(
589
+ "Worker picked up task: process_id={}, job_id={}",
590
+ process_id,
591
+ job_id,
592
+ )
586
593
 
587
594
  # First: Get the process
588
595
  try:
589
- logger.info(f"Worker retrieving process {process_id} from registry")
590
596
  process = get_process_registry().get_process(process_id)
591
597
  except ValueError as e:
592
598
  job_status = JobStatusCode.FAILED
@@ -600,7 +606,27 @@ def execute_process(self, process_id: str, serialized_data: str | bytes):
600
606
  except ProcessClassNotFoundError as e:
601
607
  raise e
602
608
 
603
- # Second: resolve remote inputs (URI strings downloaded data)
609
+ # Second: validate wire-format inputs against the process description schema
610
+ try:
611
+ update_job_status(
612
+ job_id,
613
+ 0,
614
+ "Validating inputs.",
615
+ job_status,
616
+ )
617
+ process.validate_inputs(data["inputs"])
618
+ except ValueError as e:
619
+ logger.error(f"Input validation failed for process {process_id}: {str(e)}")
620
+ job_status = JobStatusCode.FAILED
621
+ update_job_status(
622
+ job_id,
623
+ 0,
624
+ str(e),
625
+ job_status,
626
+ )
627
+ raise InputValidationError(process_id, repr(e))
628
+
629
+ # Third: resolve remote inputs (URI strings → downloaded data)
604
630
  try:
605
631
  update_job_status(
606
632
  job_id,
@@ -617,30 +643,19 @@ def execute_process(self, process_id: str, serialized_data: str | bytes):
617
643
  update_job_status(job_id, 0, str(e), job_status)
618
644
  raise InputValidationError(process_id, repr(e))
619
645
 
620
- # Third: deep validation of inputs
646
+ # Fourth: late validation of resolved inputs (process-specific, no-op by default)
621
647
  try:
622
- logger.info(f"Worker validating inputs for process {process_id}")
623
- update_job_status(
624
- job_id,
625
- 0,
626
- "Validating inputs. This may take a while for complex inputs.",
627
- job_status,
628
- )
629
- process.validate_inputs(data["inputs"])
648
+ process.late_validate(data["inputs"])
630
649
  except ValueError as e:
631
- logger.error(f"Input validation failed for process {process_id}: {str(e)}")
632
- job_status = JobStatusCode.FAILED
633
- update_job_status(
634
- job_id,
635
- 0,
636
- str(e),
637
- job_status,
650
+ logger.error(
651
+ "Late validation failed for process {}: {}", process_id, e
638
652
  )
653
+ job_status = JobStatusCode.FAILED
654
+ update_job_status(job_id, 0, str(e), job_status)
639
655
  raise InputValidationError(process_id, repr(e))
640
656
 
641
657
  # Fourth: Execute the process
642
658
  try:
643
- logger.info("Worker executing process {}", process_id)
644
659
  job_status = JobStatusCode.RUNNING
645
660
  # BUG: if redis returns no job_status, this fails and creates a ValueError too
646
661
  update_job_status(
@@ -690,21 +705,33 @@ def execute_process(self, process_id: str, serialized_data: str | bytes):
690
705
  except Exception as e:
691
706
  # Update job with error status
692
707
  job_status = JobStatusCode.FAILED
708
+ task_elapsed = time.monotonic() - task_start
693
709
 
694
710
  # decide if its a validation error or a general error
695
711
  if isinstance(e, ValueError) or isinstance(e, ValidationError):
696
- logger.error(f"Validation error in process {process_id}: {e}")
712
+ logger.error(
713
+ "Task failed: process_id={}, job_id={}, duration={:.2f}s, reason={}",
714
+ process_id,
715
+ job_id,
716
+ task_elapsed,
717
+ e,
718
+ )
697
719
  job_message = e
698
720
  raise e
699
721
 
700
722
  # Log the full traceback server-side only — never expose internal
701
723
  # class names, source lines, or line numbers to the client.
702
- logger.exception(
703
- "Unhandled exception in process '{}' (job {}): {}",
724
+ logger.error(
725
+ "Task failed: process_id={}, job_id={}, duration={:.2f}s, reason={}",
704
726
  process_id,
705
727
  job_id,
728
+ task_elapsed,
706
729
  e,
707
- exc_info=True,
730
+ )
731
+ logger.debug(
732
+ "Full traceback for job {}:\n{}",
733
+ job_id,
734
+ traceback.format_exc(),
708
735
  )
709
736
 
710
737
  job_message = (
@@ -720,10 +747,13 @@ def execute_process(self, process_id: str, serialized_data: str | bytes):
720
747
  return None
721
748
 
722
749
  if result:
723
- result_dump = jsonable_encoder(result)
750
+ task_elapsed = time.monotonic() - task_start
724
751
  logger.info(
725
- f"Process {process_id} executed "
726
- f"successfully with result {json.dumps(result_dump)[:80]}"
752
+ "Task completed: process_id={}, job_id={},"
753
+ " duration={:.2f}s",
754
+ process_id,
755
+ job_id,
756
+ task_elapsed,
727
757
  )
728
758
  job_status = JobStatusCode.SUCCESSFUL
729
759