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.
- fastprocesses/__init__.py +8 -0
- fastprocesses/api/__init__.py +0 -0
- fastprocesses/api/manager.py +377 -0
- fastprocesses/api/router.py +177 -0
- fastprocesses/api/server.py +24 -0
- fastprocesses/celery_worker.py +45 -0
- fastprocesses/common.py +59 -0
- fastprocesses/core/__init__.py +0 -0
- fastprocesses/core/base_process.py +137 -0
- fastprocesses/core/cache.py +55 -0
- fastprocesses/core/config.py +17 -0
- fastprocesses/core/logging.py +75 -0
- fastprocesses/core/models.py +188 -0
- fastprocesses/processes/__init__.py +0 -0
- fastprocesses/processes/process_registry.py +114 -0
- fastprocesses/py.typed +0 -0
- fastprocesses/worker/__init__.py +0 -0
- fastprocesses/worker/celery_app.py +154 -0
- fastprocesses-0.7.4.dist-info/AUTHORS.md +1 -0
- fastprocesses-0.7.4.dist-info/METADATA +274 -0
- fastprocesses-0.7.4.dist-info/RECORD +23 -0
- fastprocesses-0.7.4.dist-info/WHEEL +4 -0
- fastprocesses-0.7.4.dist-info/entry_points.txt +3 -0
|
@@ -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
|