fastprocesses 0.7.4__py3-none-any.whl

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.
@@ -0,0 +1,137 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Callable, ClassVar, Dict, List
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from fastprocesses.core.models import ProcessDescription
7
+
8
+
9
+ class BaseProcess(ABC):
10
+ process_description: ClassVar[ProcessDescription]
11
+
12
+ def get_description(self) -> ProcessDescription:
13
+ """
14
+ Returns the OGC API Process description.
15
+
16
+ Returns:
17
+ ProcessDescription: Complete process description following OGC API standard
18
+ """
19
+ if not hasattr(self, 'process_description'):
20
+ raise NotImplementedError(
21
+ f"Process class {self.__class__.__name__} must define 'process_description'"
22
+ )
23
+ return self.process_description
24
+
25
+ @classmethod
26
+ def create_description(cls, description_dict: Dict[str, Any]) -> ProcessDescription:
27
+ """
28
+ Creates a ProcessDescription from a dictionary.
29
+
30
+ Args:
31
+ description_dict (Dict[str, Any]): Dictionary containing process description
32
+
33
+ Returns:
34
+ ProcessDescription: Validated process description object
35
+ """
36
+ return ProcessDescription.model_validate(description_dict)
37
+
38
+ @abstractmethod
39
+ async def execute(
40
+ self,
41
+ exec_body: Dict[str, Any],
42
+ progress_callback: Callable[[int, str], None] | None = None
43
+ ) -> Dict[str, Any]:
44
+ """
45
+ Executes the process with given inputs.
46
+
47
+ Args:
48
+ inputs (Dict[str, Any]): Input parameters matching the process description
49
+
50
+ Returns:
51
+ Dict[str, Any]: Output values matching the process description
52
+
53
+ Raises:
54
+ ValueError: If inputs are invalid
55
+ """
56
+ pass
57
+
58
+ def validate_inputs(self, inputs: Dict[str, Any]) -> bool:
59
+ """
60
+ Validates the input data against the process description.
61
+
62
+ Args:
63
+ inputs (Dict[str, Any]): The input data to validate
64
+
65
+ Returns:
66
+ bool: True if inputs are valid
67
+
68
+ Raises:
69
+ ValueError: With detailed error message if validation fails
70
+ """
71
+ description = self.get_description()
72
+ required_inputs = description.inputs
73
+
74
+ # Check for missing required inputs
75
+ for input_name, input_desc in required_inputs.items():
76
+ if input_desc.minOccurs > 0 and input_name not in inputs:
77
+ raise ValueError(
78
+ f"Missing required input '{input_name}'. "
79
+ f"Description: {input_desc.get('description', 'No description available')}"
80
+ )
81
+
82
+ # Validate input type if schema is provided
83
+ if input_name in inputs:
84
+ expected_type = input_desc.scheme.type
85
+ if expected_type == "string" and not isinstance(inputs[input_name], str):
86
+ raise ValueError(
87
+ f"Invalid type for input '{input_name}'. "
88
+ f"Expected string, got {type(inputs[input_name]).__name__}. "
89
+ f"Description: {input_desc.get('description', 'No description available')}"
90
+ )
91
+ elif expected_type == "number" and not isinstance(inputs[input_name], (int, float)):
92
+ raise ValueError(
93
+ f"Invalid type for input '{input_name}'. "
94
+ f"Expected number, got {type(inputs[input_name]).__name__}. "
95
+ f"Description: {input_desc.get('description', 'No description available')}"
96
+ )
97
+ # Add more type validations as needed
98
+
99
+ return True
100
+
101
+ def validate_outputs(self, outputs: str | List[str]) -> bool:
102
+ """
103
+ Validates the outputs parameter against the process description.
104
+
105
+ Args:
106
+ outputs: Single output identifier or list of output identifiers
107
+
108
+ Returns:
109
+ bool: True if outputs are valid
110
+
111
+ Raises:
112
+ ValueError: If any output identifier is invalid
113
+ """
114
+ description = self.get_description()
115
+ available_outputs = description.outputs.keys()
116
+
117
+ if not available_outputs:
118
+ raise ValueError("Process has no defined outputs")
119
+
120
+ # Convert single string to list for uniform handling
121
+ output_list = [outputs] if isinstance(outputs, str) else outputs
122
+
123
+ if not output_list:
124
+ # If no outputs specified, all outputs are considered valid
125
+ return True
126
+
127
+ # Validate each output identifier
128
+ invalid_outputs = [out for out in output_list if out not in available_outputs]
129
+ if invalid_outputs:
130
+ available = ", ".join(available_outputs)
131
+ invalid = ", ".join(invalid_outputs)
132
+ raise ValueError(
133
+ f"Invalid output identifiers: {invalid}. "
134
+ f"Available outputs are: {available}"
135
+ )
136
+
137
+ return True
@@ -0,0 +1,55 @@
1
+ import json
2
+
3
+ import redis
4
+ from fastapi.encoders import jsonable_encoder
5
+
6
+ from fastprocesses.core.config import settings
7
+ from fastprocesses.core.logging import logger
8
+
9
+
10
+ class Cache:
11
+ def __init__(self, key_prefix: str, ttl_days: int):
12
+ self._redis = redis.Redis.from_url(str(settings.redis_cache_url), decode_responses=True)
13
+ self._key_prefix = key_prefix
14
+ self._ttl_days = ttl_days
15
+
16
+ def get(self, key: str) -> dict:
17
+ logger.debug(f"Getting cache for key: {key}")
18
+ key = self._make_key(key)
19
+ serialized_value = self._redis.get(key)
20
+
21
+ logger.debug(f"{serialized_value}")
22
+ return None if serialized_value is None else json.loads(serialized_value)
23
+
24
+ def put(self, key: str, value: dict) -> None:
25
+ logger.debug(f"Putting cache for key: {key}")
26
+ key = self._make_key(key)
27
+ jsonable_value = jsonable_encoder(value)
28
+ serialized_value = json.dumps(jsonable_value)
29
+ ttl = self._ttl_days * 86400
30
+ self._redis.setex(key, ttl, serialized_value)
31
+
32
+ def delete(self, key: str) -> None:
33
+ logger.debug(f"Deleting cache for key: {key}")
34
+ key = self._make_key(key)
35
+ self._redis.delete(key)
36
+
37
+ def _make_key(self, key: str) -> str:
38
+ return f"{self._key_prefix}:{key}"
39
+
40
+ def keys(self, pattern: str = "*") -> list[str]:
41
+ """
42
+ Get all keys matching the pattern.
43
+
44
+ Args:
45
+ pattern (str): Redis key pattern to match. Defaults to "*".
46
+
47
+ Returns:
48
+ list[str]: List of matching keys without the prefix
49
+ """
50
+ logger.debug(f"Getting keys matching pattern: {pattern}")
51
+ full_pattern = self._make_key(pattern)
52
+ keys = self._redis.keys(full_pattern)
53
+ # Remove prefix from keys before returning
54
+ prefix_len = len(self._key_prefix) + 1 # +1 for the colon
55
+ return [key[prefix_len:] for key in keys]
@@ -0,0 +1,17 @@
1
+ from pydantic import RedisDsn, AnyUrl
2
+ from pydantic_settings import BaseSettings
3
+
4
+
5
+ class OGCProcessesSettings(BaseSettings):
6
+ api_title: str = "Simple Process API"
7
+ api_version: str = "1.0.0"
8
+ api_description: str = "A simple API for running processes"
9
+ celery_broker_url: RedisDsn = RedisDsn("redis://redis:6379/0")
10
+ celery_result_backend: RedisDsn = RedisDsn("redis://redis:6379/0")
11
+ redis_cache_url: RedisDsn = RedisDsn("redis://redis:6379/1")
12
+
13
+ class Config:
14
+ env_file = ".env"
15
+ extra = "ignore"
16
+
17
+ settings = OGCProcessesSettings()
@@ -0,0 +1,75 @@
1
+ import logging
2
+ import sys
3
+
4
+ from loguru import logger
5
+
6
+ # Remove existing handlers
7
+ for handler in logging.root.handlers[:]:
8
+ logging.root.removeHandler(handler)
9
+
10
+ loggers = (
11
+ "uvicorn",
12
+ "uvicorn.access",
13
+ "uvicorn.error",
14
+ "fastapi",
15
+ "asyncio",
16
+ "starlette",
17
+ )
18
+
19
+ for logger_name in loggers:
20
+ logging_logger = logging.getLogger(logger_name)
21
+ logging_logger.handlers = []
22
+ logging_logger.propagate = True
23
+
24
+ class InterceptHandler(logging.Handler):
25
+ def emit(self, record):
26
+ # Get corresponding Loguru level
27
+ try:
28
+ level = logger.level(record.levelname).name
29
+ except ValueError:
30
+ level = record.levelno
31
+
32
+ # Find caller to get correct stack depth
33
+ frame, depth = logging.currentframe(), 2
34
+ while frame.f_back and frame.f_code.co_filename == logging.__file__:
35
+ frame = frame.f_back
36
+ depth += 1
37
+
38
+ logger.opt(depth=depth, exception=record.exc_info).log(
39
+ level, record.getMessage()
40
+ )
41
+
42
+ # Configure logger
43
+ logger.remove() # Remove default logger
44
+
45
+ # Error logs to stderr
46
+ logger.add(
47
+ sys.stderr,
48
+ level="ERROR",
49
+ format="{time:YYYY-MM-DD at HH:mm:ss} | {level} | {message}"
50
+ )
51
+
52
+ # Error logs to file with weekly rotation
53
+ logger.add(
54
+ "logs/errors_{time}.log",
55
+ level="ERROR",
56
+ rotation="1 week",
57
+ retention="1 month",
58
+ format="{time:YYYY-MM-DD at HH:mm:ss} | {level} | {message}"
59
+ )
60
+
61
+ # Info logs to stdout
62
+ # TODO read loglevel from settings!
63
+ logger.add(
64
+ sys.stdout,
65
+ level="DEBUG",
66
+ format="{time:YYYY-MM-DD at HH:mm:ss} | {level} | {message}",
67
+ backtrace=True,
68
+ diagnose=True,
69
+ )
70
+
71
+ # Intercept standard logging
72
+ logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO)
73
+
74
+ # Example usage
75
+ logger.info("Logging setup complete.")
@@ -0,0 +1,188 @@
1
+ import hashlib
2
+ import json
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import Annotated, Any, Dict, List, Literal, Optional, Union
6
+
7
+ from fastapi.encoders import jsonable_encoder
8
+ from pydantic import AfterValidator, BaseModel, ConfigDict, Field, computed_field, field_validator
9
+
10
+
11
+ class Link(BaseModel):
12
+ href: str
13
+ rel: str
14
+ type: str
15
+
16
+
17
+ class Landing(BaseModel):
18
+ title: str
19
+ description: str
20
+ links: List[Link]
21
+
22
+
23
+ class Conformance(BaseModel):
24
+ conformsTo: List[str]
25
+
26
+
27
+ class ProcessJobControlOptions(str, Enum):
28
+ SYNC_EXECUTE = "sync-execute"
29
+ ASYNC_EXECUTE = "async-execute"
30
+ DISMISS = "dismiss"
31
+
32
+
33
+ # TODO: needs to be passed to outputs keys and when part of the data validated
34
+ # TODO: transmission mode can be different for each output
35
+ # https://schemas.opengis.net/ogcapi/processes/part1/1.0/examples/json/ProcessDescription.json
36
+ class ProcessOutputTransmission(str, Enum):
37
+ VALUE = "value"
38
+ REFERENCE = "reference"
39
+
40
+
41
+ class ResponseType(str, Enum):
42
+ RAW = "raw"
43
+ DOCUMENT = "document"
44
+
45
+
46
+ class Schema(BaseModel):
47
+ type: Optional[str] = None
48
+ format: Optional[str] = None
49
+ minimum: Optional[float] = None
50
+ maximum: Optional[float] = None
51
+ minLength: Optional[int] = None
52
+ maxLength: Optional[int] = None
53
+ pattern: Optional[str] = None
54
+ enum: Optional[List[Any]] = None
55
+ properties: Optional[Dict[str, Any]] = None
56
+ required: Optional[List[str]] = None
57
+ items: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None
58
+ oneOf: Optional[List[Dict[str, Any]]] = None
59
+ allOf: Optional[List[Dict[str, Any]]] = None
60
+ contentMediaType: Optional[str] = None
61
+ contentEncoding: Optional[str] = None
62
+ contentSchema: Optional[str] = None
63
+
64
+ class Config:
65
+ exclude_none = True
66
+
67
+
68
+ class ProcessInput(BaseModel):
69
+ title: str
70
+ description: str
71
+ scheme: Schema = Field(alias="schema")
72
+ minOccurs: Optional[int] = 1
73
+ maxOccurs: Optional[int] = 1
74
+ metadata: Optional[Dict[str, Any]] = None
75
+
76
+ class Config:
77
+ exclude_none = True
78
+ populate_by_name = True
79
+
80
+
81
+ class ProcessOutput(BaseModel):
82
+ title: str
83
+ description: str
84
+ scheme: Schema = Field(alias="schema")
85
+ metadata: Optional[Dict[str, Any]] = None
86
+
87
+ model_config = ConfigDict(
88
+ populate_by_name=True,
89
+ exclude_none=True,
90
+ )
91
+
92
+
93
+ class ProcessDescription(BaseModel):
94
+ id: str
95
+ title: str
96
+ description: str
97
+ version: str
98
+ jobControlOptions: List[ProcessJobControlOptions]
99
+ outputTransmission: List[ProcessOutputTransmission]
100
+ inputs: Dict[str, ProcessInput]
101
+ outputs: Dict[str, ProcessOutput]
102
+ links: Optional[List[Link]] = None
103
+ keywords: Optional[List[str]] = None
104
+ metadata: Optional[Dict[str, Any]] = None
105
+
106
+ class Config:
107
+ exclude_none = True
108
+ populate_by_name = True
109
+
110
+
111
+ class ProcessList(BaseModel):
112
+ processes: List[ProcessDescription]
113
+ links: Optional[List[Link]] = None
114
+
115
+
116
+ class ExecutionMode(str, Enum):
117
+ SYNC = "sync"
118
+ ASYNC = "async"
119
+
120
+
121
+ class OutputControl(BaseModel):
122
+ transmissionMode: Literal["value", "reference"] | None = "value"
123
+ format: dict | None = None
124
+
125
+
126
+ class ProcessExecRequestBody(BaseModel):
127
+ inputs: Dict[str, Any]
128
+ outputs: dict[str, dict[str, OutputControl]] | None = None
129
+ mode: Optional[ExecutionMode] = ExecutionMode.ASYNC
130
+ response: Optional[ResponseType] = ResponseType.RAW
131
+
132
+ model_config = ConfigDict(exclude_none=True)
133
+
134
+
135
+ def deserialize_json(value: Any) -> Any:
136
+ return jsonable_encoder(value)
137
+
138
+
139
+ class CalculationTask(BaseModel):
140
+ inputs: Annotated[Dict[str, Any], AfterValidator(deserialize_json)]
141
+ outputs: dict[str, dict[str, OutputControl]] | None = None
142
+ response: ResponseType = ResponseType.RAW
143
+
144
+ def _hash_dict(self):
145
+ return hashlib.sha256(
146
+ json.dumps(self.inputs, sort_keys=True).encode()
147
+ ).hexdigest()
148
+
149
+ @computed_field
150
+ def celery_key(self) -> str:
151
+ return self._hash_dict()
152
+
153
+
154
+ class ProcessExecResponse(BaseModel):
155
+ status: str
156
+ jobID: str
157
+ type: str = "process"
158
+
159
+
160
+ class ProcessSummary(BaseModel):
161
+ """
162
+ The OGC conform ProcessSummary Model.
163
+ """
164
+
165
+ id: str
166
+ version: str
167
+ links: Optional[list[Link]] = None
168
+
169
+
170
+ class JobStatusInfo(BaseModel):
171
+ jobID: str
172
+ status: str
173
+ type: str = "process"
174
+ processID: Optional[str] = None
175
+ message: Optional[str] = None
176
+ created: Optional[datetime] = None
177
+ started: Optional[datetime] = None
178
+ finished: Optional[datetime] = None
179
+ updated: Optional[datetime] = None
180
+ progress: Optional[int] = Field(None, ge=0, le=100)
181
+ links: Optional[List[Link]] = None
182
+
183
+ model_config = ConfigDict(populate_by_name=True, exclude_none=True)
184
+
185
+
186
+ class JobList(BaseModel):
187
+ jobs: List[JobStatusInfo]
188
+ links: List[Link]
File without changes
@@ -0,0 +1,114 @@
1
+ # src/fastprocesses/services/service_registry.py
2
+ import json
3
+ from pydoc import locate
4
+ from typing import List
5
+
6
+ import redis
7
+
8
+ from fastprocesses.core.base_process import BaseProcess
9
+ from fastprocesses.core.config import settings
10
+ from fastprocesses.core.logging import logger
11
+ from fastprocesses.core.models import ProcessDescription
12
+
13
+
14
+ class ProcessRegistry:
15
+ """Manages the registration and retrieval of available services (processes)."""
16
+
17
+ def __init__(self):
18
+ """Initializes the ProcessRegistry with Redis connection."""
19
+ self.redis = redis.Redis.from_url(str(settings.redis_cache_url))
20
+ self.registry_key = "service_registry"
21
+
22
+ def register_service(self, process_id: str, service: BaseProcess):
23
+ """
24
+ Registers a process service in Redis:
25
+ - Stores process description and class path for dynamic loading
26
+ - Uses Redis hash structure for efficient lookups
27
+ - Enables service discovery and instantiation
28
+ """
29
+ try:
30
+ description: ProcessDescription = service.get_description()
31
+
32
+ # serialize the description
33
+ description_dict = description.model_dump(exclude_none=True)
34
+ service_data = {
35
+ "description": description_dict,
36
+ "class_path": f"{service.__module__}.{service.__class__.__name__}"
37
+ }
38
+ logger.debug(f"Service data to be registered: {service_data}")
39
+ self.redis.hset(self.registry_key, process_id, json.dumps(service_data))
40
+ except Exception as e:
41
+ logger.error(f"Failed to register service {process_id}: {e}")
42
+ raise
43
+
44
+ def get_service_ids(self) -> List[str]:
45
+ """
46
+ Retrieves the IDs of all registered services.
47
+
48
+ Returns:
49
+ List[str]: A list of service IDs.
50
+ """
51
+ logger.debug("Retrieving all registered service IDs")
52
+ return [key.decode('utf-8') for key in self.redis.hkeys(self.registry_key)]
53
+
54
+ def has_service(self, process_id: str) -> bool:
55
+ """
56
+ Checks if a service is registered.
57
+
58
+ Args:
59
+ process_id (str): The ID of the process.
60
+
61
+ Returns:
62
+ bool: True if the service is registered, False otherwise.
63
+ """
64
+ logger.debug(f"Checking if service with ID {process_id} is registered")
65
+ return self.redis.hexists(self.registry_key, process_id)
66
+
67
+ def get_service(self, process_id: str) -> BaseProcess:
68
+ """
69
+ Dynamically loads and instantiates a process service:
70
+ 1. Retrieves service metadata from Redis
71
+ 2. Uses Python's module system to locate the class
72
+ 3. Instantiates a new service instance
73
+
74
+ The locate() function dynamically imports the class based on its path.
75
+ """
76
+ logger.info(f"Retrieving service with ID: {process_id}")
77
+ service_data = self.redis.hget(self.registry_key, process_id)
78
+
79
+ if not service_data:
80
+ logger.error(f"Service {process_id} not found!")
81
+ raise ValueError(f"Service {process_id} not found!")
82
+
83
+ service_info = json.loads(service_data)
84
+ service_class = locate(service_info["class_path"])
85
+
86
+ if not service_class:
87
+ logger.error(f"Service class {service_info['class_path']} not found!")
88
+ # raise ValueError(f"Service class {service_info['class_path']} not found!")
89
+
90
+ return service_class()
91
+
92
+ # Global instance of ProcessRegistry
93
+ _global_process_registry = ProcessRegistry()
94
+
95
+ def get_process_registry() -> ProcessRegistry:
96
+ """Returns the global ProcessRegistry instance."""
97
+ return _global_process_registry
98
+
99
+ def register_process(process_id: str):
100
+ """
101
+ Decorator for automatic process registration.
102
+ Allows processes to self-register by simply using @register_process decorator.
103
+ Example:
104
+ @register_process("my_process")
105
+ class MyProcess(BaseProcess):
106
+ ...
107
+ """
108
+ def decorator(cls):
109
+ if not hasattr(cls, 'process_description'):
110
+ raise ValueError(f"Process {cls.__name__} must define a 'description' class variable")
111
+ get_process_registry().register_service(process_id, cls())
112
+ return cls
113
+ return decorator
114
+
fastprocesses/py.typed ADDED
File without changes
File without changes