tachyon-api 0.5.5__py3-none-any.whl → 0.5.9__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.

Potentially problematic release.


This version of tachyon-api might be problematic. Click here for more details.

tachyon_api/cache.py ADDED
@@ -0,0 +1,270 @@
1
+ """
2
+ Cache utilities: decorator with TTL and pluggable backends.
3
+
4
+ This module provides:
5
+ - BaseCacheBackend protocol and an in-memory implementation
6
+ - CacheConfig dataclass and helpers to set global/app config
7
+ - cache decorator usable on any sync/async function (including routes)
8
+
9
+ Design notes:
10
+ - Key builder defaults to a stable representation of function + args/kwargs
11
+ - Unless predicate allows opt-out per-call
12
+ - TTL can be provided per-decorator or falls back to global default
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ import asyncio
19
+ import hashlib
20
+ from dataclasses import dataclass
21
+ from functools import wraps
22
+ from typing import Any, Callable, Optional, Tuple
23
+
24
+
25
+ class BaseCacheBackend:
26
+ """Minimal cache backend interface."""
27
+
28
+ def get(self, key: str) -> Any: # pragma: no cover - interface
29
+ raise NotImplementedError
30
+
31
+ def set(
32
+ self, key: str, value: Any, ttl: Optional[float] = None
33
+ ) -> None: # pragma: no cover - interface
34
+ raise NotImplementedError
35
+
36
+ def delete(self, key: str) -> None: # pragma: no cover - interface
37
+ raise NotImplementedError
38
+
39
+ def clear(self) -> None: # pragma: no cover - interface
40
+ raise NotImplementedError
41
+
42
+
43
+ class InMemoryCacheBackend(BaseCacheBackend):
44
+ """Simple in-memory cache with TTL using wall-clock time."""
45
+
46
+ def __init__(self) -> None:
47
+ self._store: dict[str, Tuple[float | None, Any]] = {}
48
+
49
+ def _is_expired(self, expires_at: float | None) -> bool:
50
+ return expires_at is not None and time.time() >= expires_at
51
+
52
+ def get(self, key: str) -> Any:
53
+ item = self._store.get(key)
54
+ if not item:
55
+ return None
56
+ expires_at, value = item
57
+ if self._is_expired(expires_at):
58
+ # Lazy expiration
59
+ try:
60
+ del self._store[key]
61
+ except KeyError:
62
+ pass
63
+ return None
64
+ return value
65
+
66
+ def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
67
+ expires_at = time.time() + ttl if ttl and ttl > 0 else None
68
+ self._store[key] = (expires_at, value)
69
+
70
+ def delete(self, key: str) -> None:
71
+ self._store.pop(key, None)
72
+
73
+ def clear(self) -> None:
74
+ self._store.clear()
75
+
76
+
77
+ @dataclass
78
+ class CacheConfig:
79
+ backend: BaseCacheBackend
80
+ default_ttl: float = 60.0
81
+ key_builder: Optional[Callable[[Callable, tuple, dict], str]] = None
82
+ enabled: bool = True
83
+
84
+
85
+ _cache_config: Optional[CacheConfig] = None
86
+
87
+
88
+ def _default_key_builder(func: Callable, args: tuple, kwargs: dict) -> str:
89
+ parts = [getattr(func, "__module__", ""), getattr(func, "__qualname__", ""), "|"]
90
+ # Stable kwargs order
91
+ items = [repr(a) for a in args] + [
92
+ f"{k}={repr(v)}" for k, v in sorted(kwargs.items())
93
+ ]
94
+ raw_key = ":".join(parts + items)
95
+ return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
96
+
97
+
98
+ def create_cache_config(
99
+ backend: Optional[BaseCacheBackend] = None,
100
+ default_ttl: float = 60.0,
101
+ key_builder: Optional[Callable[[Callable, tuple, dict], str]] = None,
102
+ enabled: bool = True,
103
+ ) -> CacheConfig:
104
+ """Create and set the global cache configuration.
105
+
106
+ Returns the created CacheConfig and sets it as the active global config.
107
+ """
108
+ global _cache_config
109
+ cfg = CacheConfig(
110
+ backend=backend or InMemoryCacheBackend(),
111
+ default_ttl=default_ttl,
112
+ key_builder=key_builder,
113
+ enabled=enabled,
114
+ )
115
+ _cache_config = cfg
116
+ return cfg
117
+
118
+
119
+ def set_cache_config(config: CacheConfig) -> None:
120
+ """Set the global cache configuration object."""
121
+ global _cache_config
122
+ _cache_config = config
123
+
124
+
125
+ def get_cache_config() -> CacheConfig:
126
+ """Get the current cache configuration, creating a default one if missing."""
127
+ global _cache_config
128
+ if _cache_config is None:
129
+ _cache_config = create_cache_config()
130
+ return _cache_config
131
+
132
+
133
+ def cache(
134
+ TTL: Optional[float] = None,
135
+ *,
136
+ key_builder: Optional[Callable[[Callable, tuple, dict], str]] = None,
137
+ unless: Optional[Callable[[tuple, dict], bool]] = None,
138
+ backend: Optional[BaseCacheBackend] = None,
139
+ ):
140
+ """Cache decorator with TTL and pluggable backend.
141
+
142
+ Args:
143
+ TTL: Time-to-live in seconds for this decorator instance. Falls back to config.default_ttl.
144
+ key_builder: Optional custom function to build cache keys.
145
+ unless: Predicate receiving (args, kwargs). If returns True, skip cache for that call.
146
+ backend: Optional backend override for this decorator.
147
+ """
148
+
149
+ def decorator(func: Callable):
150
+ cfg = get_cache_config()
151
+ be = backend or cfg.backend
152
+ ttl_value = cfg.default_ttl if TTL is None else TTL
153
+ kb = (
154
+ key_builder
155
+ or cfg.key_builder
156
+ or (lambda f, a, kw: _default_key_builder(f, a, kw))
157
+ )
158
+
159
+ if asyncio.iscoroutinefunction(func):
160
+
161
+ @wraps(func)
162
+ async def async_wrapper(*args, **kwargs):
163
+ if not cfg.enabled or (unless and unless(args, kwargs)):
164
+ return await func(*args, **kwargs)
165
+ key = kb(func, args, kwargs)
166
+ cached = be.get(key)
167
+ if cached is not None:
168
+ return cached
169
+ result = await func(*args, **kwargs)
170
+ try:
171
+ be.set(key, result, ttl_value)
172
+ except Exception:
173
+ # Backend errors should not break the app
174
+ pass
175
+ return result
176
+
177
+ return async_wrapper
178
+ else:
179
+
180
+ @wraps(func)
181
+ def wrapper(*args, **kwargs):
182
+ if not cfg.enabled or (unless and unless(args, kwargs)):
183
+ return func(*args, **kwargs)
184
+ key = kb(func, args, kwargs)
185
+ cached = be.get(key)
186
+ if cached is not None:
187
+ return cached
188
+ result = func(*args, **kwargs)
189
+ try:
190
+ be.set(key, result, ttl_value)
191
+ except Exception:
192
+ pass
193
+ return result
194
+
195
+ return wrapper
196
+
197
+ return decorator
198
+
199
+
200
+ class RedisCacheBackend(BaseCacheBackend):
201
+ """Adapter backend for Redis-like clients.
202
+
203
+ Expects a client with .get(key) -> bytes|str|None and .set(key, value, ex=ttl_seconds).
204
+ The stored values should be JSON-serializable or pickled externally by the user.
205
+ """
206
+
207
+ def __init__(self, client) -> None:
208
+ self.client = client
209
+
210
+ def get(self, key: str) -> Any:
211
+ value = self.client.get(key)
212
+ # Many clients return bytes; decode if possible
213
+ if isinstance(value, bytes):
214
+ try:
215
+ return value.decode("utf-8")
216
+ except Exception:
217
+ return value
218
+ return value
219
+
220
+ def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
221
+ # Use ex (expire seconds) if available
222
+ kwargs = {}
223
+ if ttl and ttl > 0:
224
+ kwargs["ex"] = int(ttl)
225
+ # Best effort set
226
+ self.client.set(key, value, **kwargs)
227
+
228
+ def delete(self, key: str) -> None:
229
+ try:
230
+ self.client.delete(key)
231
+ except Exception:
232
+ pass
233
+
234
+ def clear(self) -> None:
235
+ # Not standardized; no-op by default
236
+ pass
237
+
238
+
239
+ class MemcachedCacheBackend(BaseCacheBackend):
240
+ """Adapter backend for Memcached-like clients.
241
+
242
+ Expects a client with .get(key) and .set(key, value, expire=ttl_seconds).
243
+ """
244
+
245
+ def __init__(self, client) -> None:
246
+ self.client = client
247
+
248
+ def get(self, key: str) -> Any:
249
+ return self.client.get(key)
250
+
251
+ def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
252
+ expire = int(ttl) if ttl and ttl > 0 else 0
253
+ try:
254
+ # pymemcache: set(key, value, expire=...)
255
+ self.client.set(key, value, expire=expire)
256
+ except TypeError:
257
+ # python-binary-memcached: set(key, value, time=...)
258
+ self.client.set(key, value, time=expire)
259
+
260
+ def delete(self, key: str) -> None:
261
+ try:
262
+ self.client.delete(key)
263
+ except Exception:
264
+ pass
265
+
266
+ def clear(self) -> None:
267
+ try:
268
+ self.client.flush_all()
269
+ except Exception:
270
+ pass
@@ -2,4 +2,3 @@ from .cors import CORSMiddleware
2
2
  from .logger import LoggerMiddleware
3
3
 
4
4
  __all__ = ["CORSMiddleware", "LoggerMiddleware"]
5
-
@@ -70,7 +70,8 @@ class CORSMiddleware:
70
70
  method = scope.get("method", "").upper()
71
71
  is_preflight = (
72
72
  method == "OPTIONS"
73
- and self._get_header(req_headers, "access-control-request-method") is not None
73
+ and self._get_header(req_headers, "access-control-request-method")
74
+ is not None
74
75
  )
75
76
 
76
77
  # Build common CORS headers
@@ -89,7 +90,9 @@ class CORSMiddleware:
89
90
 
90
91
  # Credentials
91
92
  if self.allow_credentials:
92
- self._append_header(headers_out, "access-control-allow-credentials", "true")
93
+ self._append_header(
94
+ headers_out, "access-control-allow-credentials", "true"
95
+ )
93
96
 
94
97
  return headers_out
95
98
 
@@ -98,28 +101,42 @@ class CORSMiddleware:
98
101
  resp_headers = build_cors_headers()
99
102
  if resp_headers:
100
103
  # Methods
101
- allow_methods = ", ".join(self.allow_methods) if "*" not in self.allow_methods else "*"
102
- self._append_header(resp_headers, "access-control-allow-methods", allow_methods)
104
+ allow_methods = (
105
+ ", ".join(self.allow_methods)
106
+ if "*" not in self.allow_methods
107
+ else "*"
108
+ )
109
+ self._append_header(
110
+ resp_headers, "access-control-allow-methods", allow_methods
111
+ )
103
112
 
104
113
  # Requested headers or wildcard
105
- req_acrh = self._get_header(req_headers, "access-control-request-headers")
114
+ req_acrh = self._get_header(
115
+ req_headers, "access-control-request-headers"
116
+ )
106
117
  if "*" in self.allow_headers:
107
118
  allow_headers = req_acrh or "*"
108
119
  else:
109
120
  allow_headers = ", ".join(self.allow_headers)
110
121
  if allow_headers:
111
- self._append_header(resp_headers, "access-control-allow-headers", allow_headers)
122
+ self._append_header(
123
+ resp_headers, "access-control-allow-headers", allow_headers
124
+ )
112
125
 
113
126
  # Max-Age
114
127
  if self.max_age:
115
- self._append_header(resp_headers, "access-control-max-age", str(self.max_age))
128
+ self._append_header(
129
+ resp_headers, "access-control-max-age", str(self.max_age)
130
+ )
116
131
 
117
132
  # Respond directly
118
- await send({
119
- "type": "http.response.start",
120
- "status": 200,
121
- "headers": resp_headers,
122
- })
133
+ await send(
134
+ {
135
+ "type": "http.response.start",
136
+ "status": 200,
137
+ "headers": resp_headers,
138
+ }
139
+ )
123
140
  await send({"type": "http.response.body", "body": b""})
124
141
  return
125
142
  # If origin not allowed, continue the chain (app will respond accordingly)
@@ -79,7 +79,9 @@ class LoggerMiddleware:
79
79
  receive_to_use = receive_passthrough
80
80
  if body_chunks and not more_body:
81
81
  try:
82
- request_body_preview = b"".join(body_chunks)[:2048].decode("utf-8", "replace")
82
+ request_body_preview = b"".join(body_chunks)[:2048].decode(
83
+ "utf-8", "replace"
84
+ )
83
85
  except Exception:
84
86
  request_body_preview = "<non-text body>"
85
87
  else:
@@ -113,7 +115,9 @@ class LoggerMiddleware:
113
115
  finally:
114
116
  duration = time.time() - start
115
117
  status = status_code_holder["status"] or 0
116
- self.logger.log(self.level, f"<-- {method} {path} {status} ({duration:.4f}s)")
118
+ self.logger.log(
119
+ self.level, f"<-- {method} {path} {status} ({duration:.4f}s)"
120
+ )
117
121
  if self.include_headers and response_headers_holder:
118
122
  res_headers = _normalized_headers(response_headers_holder)
119
123
  self.logger.log(self.level, f" res headers: {res_headers}")
tachyon_api/openapi.py CHANGED
@@ -1,5 +1,9 @@
1
- from typing import Dict, Any, Optional, List, Type
1
+ from typing import Dict, Any, Optional, List, Type, get_origin, get_args
2
2
  from dataclasses import dataclass, field
3
+ import datetime
4
+ import uuid
5
+ import typing
6
+ import json
3
7
 
4
8
  from .models import Struct
5
9
 
@@ -7,30 +11,114 @@ from .models import Struct
7
11
  TYPE_MAP = {int: "integer", str: "string", bool: "boolean", float: "number"}
8
12
 
9
13
 
10
- def _generate_schema_for_struct(struct_class: Type[Struct]) -> Dict[str, Any]:
14
+ def _schema_for_python_type(
15
+ py_type: Type,
16
+ components: Dict[str, Dict[str, Any]],
17
+ visited: set[Type],
18
+ ) -> Dict[str, Any]:
19
+ """Return OpenAPI schema for a Python type, adding components for Structs if needed."""
20
+ origin = get_origin(py_type)
21
+ args = get_args(py_type)
22
+
23
+ # Optional[T] (Union[T, None])
24
+ if origin is typing.Union and args:
25
+ non_none = [a for a in args if a is not type(None)] # noqa: E721
26
+ if len(non_none) == 1:
27
+ inner = non_none[0]
28
+ schema = _schema_for_python_type(inner, components, visited)
29
+ schema["nullable"] = True
30
+ return schema
31
+
32
+ # List[T]
33
+ if origin in (list, List):
34
+ item = args[0] if args else str
35
+ item_schema = _schema_for_python_type(item, components, visited)
36
+ return {"type": "array", "items": item_schema}
37
+
38
+ # Struct subclass
39
+ if isinstance(py_type, type) and issubclass(py_type, Struct):
40
+ name = py_type.__name__
41
+ if py_type not in visited:
42
+ visited.add(py_type)
43
+ components[name] = _generate_struct_schema(py_type, components, visited)
44
+ return {"$ref": f"#/components/schemas/{name}"}
45
+
46
+ # Special formats
47
+ if py_type is uuid.UUID:
48
+ return {"type": "string", "format": "uuid"}
49
+ if py_type is datetime.datetime:
50
+ return {"type": "string", "format": "date-time"}
51
+ if py_type is datetime.date:
52
+ return {"type": "string", "format": "date"}
53
+
54
+ # Scalars
55
+ return {"type": TYPE_MAP.get(py_type, "string")}
56
+
57
+
58
+ def _unwrap_optional(py_type: Type) -> tuple[Type, bool]:
59
+ origin = get_origin(py_type)
60
+ args = get_args(py_type)
61
+ if origin is typing.Union and args:
62
+ non_none = [a for a in args if a is not type(None)] # noqa: E721
63
+ if len(non_none) == 1:
64
+ return non_none[0], True
65
+ return py_type, False
66
+
67
+
68
+ def _generate_struct_schema(
69
+ struct_class: Type[Struct],
70
+ components: Dict[str, Dict[str, Any]],
71
+ visited: set[Type],
72
+ ) -> Dict[str, Any]:
73
+ """
74
+ Generate a JSON Schema dictionary for a msgspec Struct, populating components for nested Structs.
11
75
  """
12
- Generate a JSON Schema dictionary from a tachyon_api.models.Struct.
76
+ properties: Dict[str, Any] = {}
77
+ required: List[str] = []
13
78
 
14
- Args:
15
- struct_class: The Struct class to generate schema for
79
+ annotations = getattr(struct_class, "__annotations__", {})
80
+ for field_name in getattr(struct_class, "__struct_fields__", annotations.keys()):
81
+ field_type = annotations.get(field_name, str)
82
+ base_type, is_opt = _unwrap_optional(field_type)
16
83
 
17
- Returns:
18
- Dictionary containing the OpenAPI schema for the struct
84
+ # Build property schema
85
+ prop_schema = _schema_for_python_type(base_type, components, visited)
86
+ if is_opt:
87
+ prop_schema["nullable"] = True
88
+
89
+ properties[field_name] = prop_schema
90
+
91
+ # Determine required: mark non-Optional fields as required
92
+ if not is_opt:
93
+ required.append(field_name)
94
+
95
+ schema: Dict[str, Any] = {"type": "object", "properties": properties}
96
+ if required:
97
+ schema["required"] = required
98
+ return schema
99
+
100
+
101
+ def build_components_for_struct(
102
+ struct_class: Type[Struct],
103
+ ) -> Dict[str, Dict[str, Any]]:
19
104
  """
20
- properties = {}
21
- required = []
22
-
23
- # Use msgspec's introspection tools
24
- for field_name in struct_class.__struct_fields__:
25
- field_type = struct_class.__annotations__.get(field_name)
26
- properties[field_name] = {
27
- "type": TYPE_MAP.get(field_type, "string"),
28
- "title": field_name.replace("_", " ").title(),
29
- }
30
- # For now, assume all fields are required (can be enhanced later)
31
- required.append(field_name)
105
+ Build components schemas for the given Struct and all nested Structs.
106
+
107
+ Returns a dict mapping component name to schema, including the top-level struct.
108
+ """
109
+ components: Dict[str, Dict[str, Any]] = {}
110
+ visited: set[Type] = set()
111
+ name = struct_class.__name__
112
+ components[name] = _generate_struct_schema(struct_class, components, visited)
113
+ return components
114
+
32
115
 
33
- return {"type": "object", "properties": properties, "required": required}
116
+ def _generate_schema_for_struct(struct_class: Type[Struct]) -> Dict[str, Any]:
117
+ """
118
+ Backward-compatible API: generate only the schema for the provided Struct (no nested components registration).
119
+ """
120
+ comps = build_components_for_struct(struct_class)
121
+ return comps[struct_class.__name__]
34
122
 
35
123
 
36
124
  @dataclass
@@ -175,13 +263,8 @@ class OpenAPIGenerator:
175
263
  """Generate HTML for Swagger UI"""
176
264
  swagger_ui_parameters = self.config.swagger_ui_parameters or {}
177
265
 
178
- # Convert parameters to JSON string, handling Python booleans correctly
179
- params_json = (
180
- str(swagger_ui_parameters)
181
- .replace("'", '"')
182
- .replace("True", "true")
183
- .replace("False", "false")
184
- )
266
+ # Serialize parameters to JSON safely
267
+ params_json = json.dumps(swagger_ui_parameters)
185
268
 
186
269
  html = f"""<!DOCTYPE html>
187
270
  <html>
@@ -268,7 +351,6 @@ class OpenAPIGenerator:
268
351
  """
269
352
  if self._openapi_schema is None:
270
353
  self._openapi_schema = self.config.to_openapi_dict()
271
-
272
354
  if path not in self._openapi_schema["paths"]:
273
355
  self._openapi_schema["paths"][path] = {}
274
356
 
tachyon_api/responses.py CHANGED
@@ -6,12 +6,23 @@ with Starlette responses.
6
6
  """
7
7
 
8
8
  from starlette.responses import JSONResponse, HTMLResponse # noqa
9
+ from .models import encode_json
10
+
11
+
12
+ class TachyonJSONResponse(JSONResponse):
13
+ """High-performance JSON response using orjson for serialization."""
14
+
15
+ media_type = "application/json"
16
+
17
+ def render(self, content) -> bytes: # type: ignore[override]
18
+ # Use centralized encoder to support Struct, UUID, date, datetime, etc.
19
+ return encode_json(content)
9
20
 
10
21
 
11
22
  # Simple helper functions for common response patterns
12
23
  def success_response(data=None, message="Success", status_code=200):
13
24
  """Create a success response with consistent structure"""
14
- return JSONResponse(
25
+ return TachyonJSONResponse(
15
26
  {"success": True, "message": message, "data": data}, status_code=status_code
16
27
  )
17
28
 
@@ -22,7 +33,7 @@ def error_response(error, status_code=400, code=None):
22
33
  if code:
23
34
  response_data["code"] = code
24
35
 
25
- return JSONResponse(response_data, status_code=status_code)
36
+ return TachyonJSONResponse(response_data, status_code=status_code)
26
37
 
27
38
 
28
39
  def not_found_response(error="Resource not found"):
@@ -41,7 +52,39 @@ def validation_error_response(error="Validation failed", errors=None):
41
52
  if errors:
42
53
  response_data["errors"] = errors
43
54
 
44
- return JSONResponse(response_data, status_code=422)
55
+ return TachyonJSONResponse(response_data, status_code=422)
56
+
57
+
58
+ def response_validation_error_response(error="Response validation error"):
59
+ """Create a 500 response validation error response"""
60
+ # Normalize message with prefix and include 'detail' for backward compatibility
61
+ msg = str(error)
62
+ if not msg.lower().startswith("response validation error"):
63
+ msg = f"Response validation error: {msg}"
64
+ return TachyonJSONResponse(
65
+ {
66
+ "success": False,
67
+ "error": msg,
68
+ "detail": msg,
69
+ "code": "RESPONSE_VALIDATION_ERROR",
70
+ },
71
+ status_code=500,
72
+ )
73
+
74
+
75
+ def internal_server_error_response():
76
+ """Create a 500 internal server error response for unhandled exceptions.
77
+
78
+ This intentionally avoids leaking internal exception details in the payload.
79
+ """
80
+ return TachyonJSONResponse(
81
+ {
82
+ "success": False,
83
+ "error": "Internal Server Error",
84
+ "code": "INTERNAL_SERVER_ERROR",
85
+ },
86
+ status_code=500,
87
+ )
45
88
 
46
89
 
47
90
  # Re-export Starlette responses for convenience
@@ -0,0 +1,14 @@
1
+ """
2
+ Tachyon API - Utilities Module
3
+
4
+ This package contains utility functions and classes that provide common functionality
5
+ across the Tachyon framework, including type conversion, validation, and helper functions.
6
+ """
7
+
8
+ from .type_utils import TypeUtils
9
+ from .type_converter import TypeConverter
10
+
11
+ __all__ = [
12
+ "TypeUtils",
13
+ "TypeConverter",
14
+ ]