caspian-utils 0.0.12__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.
casp/rpc.py ADDED
@@ -0,0 +1,230 @@
1
+ import traceback
2
+ from datetime import timedelta
3
+ from .auth import auth
4
+ from fastapi import FastAPI, Request, Response
5
+ from fastapi.responses import JSONResponse
6
+ from slowapi import Limiter
7
+ from slowapi.util import get_remote_address
8
+ from slowapi.errors import RateLimitExceeded
9
+ from functools import wraps
10
+ from .caspian_config import get_files_index
11
+ from typing import Optional, Any
12
+ import inspect
13
+ import os
14
+ import json
15
+ import hmac
16
+ import dataclasses
17
+ from datetime import datetime, date
18
+
19
+ RPC_REGISTRY = {}
20
+ RPC_META = {}
21
+
22
+ CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', '').split(
23
+ ',') if os.getenv('CORS_ALLOWED_ORIGINS') else None
24
+ IS_PRODUCTION = os.getenv('APP_ENV') == 'production'
25
+
26
+ RATE_LIMIT_DEFAULT = os.getenv('RATE_LIMIT_DEFAULT', '200/minute')
27
+ RATE_LIMIT_RPC = os.getenv('RATE_LIMIT_RPC', '60/minute')
28
+ RATE_LIMIT_AUTH = os.getenv('RATE_LIMIT_AUTH', '10/minute')
29
+
30
+ limiter = Limiter(key_func=get_remote_address)
31
+
32
+
33
+ def _serialize_result(obj: Any) -> Any:
34
+ """Convert objects to JSON-serializable format."""
35
+ if obj is None:
36
+ return None
37
+ if isinstance(obj, (str, int, float, bool)):
38
+ return obj
39
+ if isinstance(obj, datetime):
40
+ return obj.isoformat()
41
+ if isinstance(obj, date):
42
+ return obj.isoformat()
43
+ if isinstance(obj, (list, tuple)):
44
+ return [_serialize_result(item) for item in obj]
45
+ if isinstance(obj, dict):
46
+ return {k: _serialize_result(v) for k, v in obj.items()}
47
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
48
+ return {k: _serialize_result(v) for k, v in dataclasses.asdict(obj).items()}
49
+ # Pydantic v2
50
+ model_dump = getattr(obj, 'model_dump', None)
51
+ if callable(model_dump):
52
+ return model_dump()
53
+ # Fallback to __dict__
54
+ obj_dict = getattr(obj, '__dict__', None)
55
+ if obj_dict is not None:
56
+ return {k: _serialize_result(v) for k, v in obj_dict.items()}
57
+ return str(obj)
58
+
59
+
60
+ def _filepath_to_route(filepath: str) -> str:
61
+ files_index = get_files_index()
62
+ filepath = filepath.replace('\\', '/')
63
+ for route_entry in files_index.routes:
64
+ if route_entry.fs_dir:
65
+ pattern = f'/src/app/{route_entry.fs_dir}/index.py'
66
+ if filepath.endswith(pattern) or pattern in filepath:
67
+ return route_entry.url_path
68
+ else:
69
+ if filepath.endswith('/src/app/index.py'):
70
+ return '/'
71
+ return '/'
72
+
73
+
74
+ def rpc(require_auth: bool = False, allowed_roles: Optional[list[str]] = None):
75
+ def decorator(func):
76
+ frame = inspect.stack()[1]
77
+ filepath = frame.filename
78
+ route = _filepath_to_route(filepath)
79
+
80
+ key = f"{route}:{func.__name__}"
81
+ RPC_REGISTRY[key] = func
82
+ RPC_META[key] = {
83
+ 'require_auth': require_auth,
84
+ 'allowed_roles': allowed_roles or [],
85
+ 'route': route
86
+ }
87
+
88
+ @wraps(func)
89
+ def wrapper(*args, **kwargs):
90
+ return func(*args, **kwargs)
91
+ return wrapper
92
+ return decorator
93
+
94
+
95
+ def _validate_origin(request: Request) -> Optional[JSONResponse]:
96
+ origin = request.headers.get('Origin')
97
+ if not origin:
98
+ return None
99
+ if not IS_PRODUCTION:
100
+ if origin.startswith(('http://localhost:', 'http://127.0.0.1:')):
101
+ return None
102
+ host_url = str(request.base_url).rstrip('/')
103
+ if CORS_ALLOWED_ORIGINS:
104
+ if origin not in CORS_ALLOWED_ORIGINS and origin != host_url:
105
+ return JSONResponse({'error': 'Invalid origin'}, status_code=403)
106
+ elif origin != host_url:
107
+ return JSONResponse({'error': 'Invalid origin'}, status_code=403)
108
+ return None
109
+
110
+
111
+ def _validate_csrf(request: Request, session: dict) -> Optional[JSONResponse]:
112
+ csrf_header = request.headers.get('X-CSRF-Token')
113
+ session_token = session.get('csrf_token')
114
+
115
+ if not csrf_header or not session_token:
116
+ return JSONResponse({'error': 'Missing CSRF token'}, status_code=403)
117
+
118
+ if not hmac.compare_digest(csrf_header, session_token):
119
+ return JSONResponse({'error': 'Invalid CSRF token'}, status_code=403)
120
+ return None
121
+
122
+
123
+ def _validate_content_type(request: Request) -> Optional[JSONResponse]:
124
+ content_type = request.headers.get('content-type', '')
125
+ if not content_type.startswith(('application/json', 'multipart/form-data')):
126
+ content_length = request.headers.get('content-length')
127
+ if content_length and int(content_length) > 0:
128
+ return JSONResponse({'error': 'Invalid content type'}, status_code=415)
129
+ return None
130
+
131
+
132
+ def _get_registry_key(route: str, func_name: str) -> Optional[str]:
133
+ route = ('/' + route.strip('/')).rstrip('/') or '/'
134
+ key = f"{route}:{func_name}"
135
+ if key in RPC_REGISTRY:
136
+ return key
137
+ if func_name in RPC_REGISTRY:
138
+ return func_name
139
+ return None
140
+
141
+
142
+ async def _handle_rpc_request(request: Request, session: dict) -> Response:
143
+ func_name = request.headers.get('X-PP-Function')
144
+ if not func_name:
145
+ return JSONResponse({'error': 'Missing function name'}, status_code=400)
146
+
147
+ route = request.url.path.rstrip('/') or '/'
148
+ registry_key = _get_registry_key(route, func_name)
149
+ if not registry_key:
150
+ return JSONResponse({'error': 'Function not found'}, status_code=404)
151
+
152
+ if error := _validate_origin(request):
153
+ return error
154
+ if error := _validate_content_type(request):
155
+ return error
156
+ if error := _validate_csrf(request, session):
157
+ return error
158
+
159
+ meta = RPC_META.get(registry_key, {})
160
+
161
+ if meta.get('require_auth') and not auth.is_authenticated():
162
+ return JSONResponse({'error': 'Authentication required'}, status_code=401)
163
+
164
+ allowed_roles = meta.get('allowed_roles', [])
165
+ if allowed_roles:
166
+ user = auth.get_payload()
167
+ if not auth.check_role(user, allowed_roles):
168
+ return JSONResponse({'error': 'Permission denied'}, status_code=403)
169
+
170
+ content_type = request.headers.get('content-type', '')
171
+ if content_type.startswith('multipart/form-data'):
172
+ form = await request.form()
173
+ data: dict[str, Any] = {}
174
+ for key in form:
175
+ value = form[key]
176
+ if isinstance(value, str):
177
+ try:
178
+ data[key] = json.loads(value)
179
+ except (json.JSONDecodeError, TypeError):
180
+ data[key] = value
181
+ else:
182
+ data[key] = value
183
+ else:
184
+ try:
185
+ data = await request.json()
186
+ except:
187
+ data = {}
188
+
189
+ try:
190
+ result = RPC_REGISTRY[registry_key](**data)
191
+
192
+ if isinstance(result, Response):
193
+ location = result.headers.get("Location")
194
+ status = result.status_code
195
+ if location and 300 <= status < 400:
196
+ resp = JSONResponse({"result": None})
197
+ resp.headers["X-PP-Redirect"] = location
198
+ resp.headers["X-PP-Redirect-Status"] = str(status)
199
+ return resp
200
+ return result
201
+
202
+ return JSONResponse({'result': _serialize_result(result)})
203
+
204
+ except PermissionError as e:
205
+ return JSONResponse({'error': str(e)}, status_code=403)
206
+ except ValueError as e:
207
+ return JSONResponse({'error': str(e)}, status_code=400)
208
+ except Exception as e:
209
+ print(f"[RPC Error] {registry_key}: {e}")
210
+ traceback.print_exc() # This prints the full stack trace
211
+ return JSONResponse({'error': 'Internal server error'}, status_code=500)
212
+
213
+
214
+ # Standalone middleware function (exported)
215
+ async def rpc_middleware(request: Request, call_next):
216
+ session = dict(request.session) if hasattr(request, 'session') else {}
217
+
218
+ if request.headers.get('X-PP-RPC') == 'true' and request.method == 'POST':
219
+ return await _handle_rpc_request(request, session)
220
+
221
+ response = await call_next(request)
222
+ return response
223
+
224
+
225
+ def register_rpc_routes(app: FastAPI):
226
+ app.state.limiter = limiter
227
+
228
+ @app.exception_handler(RateLimitExceeded)
229
+ async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
230
+ return JSONResponse({'error': 'Rate limit exceeded. Please slow down.'}, status_code=429)
casp/scripts_type.py ADDED
@@ -0,0 +1,21 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+
4
+ def transform_scripts(html_content):
5
+ """Add type='text/pp' to script tags without a type attribute (only in body)"""
6
+ has_doctype = html_content.strip().lower().startswith('<!doctype')
7
+
8
+ soup = BeautifulSoup(html_content, 'html.parser')
9
+
10
+ body = soup.find('body')
11
+ if body:
12
+ for script in body.find_all('script'):
13
+ if not script.has_attr('type'):
14
+ script['type'] = 'text/pp'
15
+
16
+ result = str(soup)
17
+
18
+ if has_doctype and not result.strip().lower().startswith('<!doctype'):
19
+ result = '<!DOCTYPE html>\n' + result
20
+
21
+ return result
casp/state_manager.py ADDED
@@ -0,0 +1,134 @@
1
+ import json
2
+ from contextvars import ContextVar
3
+ from typing import Any, Callable, Dict, Optional
4
+ from fastapi import Request
5
+
6
+ # Context variables for request-scoped state
7
+ _state_data: ContextVar[Dict] = ContextVar('state_data', default={})
8
+ _state_listeners: ContextVar[list] = ContextVar('state_listeners', default=[])
9
+ _current_request: ContextVar[Optional[Request]
10
+ ] = ContextVar('current_request', default=None)
11
+
12
+
13
+ class AttributeDict(dict):
14
+ def __getattr__(self, key):
15
+ try:
16
+ return self[key]
17
+ except KeyError:
18
+ raise AttributeError(
19
+ f"'AttributeDict' object has no attribute '{key}'")
20
+
21
+ def __setattr__(self, key, value):
22
+ self[key] = value
23
+
24
+
25
+ class StateManager:
26
+ APP_STATE_KEY = 'app_state_cL7y4KirLp'
27
+
28
+ @staticmethod
29
+ def set_request(request: Request):
30
+ _current_request.set(request)
31
+
32
+ @staticmethod
33
+ def _get_session() -> dict:
34
+ request = _current_request.get()
35
+ if request and hasattr(request.state, 'session'):
36
+ return request.state.session
37
+ return {}
38
+
39
+ @staticmethod
40
+ def _set_session(key: str, value: Any):
41
+ request = _current_request.get()
42
+ if request:
43
+ if not hasattr(request.state, 'session'):
44
+ request.state.session = {}
45
+ request.state.session[key] = value
46
+
47
+ @staticmethod
48
+ def _get_state_data() -> Dict:
49
+ return _state_data.get()
50
+
51
+ @staticmethod
52
+ def _set_state_data(data: Dict) -> None:
53
+ _state_data.set(data)
54
+
55
+ @staticmethod
56
+ def _get_listeners() -> list:
57
+ return _state_listeners.get()
58
+
59
+ @staticmethod
60
+ def init(request: Request) -> None:
61
+ StateManager.set_request(request)
62
+ _state_data.set({})
63
+ _state_listeners.set([])
64
+ StateManager.load_state()
65
+
66
+ is_wire = request.headers.get('X-PulsePoint-Wire', False)
67
+ if not is_wire:
68
+ StateManager.reset_state()
69
+
70
+ @staticmethod
71
+ def get_state(key: Optional[str] = None, initial_value: Any = None) -> Any:
72
+ state = StateManager._get_state_data()
73
+ if key is None:
74
+ return AttributeDict(state)
75
+ value = state.get(key, initial_value)
76
+ return AttributeDict(value) if isinstance(value, dict) else value
77
+
78
+ @staticmethod
79
+ def set_state(key: str, value: Any = None) -> None:
80
+ state = StateManager._get_state_data()
81
+ state[key] = value
82
+ _state_data.set(state)
83
+ StateManager.notify_listeners()
84
+ StateManager.save_state()
85
+
86
+ @staticmethod
87
+ def subscribe(listener: Callable) -> Callable:
88
+ listeners = StateManager._get_listeners()
89
+ listeners.append(listener)
90
+ listener(StateManager._get_state_data())
91
+
92
+ def unsubscribe():
93
+ current_listeners = StateManager._get_listeners()
94
+ if listener in current_listeners:
95
+ current_listeners.remove(listener)
96
+
97
+ return unsubscribe
98
+
99
+ @staticmethod
100
+ def save_state() -> None:
101
+ state = StateManager._get_state_data()
102
+ StateManager._set_session(
103
+ StateManager.APP_STATE_KEY, json.dumps(state))
104
+
105
+ @staticmethod
106
+ def load_state() -> None:
107
+ session = StateManager._get_session()
108
+ if StateManager.APP_STATE_KEY in session:
109
+ try:
110
+ loaded_state = json.loads(session[StateManager.APP_STATE_KEY])
111
+ if isinstance(loaded_state, dict):
112
+ StateManager._set_state_data(loaded_state)
113
+ StateManager.notify_listeners()
114
+ except json.JSONDecodeError:
115
+ pass
116
+
117
+ @staticmethod
118
+ def reset_state(key: Optional[str] = None) -> None:
119
+ state = StateManager._get_state_data()
120
+ if key is not None:
121
+ if key in state:
122
+ state[key] = None
123
+ else:
124
+ state = {}
125
+ _state_data.set(state)
126
+ StateManager.notify_listeners()
127
+ StateManager.save_state()
128
+
129
+ @staticmethod
130
+ def notify_listeners() -> None:
131
+ listeners = StateManager._get_listeners()
132
+ state = StateManager._get_state_data()
133
+ for listener in listeners:
134
+ listener(state)
casp/string_helpers.py ADDED
@@ -0,0 +1,18 @@
1
+ import re
2
+
3
+
4
+ def camel_to_kebab(name):
5
+ return re.sub(r'([a-z])([A-Z])', r'\1-\2', name).lower()
6
+
7
+
8
+ def kebab_to_camel(name):
9
+ parts = name.split('-')
10
+ return parts[0] + ''.join(p.capitalize() for p in parts[1:])
11
+
12
+
13
+ def has_mustache(value):
14
+ if isinstance(value, str):
15
+ return bool(re.search(r'\{[^}]+\}', value))
16
+ if isinstance(value, list):
17
+ return any(has_mustache(v) for v in value)
18
+ return False
casp/tw.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from tailwind_merge import TailwindMerge
4
+
5
+ _twm = TailwindMerge()
6
+
7
+
8
+ def tw_merge(*values: Any) -> str:
9
+ """
10
+ Merge Tailwind class strings (and optionally lists/tuples/sets) into a single
11
+ conflict-resolved class string using tailwind-merge.
12
+ """
13
+ parts: list[str] = []
14
+
15
+ for v in values:
16
+ if not v:
17
+ continue
18
+
19
+ if isinstance(v, (list, tuple, set)):
20
+ chunk = " ".join(str(x) for x in v if x)
21
+ else:
22
+ chunk = str(v).strip()
23
+
24
+ if chunk:
25
+ parts.append(chunk)
26
+
27
+ if not parts:
28
+ return ""
29
+
30
+ # TailwindMerge.merge accepts multiple class strings. :contentReference[oaicite:2]{index=2}
31
+ return _twm.merge(*parts)