tachyon-api 0.5.5__py3-none-any.whl → 0.5.7__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/__init__.py CHANGED
@@ -18,6 +18,17 @@ from .models import Struct
18
18
  from .params import Query, Body, Path
19
19
  from .di import injectable, Depends
20
20
  from .router import Router
21
+ from .cache import (
22
+ cache,
23
+ CacheConfig,
24
+ create_cache_config,
25
+ set_cache_config,
26
+ get_cache_config,
27
+ InMemoryCacheBackend,
28
+ BaseCacheBackend,
29
+ RedisCacheBackend,
30
+ MemcachedCacheBackend,
31
+ )
21
32
 
22
33
  __all__ = [
23
34
  "Tachyon",
@@ -28,4 +39,13 @@ __all__ = [
28
39
  "injectable",
29
40
  "Depends",
30
41
  "Router",
42
+ "cache",
43
+ "CacheConfig",
44
+ "create_cache_config",
45
+ "set_cache_config",
46
+ "get_cache_config",
47
+ "InMemoryCacheBackend",
48
+ "BaseCacheBackend",
49
+ "RedisCacheBackend",
50
+ "MemcachedCacheBackend",
31
51
  ]
tachyon_api/app.py CHANGED
@@ -11,6 +11,7 @@ import inspect
11
11
  import msgspec
12
12
  from functools import partial
13
13
  from typing import Any, Dict, Type, Union, Callable
14
+ import typing
14
15
 
15
16
  from starlette.applications import Starlette
16
17
  from starlette.responses import HTMLResponse, JSONResponse, Response
@@ -29,6 +30,16 @@ from .middlewares.core import (
29
30
  apply_middleware_to_router,
30
31
  create_decorated_middleware_class,
31
32
  )
33
+ from .responses import (
34
+ TachyonJSONResponse,
35
+ validation_error_response,
36
+ response_validation_error_response,
37
+ )
38
+ # New: optional cache configuration support
39
+ try:
40
+ from .cache import set_cache_config
41
+ except Exception: # pragma: no cover - defensive import guard
42
+ set_cache_config = None # type: ignore
32
43
 
33
44
 
34
45
  class Tachyon:
@@ -46,13 +57,15 @@ class Tachyon:
46
57
  openapi_generator: Generator for OpenAPI schema and documentation
47
58
  """
48
59
 
49
- def __init__(self, openapi_config: OpenAPIConfig = None):
60
+ def __init__(self, openapi_config: OpenAPIConfig = None, cache_config=None):
50
61
  """
51
62
  Initialize a new Tachyon application instance.
52
63
 
53
64
  Args:
54
65
  openapi_config: Optional OpenAPI configuration. If not provided,
55
66
  uses default configuration similar to FastAPI.
67
+ cache_config: Optional cache configuration (tachyon_api.cache.CacheConfig).
68
+ If provided, it will be set as the active cache configuration.
56
69
  """
57
70
  self._router = Starlette()
58
71
  self.routes = []
@@ -64,6 +77,15 @@ class Tachyon:
64
77
  self.openapi_generator = OpenAPIGenerator(self.openapi_config)
65
78
  self._docs_setup = False
66
79
 
80
+ # Apply cache configuration if provided
81
+ self.cache_config = cache_config
82
+ if cache_config is not None and set_cache_config is not None:
83
+ try:
84
+ set_cache_config(cache_config)
85
+ except Exception:
86
+ # Do not break app initialization if cache setup fails
87
+ pass
88
+
67
89
  # Dynamically create HTTP method decorators (get, post, put, delete, etc.)
68
90
  http_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
69
91
 
@@ -100,6 +122,14 @@ class Tachyon:
100
122
  - Boolean conversion accepts: "true", "1", "t", "yes" (case-insensitive)
101
123
  - Path parameter errors return 404, query parameter errors return 422
102
124
  """
125
+ # Unwrap Optional/Union[T, None]
126
+ origin = typing.get_origin(target_type)
127
+ args = typing.get_args(target_type)
128
+ if origin is Union and args:
129
+ non_none = [a for a in args if a is not type(None)] # noqa: E721
130
+ if len(non_none) == 1:
131
+ target_type = non_none[0]
132
+
103
133
  try:
104
134
  if target_type is bool:
105
135
  return value_str.lower() in ("true", "1", "t", "yes")
@@ -111,11 +141,19 @@ class Tachyon:
111
141
  if is_path_param:
112
142
  return JSONResponse({"detail": "Not Found"}, status_code=404)
113
143
  else:
114
- type_name = "integer" if target_type is int else target_type.__name__
115
- return JSONResponse(
116
- {"detail": f"Invalid value for {type_name} conversion"},
117
- status_code=422,
118
- )
144
+ type_name = "integer" if target_type is int else getattr(target_type, "__name__", str(target_type))
145
+ return validation_error_response(f"Invalid value for {type_name} conversion")
146
+
147
+ @staticmethod
148
+ def _unwrap_optional(python_type: Type) -> tuple[Type, bool]:
149
+ """Return (inner_type, is_optional) for Optional[T] or (python_type, False)."""
150
+ origin = typing.get_origin(python_type)
151
+ args = typing.get_args(python_type)
152
+ if origin is Union and args:
153
+ non_none = [a for a in args if a is not type(None)] # noqa: E721
154
+ if len(non_none) == 1:
155
+ return non_none[0], True
156
+ return python_type, False
119
157
 
120
158
  def _resolve_dependency(self, cls: Type) -> Any:
121
159
  """
@@ -210,6 +248,8 @@ class Tachyon:
210
248
  4. Path parameters (both explicit with Path() and implicit from URL)
211
249
  """
212
250
 
251
+ response_model = kwargs.get("response_model")
252
+
213
253
  async def handler(request):
214
254
  """
215
255
  Async request handler that processes parameters and calls the endpoint.
@@ -254,33 +294,92 @@ class Tachyon:
254
294
  validated_data = decoder.decode(_raw_body)
255
295
  kwargs_to_inject[param.name] = validated_data
256
296
  except msgspec.ValidationError as e:
257
- return JSONResponse({"detail": str(e)}, status_code=422)
297
+ # Attempt to build field errors map using e.path
298
+ field_errors = None
299
+ try:
300
+ path = getattr(e, "path", None)
301
+ if path:
302
+ # Choose last string-ish path element as field name
303
+ field_name = None
304
+ for p in reversed(path):
305
+ if isinstance(p, str):
306
+ field_name = p
307
+ break
308
+ if field_name:
309
+ field_errors = {field_name: [str(e)]}
310
+ except Exception:
311
+ field_errors = None
312
+ return validation_error_response(str(e), errors=field_errors)
258
313
 
259
314
  # Process Query parameters (URL query string)
260
315
  elif isinstance(param.default, Query):
261
316
  query_info = param.default
262
317
  param_name = param.name
263
318
 
319
+ # Determine typing for advanced cases
320
+ ann = param.annotation
321
+ origin = typing.get_origin(ann)
322
+ args = typing.get_args(ann)
323
+
324
+ # List[T] handling
325
+ if origin in (list, typing.List):
326
+ item_type = args[0] if args else str
327
+ values = []
328
+ # collect repeated params
329
+ if hasattr(query_params, "getlist"):
330
+ values = query_params.getlist(param_name)
331
+ # if not repeated, check for CSV in single value
332
+ if not values and param_name in query_params:
333
+ raw = query_params[param_name]
334
+ values = raw.split(",") if "," in raw else [raw]
335
+ # flatten CSV in any element
336
+ flat_values = []
337
+ for v in values:
338
+ if isinstance(v, str) and "," in v:
339
+ flat_values.extend(v.split(","))
340
+ else:
341
+ flat_values.append(v)
342
+ values = flat_values
343
+ if not values:
344
+ if query_info.default is not ...:
345
+ kwargs_to_inject[param_name] = query_info.default
346
+ continue
347
+ return validation_error_response(
348
+ f"Missing required query parameter: {param_name}"
349
+ )
350
+ # Unwrap Optional for item type
351
+ base_item_type, item_is_opt = self._unwrap_optional(item_type)
352
+ converted_list = []
353
+ for v in values:
354
+ if item_is_opt and (v == "" or v.lower() == "null"):
355
+ converted_list.append(None)
356
+ continue
357
+ converted_value = self._convert_value(
358
+ v, base_item_type, param_name, is_path_param=False
359
+ )
360
+ if isinstance(converted_value, JSONResponse):
361
+ return converted_value
362
+ converted_list.append(converted_value)
363
+ kwargs_to_inject[param_name] = converted_list
364
+ continue
365
+
366
+ # Optional[T] handling for single value
367
+ base_type, _is_opt = self._unwrap_optional(ann)
368
+
264
369
  if param_name in query_params:
265
370
  value_str = query_params[param_name]
266
371
  converted_value = self._convert_value(
267
- value_str, param.annotation, param_name, is_path_param=False
372
+ value_str, base_type, param_name, is_path_param=False
268
373
  )
269
- # Return error response if conversion failed
270
374
  if isinstance(converted_value, JSONResponse):
271
375
  return converted_value
272
376
  kwargs_to_inject[param_name] = converted_value
273
377
 
274
378
  elif query_info.default is not ...:
275
- # Use default value if parameter is optional
276
379
  kwargs_to_inject[param.name] = query_info.default
277
380
  else:
278
- # Return error if required parameter is missing
279
- return JSONResponse(
280
- {
281
- "detail": f"Missing required query parameter: {param_name}"
282
- },
283
- status_code=422,
381
+ return validation_error_response(
382
+ f"Missing required query parameter: {param_name}"
284
383
  )
285
384
 
286
385
  # Process explicit Path parameters (with Path() annotation)
@@ -288,13 +387,35 @@ class Tachyon:
288
387
  param_name = param.name
289
388
  if param_name in path_params:
290
389
  value_str = path_params[param_name]
291
- converted_value = self._convert_value(
292
- value_str, param.annotation, param_name, is_path_param=True
293
- )
294
- # Return 404 if conversion failed
295
- if isinstance(converted_value, JSONResponse):
296
- return converted_value
297
- kwargs_to_inject[param_name] = converted_value
390
+ # Support List[T] in path params via CSV
391
+ ann = param.annotation
392
+ origin = typing.get_origin(ann)
393
+ args = typing.get_args(ann)
394
+ if origin in (list, typing.List):
395
+ item_type = args[0] if args else str
396
+ parts = value_str.split(",") if value_str else []
397
+ # Unwrap Optional for item type
398
+ base_item_type, item_is_opt = self._unwrap_optional(item_type)
399
+ converted_list = []
400
+ for v in parts:
401
+ if item_is_opt and (v == "" or v.lower() == "null"):
402
+ converted_list.append(None)
403
+ continue
404
+ converted_value = self._convert_value(
405
+ v, base_item_type, param_name, is_path_param=True
406
+ )
407
+ if isinstance(converted_value, JSONResponse):
408
+ return converted_value
409
+ converted_list.append(converted_value)
410
+ kwargs_to_inject[param_name] = converted_list
411
+ else:
412
+ converted_value = self._convert_value(
413
+ value_str, ann, param_name, is_path_param=True
414
+ )
415
+ # Return 404 if conversion failed
416
+ if isinstance(converted_value, JSONResponse):
417
+ return converted_value
418
+ kwargs_to_inject[param_name] = converted_value
298
419
  else:
299
420
  return JSONResponse({"detail": "Not Found"}, status_code=404)
300
421
 
@@ -307,13 +428,35 @@ class Tachyon:
307
428
  ):
308
429
  param_name = param.name
309
430
  value_str = path_params[param_name]
310
- converted_value = self._convert_value(
311
- value_str, param.annotation, param_name, is_path_param=True
312
- )
313
- # Return 404 if conversion failed
314
- if isinstance(converted_value, JSONResponse):
315
- return converted_value
316
- kwargs_to_inject[param_name] = converted_value
431
+ # Support List[T] via CSV
432
+ ann = param.annotation
433
+ origin = typing.get_origin(ann)
434
+ args = typing.get_args(ann)
435
+ if origin in (list, typing.List):
436
+ item_type = args[0] if args else str
437
+ parts = value_str.split(",") if value_str else []
438
+ # Unwrap Optional for item type
439
+ base_item_type, item_is_opt = self._unwrap_optional(item_type)
440
+ converted_list = []
441
+ for v in parts:
442
+ if item_is_opt and (v == "" or v.lower() == "null"):
443
+ converted_list.append(None)
444
+ continue
445
+ converted_value = self._convert_value(
446
+ v, base_item_type, param_name, is_path_param=True
447
+ )
448
+ if isinstance(converted_value, JSONResponse):
449
+ return converted_value
450
+ converted_list.append(converted_value)
451
+ kwargs_to_inject[param_name] = converted_list
452
+ else:
453
+ converted_value = self._convert_value(
454
+ value_str, ann, param_name, is_path_param=True
455
+ )
456
+ # Return 404 if conversion failed
457
+ if isinstance(converted_value, JSONResponse):
458
+ return converted_value
459
+ kwargs_to_inject[param_name] = converted_value
317
460
 
318
461
  # Call the endpoint function with injected parameters
319
462
  if asyncio.iscoroutinefunction(endpoint_func):
@@ -325,6 +468,13 @@ class Tachyon:
325
468
  if isinstance(payload, Response):
326
469
  return payload
327
470
 
471
+ # Validate/convert response against response_model if provided
472
+ if response_model is not None:
473
+ try:
474
+ payload = msgspec.convert(payload, response_model)
475
+ except Exception as e:
476
+ return response_validation_error_response(str(e))
477
+
328
478
  # Convert Struct objects to dictionaries for JSON serialization
329
479
  if isinstance(payload, Struct):
330
480
  payload = msgspec.to_builtins(payload)
@@ -334,7 +484,7 @@ class Tachyon:
334
484
  if isinstance(value, Struct):
335
485
  payload[key] = msgspec.to_builtins(value)
336
486
 
337
- return JSONResponse(payload)
487
+ return TachyonJSONResponse(payload)
338
488
 
339
489
  # Register the route with Starlette
340
490
  route = Route(path, endpoint=handler, methods=[method])
@@ -365,6 +515,40 @@ class Tachyon:
365
515
  """
366
516
  sig = inspect.signature(endpoint_func)
367
517
 
518
+ # Ensure common error schemas exist in components
519
+ self.openapi_generator.add_schema(
520
+ "ValidationErrorResponse",
521
+ {
522
+ "type": "object",
523
+ "properties": {
524
+ "success": {"type": "boolean"},
525
+ "error": {"type": "string"},
526
+ "code": {"type": "string"},
527
+ "errors": {
528
+ "type": "object",
529
+ "additionalProperties": {
530
+ "type": "array",
531
+ "items": {"type": "string"},
532
+ },
533
+ },
534
+ },
535
+ "required": ["success", "error", "code"],
536
+ },
537
+ )
538
+ self.openapi_generator.add_schema(
539
+ "ResponseValidationError",
540
+ {
541
+ "type": "object",
542
+ "properties": {
543
+ "success": {"type": "boolean"},
544
+ "error": {"type": "string"},
545
+ "detail": {"type": "string"},
546
+ "code": {"type": "string"},
547
+ },
548
+ "required": ["success", "error", "code"],
549
+ },
550
+ )
551
+
368
552
  # Build the OpenAPI operation object
369
553
  operation = {
370
554
  "summary": kwargs.get(
@@ -375,10 +559,37 @@ class Tachyon:
375
559
  "200": {
376
560
  "description": "Successful Response",
377
561
  "content": {"application/json": {"schema": {"type": "object"}}},
378
- }
562
+ },
563
+ "422": {
564
+ "description": "Validation Error",
565
+ "content": {
566
+ "application/json": {
567
+ "schema": {"$ref": "#/components/schemas/ValidationErrorResponse"}
568
+ }
569
+ },
570
+ },
571
+ "500": {
572
+ "description": "Response Validation Error",
573
+ "content": {
574
+ "application/json": {
575
+ "schema": {"$ref": "#/components/schemas/ResponseValidationError"}
576
+ }
577
+ },
578
+ },
379
579
  },
380
580
  }
381
581
 
582
+ # If a response_model is provided and is a Struct, use it for the 200 response schema
583
+ response_model = kwargs.get("response_model")
584
+ if response_model is not None and issubclass(response_model, Struct):
585
+ from .openapi import build_components_for_struct
586
+ comps = build_components_for_struct(response_model)
587
+ for name, schema in comps.items():
588
+ self.openapi_generator.add_schema(name, schema)
589
+ operation["responses"]["200"]["content"]["application/json"]["schema"] = {
590
+ "$ref": f"#/components/schemas/{response_model.__name__}"
591
+ }
592
+
382
593
  # Add tags if provided
383
594
  if "tags" in kwargs:
384
595
  operation["tags"] = kwargs["tags"]
@@ -402,7 +613,7 @@ class Tachyon:
402
613
  "name": param.name,
403
614
  "in": "query",
404
615
  "required": param.default.default is ...,
405
- "schema": {"type": self._get_openapi_type(param.annotation)},
616
+ "schema": self._build_param_openapi_schema(param.annotation),
406
617
  "description": getattr(param.default, "description", ""),
407
618
  }
408
619
  )
@@ -416,7 +627,7 @@ class Tachyon:
416
627
  "name": param.name,
417
628
  "in": "path",
418
629
  "required": True,
419
- "schema": {"type": self._get_openapi_type(param.annotation)},
630
+ "schema": self._build_param_openapi_schema(param.annotation),
420
631
  "description": getattr(param.default, "description", "")
421
632
  if isinstance(param.default, Path)
422
633
  else "",
@@ -427,17 +638,16 @@ class Tachyon:
427
638
  elif isinstance(param.default, Body):
428
639
  model_class = param.annotation
429
640
  if issubclass(model_class, Struct):
430
- schema_name = model_class.__name__
431
- # Add schema to components
432
- self.openapi_generator.add_schema(
433
- schema_name, _generate_schema_for_struct(model_class)
434
- )
641
+ from .openapi import build_components_for_struct
642
+ comps = build_components_for_struct(model_class)
643
+ for name, schema in comps.items():
644
+ self.openapi_generator.add_schema(name, schema)
435
645
 
436
646
  request_body_schema = {
437
647
  "content": {
438
648
  "application/json": {
439
649
  "schema": {
440
- "$ref": f"#/components/schemas/{schema_name}"
650
+ "$ref": f"#/components/schemas/{model_class.__name__}"
441
651
  }
442
652
  }
443
653
  },
@@ -448,11 +658,9 @@ class Tachyon:
448
658
  if parameters:
449
659
  operation["parameters"] = parameters
450
660
 
451
- # Add request body if it exists
452
661
  if request_body_schema:
453
662
  operation["requestBody"] = request_body_schema
454
663
 
455
- # Add the operation to the OpenAPI schema
456
664
  self.openapi_generator.add_path(path, method, operation)
457
665
 
458
666
  @staticmethod
@@ -476,6 +684,44 @@ class Tachyon:
476
684
  }
477
685
  return type_map.get(python_type, "string")
478
686
 
687
+ @staticmethod
688
+ def _build_param_openapi_schema(python_type: Type) -> Dict[str, Any]:
689
+ """Build OpenAPI schema for parameter types, supporting Optional[T] and List[T]."""
690
+ origin = typing.get_origin(python_type)
691
+ args = typing.get_args(python_type)
692
+ nullable = False
693
+ # Optional[T]
694
+ if origin is Union and args:
695
+ non_none = [a for a in args if a is not type(None)] # noqa: E721
696
+ if len(non_none) == 1:
697
+ python_type = non_none[0]
698
+ nullable = True
699
+ # List[T] (and List[Optional[T]])
700
+ origin = typing.get_origin(python_type)
701
+ args = typing.get_args(python_type)
702
+ if origin in (list, typing.List):
703
+ item_type = args[0] if args else str
704
+ # Unwrap Optional in items for List[Optional[T]]
705
+ item_origin = typing.get_origin(item_type)
706
+ item_args = typing.get_args(item_type)
707
+ item_nullable = False
708
+ if item_origin is Union and item_args:
709
+ item_non_none = [a for a in item_args if a is not type(None)] # noqa: E721
710
+ if len(item_non_none) == 1:
711
+ item_type = item_non_none[0]
712
+ item_nullable = True
713
+ schema = {
714
+ "type": "array",
715
+ "items": {"type": Tachyon._get_openapi_type(item_type)},
716
+ }
717
+ if item_nullable:
718
+ schema["items"]["nullable"] = True
719
+ else:
720
+ schema = {"type": Tachyon._get_openapi_type(python_type)}
721
+ if nullable:
722
+ schema["nullable"] = True
723
+ return schema
724
+
479
725
  def _setup_docs(self):
480
726
  """
481
727
  Setup OpenAPI documentation endpoints.
@@ -576,7 +822,7 @@ class Tachyon:
576
822
  middleware_class: The middleware class.
577
823
  **options: Options to be passed to the middleware constructor.
578
824
  """
579
- # Usar helper centralizado para aplicar el middleware sobre Starlette
825
+ # Use centralized helper to apply middleware to internal Starlette app
580
826
  apply_middleware_to_router(self._router, middleware_class, **options)
581
827
 
582
828
  if not hasattr(self, "middleware_stack"):
@@ -596,11 +842,11 @@ class Tachyon:
596
842
  """
597
843
 
598
844
  def decorator(middleware_func):
599
- # Crear una clase de middleware a partir de la función decorada
845
+ # Create a middleware class from the decorated function
600
846
  DecoratedMiddleware = create_decorated_middleware_class(
601
847
  middleware_func, middleware_type
602
848
  )
603
- # Registrar el middleware usando el método existente
849
+ # Register the middleware using the existing method
604
850
  self.add_middleware(DecoratedMiddleware)
605
851
  return middleware_func
606
852