aspyx-service 0.11.2__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,197 @@
1
+ from typing import get_type_hints, get_origin, get_args, Any
2
+ import json
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from aspyx.reflection import TypeDescriptor
7
+
8
+
9
+ class JSONSchemaGenerator:
10
+ """
11
+ Generates a JSON Schema-based service descriptor for framework-agnostic
12
+ service discovery and handshaking between systems.
13
+ """
14
+
15
+ PRIMITIVES = {
16
+ str: {"type": "string"},
17
+ int: {"type": "integer"},
18
+ float: {"type": "number"},
19
+ bool: {"type": "boolean"},
20
+ type(None): {"type": "null"},
21
+ }
22
+
23
+ def __init__(self, service_manager):
24
+ self.service_manager = service_manager
25
+ self.type_defs: dict[str, dict] = {} # Stores reusable type definitions
26
+ self.processed_types: set[type] = set() # Track processed types to avoid duplicates
27
+
28
+ def _get_type_name(self, typ: type) -> str:
29
+ """Get a unique name for a type."""
30
+ if hasattr(typ, '__name__'):
31
+ return typ.__name__
32
+ return str(typ)
33
+
34
+ def _process_type(self, typ: type) -> dict:
35
+ """
36
+ Process a type and return its JSON Schema representation.
37
+ Complex types are added to type_defs and referenced via $ref.
38
+ """
39
+ # Handle None type
40
+ if typ is type(None):
41
+ return {"type": "null"}
42
+
43
+ # Handle primitives
44
+ if typ in self.PRIMITIVES:
45
+ return self.PRIMITIVES[typ].copy()
46
+
47
+ # Handle Optional types (Union[X, None])
48
+ origin = get_origin(typ)
49
+ if origin is type(None) or origin is type(None).__class__:
50
+ return {"type": "null"}
51
+
52
+ # Handle Union types
53
+ if origin is Union or (hasattr(types, 'UnionType') and origin is types.UnionType):
54
+ args = get_args(typ)
55
+ # Check if it's Optional (Union with None)
56
+ if type(None) in args:
57
+ non_none_args = [arg for arg in args if arg is not type(None)]
58
+ if len(non_none_args) == 1:
59
+ # This is Optional[X]
60
+ schema = self._process_type(non_none_args[0])
61
+ return {"anyOf": [schema, {"type": "null"}]}
62
+ # Regular union
63
+ return {"anyOf": [self._process_type(arg) for arg in args]}
64
+
65
+ # Handle List/Sequence types
66
+ if origin in (list, List) or (hasattr(collections.abc, 'Sequence') and origin is collections.abc.Sequence):
67
+ args = get_args(typ)
68
+ item_type = args[0] if args else Any
69
+ return {
70
+ "type": "array",
71
+ "items": self._process_type(item_type)
72
+ }
73
+
74
+ # Handle Dict types
75
+ if origin in (dict, Dict):
76
+ args = get_args(typ)
77
+ if len(args) >= 2:
78
+ return {
79
+ "type": "object",
80
+ "additionalProperties": self._process_type(args[1])
81
+ }
82
+ return {"type": "object"}
83
+
84
+ # Handle Pydantic models
85
+ if isinstance(typ, type) and issubclass(typ, BaseModel):
86
+ type_name = self._get_type_name(typ)
87
+
88
+ # Add to type_defs if not already processed
89
+ if typ not in self.processed_types:
90
+ self.processed_types.add(typ)
91
+ schema = typ.model_json_schema()
92
+ # Remove $defs from individual schemas to avoid nesting
93
+ if '$defs' in schema:
94
+ nested_defs = schema.pop('$defs')
95
+ self.type_defs.update(nested_defs)
96
+ self.type_defs[type_name] = schema
97
+
98
+ return {"$ref": f"#/$defs/{type_name}"}
99
+
100
+ # Fallback for unknown types
101
+ return {"type": "object", "description": f"Unknown type: {typ}"}
102
+
103
+ def _extract_method_info(self, method_desc) -> dict:
104
+ """Extract parameter and return type information from a method."""
105
+ method_info = {
106
+ "description": method_desc.method.__doc__ or "",
107
+ "parameters": {
108
+ "type": "object",
109
+ "properties": {},
110
+ "required": []
111
+ }
112
+ }
113
+
114
+ # Process parameters
115
+ for param in method_desc.params:
116
+ if param.name == 'self':
117
+ continue
118
+
119
+ param_schema = self._process_type(param.type)
120
+ method_info["parameters"]["properties"][param.name] = param_schema
121
+
122
+ # Check if parameter is required (not Optional)
123
+ origin = get_origin(param.type)
124
+ args = get_args(param.type)
125
+ is_optional = origin is Union and type(None) in args
126
+
127
+ # Check if parameter has a default value
128
+ has_default = hasattr(param, 'default') and param.default is not None
129
+
130
+ if not is_optional and not has_default:
131
+ method_info["parameters"]["required"].append(param.name)
132
+
133
+ # If no required parameters, remove the empty list
134
+ if not method_info["parameters"]["required"]:
135
+ del method_info["parameters"]["required"]
136
+
137
+ # Process return type
138
+ if method_desc.return_type and method_desc.return_type is not type(None):
139
+ method_info["returns"] = self._process_type(method_desc.return_type)
140
+ else:
141
+ method_info["returns"] = {"type": "null"}
142
+
143
+ return method_info
144
+
145
+ def generate(self) -> dict:
146
+ """
147
+ Generate a JSON Schema-based service descriptor.
148
+
149
+ Returns a dictionary with:
150
+ - services: Dictionary of service definitions
151
+ - $defs: Reusable type definitions
152
+ """
153
+ schema = {
154
+ "schemaVersion": "1.0.0",
155
+ "services": {},
156
+ "$defs": {}
157
+ }
158
+
159
+ for service_name, service in self.service_manager.descriptors_by_name.items():
160
+ if service.is_component():
161
+ continue
162
+
163
+ descriptor = TypeDescriptor.for_type(service.type)
164
+
165
+ service_schema = {
166
+ "name": service_name,
167
+ "type": self._get_type_name(service.type),
168
+ "description": service.type.__doc__ or "",
169
+ "methods": {}
170
+ }
171
+
172
+ # Process all methods
173
+ for method_desc in descriptor.get_methods():
174
+ method_name = method_desc.method.__name__
175
+
176
+ # Skip private methods
177
+ if method_name.startswith('_'):
178
+ continue
179
+
180
+ service_schema["methods"][method_name] = self._extract_method_info(method_desc)
181
+
182
+ schema["services"][service_name] = service_schema
183
+
184
+ # Add all collected type definitions
185
+ schema["$defs"] = self.type_defs
186
+
187
+ return schema
188
+
189
+ def to_json(self, indent: int = 2) -> str:
190
+ """Generate and serialize the schema to JSON."""
191
+ return json.dumps(self.generate(), indent=indent)
192
+
193
+
194
+ # Import required types
195
+ from typing import Union, List, Dict
196
+ import types
197
+ import collections.abc
@@ -0,0 +1,120 @@
1
+ from typing import TypeVar
2
+
3
+ from pydantic import BaseModel
4
+ from fastapi.openapi.models import OpenAPI, Info, PathItem, Operation, Response, Parameter, RequestBody, \
5
+ MediaType
6
+
7
+ from aspyx.reflection import TypeDescriptor
8
+ from aspyx_service import rest
9
+ from aspyx_service.restchannel import RestChannel
10
+
11
+ T = TypeVar("T")
12
+
13
+ class OpenAPIGenerator:
14
+ PRIMITIVES = {
15
+ str: {"type": "string"},
16
+ int: {"type": "integer", "format": "int32"},
17
+ float: {"type": "number", "format": "float"},
18
+ bool: {"type": "boolean"},
19
+ }
20
+
21
+ def __init__(self, service_manager):
22
+ self.service_manager = service_manager
23
+ self.rest_channel: RestChannel = service_manager.environment.get(RestChannel)
24
+ self.schemas: dict[type, dict] = {}
25
+
26
+ def _get_schema_for_type(self, typ: type) -> dict:
27
+ if typ in self.schemas:
28
+ return self.schemas[typ]
29
+
30
+ if typ in self.PRIMITIVES:
31
+ schema = self.PRIMITIVES[typ]
32
+ elif isinstance(typ, type) and issubclass(typ, BaseModel):
33
+ schema = typ.model_json_schema()
34
+ else:
35
+ schema = {"type": "object"}
36
+
37
+ self.schemas[typ] = schema
38
+ return schema
39
+
40
+ def generate(self) -> OpenAPI:
41
+ from fastapi.openapi.models import Components
42
+
43
+ openapi = OpenAPI(
44
+ openapi="3.1.0",
45
+ info=Info(title="My API", version="1.0.0"),
46
+ paths={},
47
+ components=Components(schemas={}),
48
+ )
49
+
50
+ for service_name, service in self.service_manager.descriptors_by_name.items():
51
+ if service.is_component():
52
+ continue
53
+
54
+ descriptor = TypeDescriptor.for_type(service.type)
55
+
56
+ if not descriptor.has_decorator(rest):
57
+ continue
58
+
59
+ print(service.type)
60
+
61
+ for method_desc in descriptor.get_methods():
62
+ call = self.rest_channel.get_call(service.type, method_desc.method)
63
+
64
+ #if call.type != "get":
65
+ # continue
66
+
67
+ path_item: PathItem = openapi.paths.get(call.url_template, PathItem())
68
+ operation = Operation(
69
+ responses={"200": Response(description="Success")},
70
+ parameters=[],
71
+ )
72
+
73
+ # Add path parameters
74
+ for name in call.path_param_names:
75
+ # get type hint
76
+ hint = next((p.type for p in method_desc.params if p.name == name), str)
77
+ operation.parameters.append(
78
+ Parameter(
79
+ name=name,
80
+ **{"in": "path"}, # Use dict unpacking to avoid keyword conflict
81
+ required=True,
82
+ schema=self._get_schema_for_type(hint),
83
+ )
84
+ )
85
+
86
+ # Add query parameters
87
+ for name in call.query_param_names:
88
+ hint = next((p.type for p in method_desc.params if p.name == name), str)
89
+ operation.parameters.append(
90
+ Parameter(
91
+ name=name,
92
+ **{"in": "query"}, # Use dict unpacking to avoid keyword conflict
93
+ required=True,
94
+ schema=self._get_schema_for_type(hint),
95
+ )
96
+ )
97
+
98
+ # Add request body
99
+ if call.body_param_name:
100
+ hint = next((p.type for p in method_desc.params if p.name == call.body_param_name), dict)
101
+ operation.request_body = RequestBody(
102
+ required=True,
103
+ content={"application/json": MediaType(schema=self._get_schema_for_type(hint))}
104
+ )
105
+
106
+ # attach operation to HTTP method
107
+ setattr(path_item, call.type.lower(), operation)
108
+ openapi.paths[call.url_template] = path_item
109
+
110
+ # attach all cached schemas
111
+ openapi.components.schemas = {k.__name__: v for k, v in self.schemas.items()}
112
+
113
+ return openapi
114
+
115
+ def to_json(self, indent: int = 2) -> str:
116
+ return self.generate().model_dump_json(
117
+ indent=indent,
118
+ exclude_none=True,
119
+ by_alias=True
120
+ )
@@ -0,0 +1,194 @@
1
+ """
2
+ health checks
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ import time
9
+ from enum import Enum
10
+ from typing import Any, Callable, Type, Optional
11
+
12
+ from aspyx.di import injectable, Environment, inject_environment, on_init
13
+ from aspyx.reflection import Decorators, TypeDescriptor
14
+
15
+
16
+ def health_checks():
17
+ """
18
+ Instances of classes that are annotated with @health_checks contain healt mehtods.
19
+ """
20
+ def decorator(cls):
21
+ Decorators.add(cls, health_checks)
22
+
23
+ #if not Providers.is_registered(cls):
24
+ # Providers.register(ClassInstanceProvider(cls, True, "singleton"))
25
+
26
+ HealthCheckManager.types.append(cls)
27
+
28
+ return cls
29
+
30
+ return decorator
31
+
32
+ def health_check(name="", cache = 0, fail_if_slower_than = 0):
33
+ """
34
+ Methods annotated with `@health_check` specify health checks that will be executed.
35
+ """
36
+ def decorator(func):
37
+ Decorators.add(func, health_check, name, cache, fail_if_slower_than)
38
+ return func
39
+
40
+ return decorator
41
+
42
+ class HealthStatus(Enum):
43
+ """
44
+ A enum specifying the health status of a service. The values are:
45
+
46
+ - `OK` service is healthy
47
+ - `WARNING` service has some problems
48
+ - `CRITICAL` service is unhealthy
49
+ """
50
+ OK = 1
51
+ WARNING = 2
52
+ ERROR = 3
53
+
54
+ def __str__(self):
55
+ return self.name
56
+
57
+
58
+ @injectable()
59
+ class HealthCheckManager:
60
+ """
61
+ The health manager is able to run all registered health checks and is able to return an overall status.
62
+ """
63
+ logger = logging.getLogger("aspyx.service.health")
64
+
65
+ # local classes
66
+
67
+ class Check:
68
+ def __init__(self, name: str, cache: int, fail_if_slower_than: int, instance: Any, callable: Callable):
69
+ self.name = name
70
+ self.cache = cache
71
+ self.callable = callable
72
+ self.instance = instance
73
+ self.fail_if_slower_than = fail_if_slower_than
74
+ self.last_check = 0
75
+
76
+ self.last_value : Optional[HealthCheckManager.Result] = None
77
+
78
+ async def run(self, result: HealthCheckManager.Result):
79
+ now = time.time()
80
+
81
+ if self.cache > 0 and self.last_check is not None and now - self.last_check < self.cache:
82
+ result.copy_from(self.last_value)
83
+ return
84
+
85
+ self.last_check = now
86
+ self.last_value = result
87
+
88
+ if asyncio.iscoroutinefunction(self.callable):
89
+ await self.callable(self.instance, result)
90
+ else:
91
+ await asyncio.to_thread(self.callable, self.instance, result)
92
+
93
+ spent = time.time() - now
94
+
95
+ if result.status == HealthStatus.OK and 0 < self.fail_if_slower_than < spent:
96
+ result.status = HealthStatus.ERROR
97
+ result.details = f"spent {spent:.3f}s"
98
+
99
+
100
+ class Result:
101
+ def __init__(self, name: str):
102
+ self.status = HealthStatus.OK
103
+ self.name = name
104
+ self.details = ""
105
+
106
+ def copy_from(self, value: HealthCheckManager.Result):
107
+ self.status = value.status
108
+ self.details = value.details
109
+
110
+ def set_status(self, status: HealthStatus, details =""):
111
+ self.status = status
112
+ self.details = details
113
+
114
+ def to_dict(self):
115
+ result = {
116
+ "name": self.name,
117
+ "status": str(self.status),
118
+ }
119
+
120
+ if self.details:
121
+ result["details"] = self.details
122
+
123
+ return result
124
+
125
+ class Health:
126
+ def __init__(self, status: HealthStatus = HealthStatus.OK):
127
+ self.status = status
128
+ self.results : list[HealthCheckManager.Result] = []
129
+
130
+ def to_dict(self):
131
+ return {
132
+ "status": str(self.status),
133
+ "checks": [result.to_dict() for result in self.results]
134
+ }
135
+
136
+ # class data
137
+
138
+ types : list[Type] = []
139
+
140
+ # constructor
141
+
142
+ def __init__(self):
143
+ self.environment : Optional[Environment] = None
144
+ self.checks: list[HealthCheckManager.Check] = []
145
+
146
+ # check
147
+
148
+ async def check(self) -> HealthCheckManager.Health:
149
+ """
150
+ run all registered health checks and return an overall result.
151
+ Returns: the overall result.
152
+
153
+ """
154
+ self.logger.info("Checking health...")
155
+
156
+ health = HealthCheckManager.Health()
157
+
158
+ tasks = []
159
+ for check in self.checks:
160
+ result = HealthCheckManager.Result(check.name)
161
+ health.results.append(result)
162
+ tasks.append(check.run(result))
163
+
164
+ await asyncio.gather(*tasks)
165
+
166
+ for result in health.results:
167
+ if result.status.value > health.status.value:
168
+ health.status = result.status
169
+
170
+ return health
171
+
172
+ # public
173
+
174
+ @inject_environment()
175
+ def set_environment(self, environment: Environment):
176
+ self.environment = environment
177
+
178
+ @on_init()
179
+ def setup(self):
180
+ for type in self.types:
181
+ descriptor = TypeDescriptor(type).for_type(type)
182
+ instance = self.environment.get(type)
183
+
184
+ for method in descriptor.get_methods():
185
+ if method.has_decorator(health_check):
186
+ decorator = method.get_decorator(health_check)
187
+
188
+ name = decorator.args[0]
189
+ cache = decorator.args[1]
190
+ fail_if_slower_than = decorator.args[2]
191
+ if len(name) == 0:
192
+ name = method.get_name()
193
+
194
+ self.checks.append(HealthCheckManager.Check(name, cache, fail_if_slower_than, instance, method.method))