Refactor code for improved readability and consistency

- Cleaned up whitespace and formatting in various files including http.py, language.py, logger.py, safe_exec.py, and SQL migration scripts.
- Consolidated import statements and removed unnecessary blank lines.
- Updated logging configuration for better clarity.
- Enhanced the safe execution code with improved error handling and logging.
- Removed commented-out code and unnecessary variables in backfill_zero_trades.py and other scripts.
- Added a pyproject.toml for Ruff and Vulture configuration.
- Introduced requirements-dev.txt for development dependencies.
- Removed commented-out stock entries in init.sql for cleaner migration scripts.
This commit is contained in:
dienakdz
2026-04-09 14:30:51 +07:00
parent 103055b3df
commit 87f2845483
157 changed files with 19026 additions and 17773 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
"""
tool module
"""
from app.utils.logger import get_logger
from app.utils.cache import CacheManager
from app.utils.http import get_retry_session
from app.utils.logger import get_logger
__all__ = ['get_logger', 'CacheManager', 'get_retry_session']
__all__ = ["get_logger", "CacheManager", "get_retry_session"]
+72 -70
View File
@@ -4,44 +4,43 @@ Authentication Utilities
JWT token generation, verification, and middleware decorators.
Supports multi-user authentication with role-based access control.
"""
import jwt
import datetime
import os
from functools import wraps
from flask import request, jsonify, g
import jwt
from flask import g, jsonify, request
from app.config.settings import Config
from app.utils.logger import get_logger
logger = get_logger(__name__)
def generate_token(user_id: int, username: str, role: str = 'user', token_version: int = 1) -> str:
def generate_token(user_id: int, username: str, role: str = "user", token_version: int = 1) -> str:
"""
Generate JWT token with user information.
Args:
user_id: User ID
username: Username
role: User role (admin/manager/user/viewer)
token_version: Token version for single-client enforcement
Returns:
JWT token string
"""
try:
payload = {
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7),
'iat': datetime.datetime.utcnow(),
'sub': username,
'user_id': user_id,
'role': role,
'token_version': token_version, # For single client login control
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
"iat": datetime.datetime.utcnow(),
"sub": username,
"user_id": user_id,
"role": role,
"token_version": token_version, # For single client login control
}
return jwt.encode(
payload,
Config.SECRET_KEY,
algorithm='HS256'
)
return jwt.encode(payload, Config.SECRET_KEY, algorithm="HS256")
except Exception as e:
logger.error(f"Token generation failed: {e}")
return None
@@ -50,26 +49,26 @@ def generate_token(user_id: int, username: str, role: str = 'user', token_versio
def verify_token(token: str) -> dict:
"""
Verify JWT token and return payload.
Args:
token: JWT token string
Returns:
Token payload dict or None if invalid
"""
try:
payload = jwt.decode(token, Config.SECRET_KEY, algorithms=['HS256'])
payload = jwt.decode(token, Config.SECRET_KEY, algorithms=["HS256"])
# Verify token_version (single client login control)
user_id = payload.get('user_id')
token_version = payload.get('token_version')
user_id = payload.get("user_id")
token_version = payload.get("token_version")
if user_id and token_version is not None:
# Check if token_version in database matches
if not _verify_token_version(user_id, token_version):
logger.debug(f"Token version mismatch for user {user_id}: expected current, got {token_version}")
return None
return payload
except jwt.ExpiredSignatureError:
logger.debug("Token expired")
@@ -83,29 +82,27 @@ def _verify_token_version(user_id: int, token_version: int) -> bool:
"""
Verify that the token version matches the version stored in the database.
Used to implement single client login (kick out duplicate logins).
Args:
user_id: user ID
token_version: version number in Token
Returns:
True if version matches, False otherwise
"""
try:
from app.utils.db import get_db_connection
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT token_version FROM qd_users WHERE id = ?",
(user_id,)
)
cur.execute("SELECT token_version FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
if not row:
return False
db_token_version = row.get('token_version') or 1
db_token_version = row.get("token_version") or 1
return int(token_version) == int(db_token_version)
except Exception as e:
logger.error(f"_verify_token_version failed: {e}")
@@ -115,45 +112,46 @@ def _verify_token_version(user_id: int, token_version: int) -> bool:
def get_current_user_id() -> int:
"""Get current user ID from flask.g context"""
return getattr(g, 'user_id', None)
return getattr(g, "user_id", None)
def get_current_user_role() -> str:
"""Get current user role from flask.g context"""
return getattr(g, 'user_role', 'user')
return getattr(g, "user_role", "user")
def login_required(f):
"""
Decorator that enforces Bearer token auth.
Sets g.user, g.user_id, g.user_role on successful auth.
"""
@wraps(f)
def decorated(*args, **kwargs):
token = None
# Read token from Authorization: Bearer <token>
auth_header = request.headers.get('Authorization')
auth_header = request.headers.get("Authorization")
if auth_header:
parts = auth_header.split()
if len(parts) == 2 and parts[0].lower() == 'bearer':
if len(parts) == 2 and parts[0].lower() == "bearer":
token = parts[1]
if not token:
return jsonify({'code': 401, 'msg': 'Token missing', 'data': None}), 401
return jsonify({"code": 401, "msg": "Token missing", "data": None}), 401
payload = verify_token(token)
if not payload:
return jsonify({'code': 401, 'msg': 'Token invalid or expired', 'data': None}), 401
return jsonify({"code": 401, "msg": "Token invalid or expired", "data": None}), 401
# Store user info in flask.g
g.user = payload.get('sub')
g.user_id = payload.get('user_id')
g.user_role = payload.get('role', 'user')
g.user = payload.get("sub")
g.user_id = payload.get("user_id")
g.user_role = payload.get("role", "user")
return f(*args, **kwargs)
return decorated
@@ -162,12 +160,14 @@ def admin_required(f):
Decorator that requires admin role.
Must be used after @login_required.
"""
@wraps(f)
def decorated(*args, **kwargs):
role = getattr(g, 'user_role', None)
if role != 'admin':
return jsonify({'code': 403, 'msg': 'Admin access required', 'data': None}), 403
role = getattr(g, "user_role", None)
if role != "admin":
return jsonify({"code": 403, "msg": "Admin access required", "data": None}), 403
return f(*args, **kwargs)
return decorated
@@ -176,12 +176,14 @@ def manager_required(f):
Decorator that requires manager or admin role.
Must be used after @login_required.
"""
@wraps(f)
def decorated(*args, **kwargs):
role = getattr(g, 'user_role', None)
if role not in ('admin', 'manager'):
return jsonify({'code': 403, 'msg': 'Manager access required', 'data': None}), 403
role = getattr(g, "user_role", None)
if role not in ("admin", "manager"):
return jsonify({"code": 403, "msg": "Manager access required", "data": None}), 403
return f(*args, **kwargs)
return decorated
@@ -189,38 +191,38 @@ def permission_required(permission: str):
"""
Decorator factory that checks for a specific permission.
Must be used after @login_required.
Usage:
@login_required
@permission_required('strategy')
def my_endpoint():
...
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
role = getattr(g, 'user_role', 'user')
role = getattr(g, "user_role", "user")
# Import here to avoid circular import
from app.services.user_service import get_user_service
permissions = get_user_service().get_user_permissions(role)
if permission not in permissions:
return jsonify({
'code': 403,
'msg': f'Permission denied: {permission}',
'data': None
}), 403
return jsonify({"code": 403, "msg": f"Permission denied: {permission}", "data": None}), 403
return f(*args, **kwargs)
return decorated
return decorator
# Legacy compatibility: single-user mode fallback
def _is_single_user_mode() -> bool:
"""Check if system is in single-user (legacy) mode"""
return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true'
return os.getenv("SINGLE_USER_MODE", "false").lower() == "true"
def authenticate_legacy(username: str, password: str) -> dict:
@@ -230,9 +232,9 @@ def authenticate_legacy(username: str, password: str) -> dict:
"""
if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD:
return {
'user_id': 1,
'username': username,
'role': 'admin',
'nickname': 'Admin',
"user_id": 1,
"username": username,
"role": "admin",
"nickname": "Admin",
}
return None
+21 -20
View File
@@ -3,24 +3,25 @@ Cache utilities.
Local-first behavior: use in-memory cache by default.
Redis is only used when explicitly enabled via environment variables.
"""
import time
import threading
from typing import Optional, Any
import json
from app.utils.logger import get_logger
import json
import threading
import time
from typing import Any, Optional
from app.config import CacheConfig
from app.utils.logger import get_logger
logger = get_logger(__name__)
class MemoryCache:
"""In-memory cache (an alternative if Redis is unavailable)"""
def __init__(self):
self._cache = {}
self._lock = threading.Lock()
def get(self, key: str) -> Optional[str]:
with self._lock:
if key in self._cache:
@@ -30,17 +31,17 @@ class MemoryCache:
else:
del self._cache[key]
return None
def setex(self, key: str, ttl: int, value: str):
with self._lock:
expiry = time.time() + ttl
self._cache[key] = (value, expiry)
def delete(self, key: str):
with self._lock:
if key in self._cache:
del self._cache[key]
def clear(self):
with self._lock:
self._cache.clear()
@@ -48,10 +49,10 @@ class MemoryCache:
class CacheManager:
"""cache manager"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
@@ -59,11 +60,11 @@ class CacheManager:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._initialized = True
self._client = None
self._use_redis = False
@@ -77,6 +78,7 @@ class CacheManager:
# Try Redis only when enabled.
try:
import redis
from app.config import RedisConfig
self._client = redis.Redis(
@@ -86,7 +88,7 @@ class CacheManager:
password=RedisConfig.PASSWORD,
decode_responses=True,
socket_connect_timeout=RedisConfig.CONNECT_TIMEOUT,
socket_timeout=RedisConfig.SOCKET_TIMEOUT
socket_timeout=RedisConfig.SOCKET_TIMEOUT,
)
self._client.ping()
self._use_redis = True
@@ -96,7 +98,7 @@ class CacheManager:
logger.info(f"Redis is enabled but unavailable; using in-memory cache instead: {e}")
self._client = MemoryCache()
self._use_redis = False
def get(self, key: str) -> Optional[Any]:
"""Get cache"""
try:
@@ -107,22 +109,21 @@ class CacheManager:
except Exception as e:
logger.error(f"Cache read failed: {e}")
return None
def set(self, key: str, value: Any, ttl: int = 300):
"""Set up cache"""
try:
self._client.setex(key, ttl, json.dumps(value))
except Exception as e:
logger.error(f"Cache write failed: {e}")
def delete(self, key: str):
"""Delete cache"""
try:
self._client.delete(key)
except Exception as e:
logger.error(f"Cache delete failed: {e}")
@property
def is_redis(self) -> bool:
return self._use_redis
+73 -88
View File
@@ -10,8 +10,9 @@ flat keys like `openrouter.api_key` become nested dicts like:
"openrouter": {"api_key": "..."}
}
"""
from typing import Dict, Any, Optional, List, Tuple
import os
from typing import Any, Dict, List, Optional, Tuple
from app.utils.logger import get_logger
@@ -31,15 +32,15 @@ def load_addon_config() -> Dict[str, Any]:
Nested config dict (PHP-compatible shape)
"""
global _config_cache
# If the cache exists, return directly
if _config_cache is not None:
return _config_cache
config: Dict[str, Any] = {}
def set_nested(cfg: Dict[str, Any], dotted_key: str, value: Any) -> None:
keys = dotted_key.split('.')
keys = dotted_key.split(".")
ref = cfg
for i, k in enumerate(keys):
if i == len(keys) - 1:
@@ -54,82 +55,66 @@ def load_addon_config() -> Dict[str, Any]:
if val is None:
return None
val = str(val).strip()
return val if val != '' else None
return val if val != "" else None
# Map env vars to PHP-style dotted keys.
mappings: List[Tuple[str, str, str]] = [
# internal
('INTERNAL_API_KEY', 'internal_api.key', 'string'),
("INTERNAL_API_KEY", "internal_api.key", "string"),
# OpenRouter / LLM
('OPENROUTER_API_KEY', 'openrouter.api_key', 'string'),
('OPENROUTER_API_URL', 'openrouter.api_url', 'string'),
('OPENROUTER_MODEL', 'openrouter.model', 'string'),
('OPENROUTER_TEMPERATURE', 'openrouter.temperature', 'float'),
('OPENROUTER_MAX_TOKENS', 'openrouter.max_tokens', 'int'),
('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'),
('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'),
("OPENROUTER_API_KEY", "openrouter.api_key", "string"),
("OPENROUTER_API_URL", "openrouter.api_url", "string"),
("OPENROUTER_MODEL", "openrouter.model", "string"),
("OPENROUTER_TEMPERATURE", "openrouter.temperature", "float"),
("OPENROUTER_MAX_TOKENS", "openrouter.max_tokens", "int"),
("OPENROUTER_TIMEOUT", "openrouter.timeout", "int"),
("OPENROUTER_CONNECT_TIMEOUT", "openrouter.connect_timeout", "int"),
# OpenAI Direct
('OPENAI_API_KEY', 'openai.api_key', 'string'),
('OPENAI_BASE_URL', 'openai.base_url', 'string'),
('OPENAI_MODEL', 'openai.model', 'string'),
("OPENAI_API_KEY", "openai.api_key", "string"),
("OPENAI_BASE_URL", "openai.base_url", "string"),
("OPENAI_MODEL", "openai.model", "string"),
# Google Gemini
('GOOGLE_API_KEY', 'google.api_key', 'string'),
('GOOGLE_MODEL', 'google.model', 'string'),
("GOOGLE_API_KEY", "google.api_key", "string"),
("GOOGLE_MODEL", "google.model", "string"),
# DeepSeek
('DEEPSEEK_API_KEY', 'deepseek.api_key', 'string'),
('DEEPSEEK_BASE_URL', 'deepseek.base_url', 'string'),
('DEEPSEEK_MODEL', 'deepseek.model', 'string'),
("DEEPSEEK_API_KEY", "deepseek.api_key", "string"),
("DEEPSEEK_BASE_URL", "deepseek.base_url", "string"),
("DEEPSEEK_MODEL", "deepseek.model", "string"),
# xAI Grok
('GROK_API_KEY', 'grok.api_key', 'string'),
('GROK_BASE_URL', 'grok.base_url', 'string'),
('GROK_MODEL', 'grok.model', 'string'),
("GROK_API_KEY", "grok.api_key", "string"),
("GROK_BASE_URL", "grok.base_url", "string"),
("GROK_MODEL", "grok.model", "string"),
# LLM Provider Selection
('LLM_PROVIDER', 'llm.provider', 'string'),
("LLM_PROVIDER", "llm.provider", "string"),
# App
('RATE_LIMIT', 'app.rate_limit', 'int'),
('ENABLE_CACHE', 'app.enable_cache', 'bool'),
('ENABLE_REQUEST_LOG', 'app.enable_request_log', 'bool'),
("RATE_LIMIT", "app.rate_limit", "int"),
("ENABLE_CACHE", "app.enable_cache", "bool"),
("ENABLE_REQUEST_LOG", "app.enable_request_log", "bool"),
# Data source common
('DATA_SOURCE_TIMEOUT', 'data_source.timeout', 'int'),
('DATA_SOURCE_RETRY', 'data_source.retry_count', 'int'),
('DATA_SOURCE_RETRY_BACKOFF', 'data_source.retry_backoff', 'float'),
("DATA_SOURCE_TIMEOUT", "data_source.timeout", "int"),
("DATA_SOURCE_RETRY", "data_source.retry_count", "int"),
("DATA_SOURCE_RETRY_BACKOFF", "data_source.retry_backoff", "float"),
# Finnhub
('FINNHUB_API_KEY', 'finnhub.api_key', 'string'),
('FINNHUB_TIMEOUT', 'finnhub.timeout', 'int'),
('FINNHUB_RATE_LIMIT', 'finnhub.rate_limit', 'int'),
("FINNHUB_API_KEY", "finnhub.api_key", "string"),
("FINNHUB_TIMEOUT", "finnhub.timeout", "int"),
("FINNHUB_RATE_LIMIT", "finnhub.rate_limit", "int"),
# CCXT
('CCXT_DEFAULT_EXCHANGE', 'ccxt.default_exchange', 'string'),
('CCXT_TIMEOUT', 'ccxt.timeout', 'int'),
("CCXT_DEFAULT_EXCHANGE", "ccxt.default_exchange", "string"),
("CCXT_TIMEOUT", "ccxt.timeout", "int"),
# Other sources
('YFINANCE_TIMEOUT', 'yfinance.timeout', 'int'),
('AKSHARE_TIMEOUT', 'akshare.timeout', 'int'),
('TIINGO_API_KEY', 'tiingo.api_key', 'string'),
('TIINGO_TIMEOUT', 'tiingo.timeout', 'int'),
('TWELVE_DATA_API_KEY', 'twelve_data.api_key', 'string'),
("YFINANCE_TIMEOUT", "yfinance.timeout", "int"),
("TIINGO_API_KEY", "tiingo.api_key", "string"),
("TIINGO_TIMEOUT", "tiingo.timeout", "int"),
# Search (Google CSE / Bing)
('SEARCH_PROVIDER', 'search.provider', 'string'),
('SEARCH_MAX_RESULTS', 'search.max_results', 'int'),
('SEARCH_GOOGLE_API_KEY', 'search.google.api_key', 'string'),
('SEARCH_GOOGLE_CX', 'search.google.cx', 'string'),
('SEARCH_BING_API_KEY', 'search.bing.api_key', 'string'),
("SEARCH_PROVIDER", "search.provider", "string"),
("SEARCH_MAX_RESULTS", "search.max_results", "int"),
("SEARCH_GOOGLE_API_KEY", "search.google.api_key", "string"),
("SEARCH_GOOGLE_CX", "search.google.cx", "string"),
("SEARCH_BING_API_KEY", "search.bing.api_key", "string"),
# Tavily (AI-optimized search)
('TAVILY_API_KEYS', 'tavily.api_keys', 'string'),
("TAVILY_API_KEYS", "tavily.api_keys", "string"),
# SerpAPI (Google/Bing scraper)
('SERPAPI_KEYS', 'serpapi.api_keys', 'string'),
("SERPAPI_KEYS", "serpapi.api_keys", "string"),
]
for env_name, dotted_key, value_type in mappings:
@@ -149,77 +134,78 @@ def load_addon_config() -> Dict[str, Any]:
def _convert_config_value(value: str, value_type: str) -> Any:
"""
Convert configuration values according to type (consistent with the convertConfigValue method on the PHP side)
Args:
value: configuration value string (may be None)
value_type: configuration type
Returns:
Converted configuration value
"""
# Handling None or null values
if value is None or value == '':
if value_type == 'int':
if value is None or value == "":
if value_type == "int":
return 0
elif value_type == 'float':
elif value_type == "float":
return 0.0
elif value_type == 'bool':
elif value_type == "bool":
return False
elif value_type == 'json':
elif value_type == "json":
return {}
else:
return ''
return ""
try:
if value_type == 'int':
if value_type == "int":
return int(value)
elif value_type == 'float':
elif value_type == "float":
return float(value)
elif value_type == 'bool':
return bool(value) or value == '1' or value == 'true' or value == 'True'
elif value_type == 'json':
elif value_type == "bool":
return bool(value) or value == "1" or value == "true" or value == "True"
elif value_type == "json":
import json
try:
return json.loads(value) if value else {}
except (json.JSONDecodeError, TypeError):
return {}
else:
return str(value) if value is not None else ''
return str(value) if value is not None else ""
except (ValueError, TypeError) as e:
logger.warning(f"Config value type conversion failed: value={value}, type={value_type}, error={str(e)}")
# Returns default value if conversion fails
if value_type == 'int':
if value_type == "int":
return 0
elif value_type == 'float':
elif value_type == "float":
return 0.0
elif value_type == 'bool':
elif value_type == "bool":
return False
elif value_type == 'json':
elif value_type == "json":
return {}
else:
return str(value) if value is not None else ''
return str(value) if value is not None else ""
def get_internal_api_key() -> Optional[str]:
"""
Get the internal API key (preferably read from environment variables)
Returns:
Internal API key, returns None if not configured
"""
try:
env_val = os.getenv('INTERNAL_API_KEY', '').strip()
env_val = os.getenv("INTERNAL_API_KEY", "").strip()
if env_val:
return env_val
config = load_addon_config()
api_key = config.get('internal_api', {}).get('key')
api_key = config.get("internal_api", {}).get("key")
if api_key:
logger.debug(f"Loaded INTERNAL_API_KEY from env-config shape, length: {len(api_key)}")
else:
logger.warning("Missing INTERNAL_API_KEY (env).")
return api_key
except Exception as e:
logger.error(f"Failed to load internal API key: {str(e)}")
@@ -233,4 +219,3 @@ def clear_config_cache():
global _config_cache
_config_cache = None
logger.debug("Addon config cache cleared")
+19 -12
View File
@@ -5,7 +5,7 @@ Provides unified interface for PostgreSQL database operations.
Usage:
from app.utils.db import get_db_connection
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
@@ -18,16 +18,22 @@ Configuration:
# Re-export from PostgreSQL module
from app.utils.db_postgres import (
get_pg_connection as get_db_connection,
get_pg_connection_sync as get_db_connection_sync,
is_postgres_available,
close_pool as close_db,
)
from app.utils.db_postgres import (
get_pg_connection as get_db_connection,
)
from app.utils.db_postgres import (
get_pg_connection_sync as get_db_connection_sync,
)
from app.utils.db_postgres import (
is_postgres_available,
)
def get_db_type() -> str:
"""Get database type (always postgresql)"""
return 'postgresql'
return "postgresql"
def is_postgres() -> bool:
@@ -42,6 +48,7 @@ def init_database():
"""
if is_postgres_available():
from app.utils.logger import get_logger
logger = get_logger(__name__)
logger.info("PostgreSQL connection verified")
else:
@@ -55,11 +62,11 @@ def close_db_connection():
__all__ = [
'get_db_connection',
'get_db_connection_sync',
'close_db_connection',
'init_database',
'close_db',
'get_db_type',
'is_postgres',
"get_db_connection",
"get_db_connection_sync",
"close_db_connection",
"init_database",
"close_db",
"get_db_type",
"is_postgres",
]
+69 -65
View File
@@ -4,19 +4,21 @@ PostgreSQL Database Connection Utility
Supports multi-user mode with connection pooling.
Provides placeholder conversion for backward compatibility with legacy code.
"""
import os
import threading
from typing import Optional, Any, List, Dict
from contextlib import contextmanager
from typing import Any, Dict, List, Optional
from app.utils.logger import get_logger
logger = get_logger(__name__)
# Try to import psycopg2
try:
import psycopg2
from psycopg2 import pool
from psycopg2.extras import RealDictCursor
HAS_PSYCOPG2 = True
except ImportError:
HAS_PSYCOPG2 = False
@@ -29,7 +31,7 @@ _pool_lock = threading.Lock()
def _get_database_url() -> str:
"""Get database connection URL from environment"""
return os.getenv('DATABASE_URL', '').strip()
return os.getenv("DATABASE_URL", "").strip()
def _parse_database_url(url: str) -> Dict[str, Any]:
@@ -38,131 +40,133 @@ def _parse_database_url(url: str) -> Dict[str, Any]:
"""
if not url:
return {}
# Remove protocol prefix
if url.startswith('postgresql://'):
if url.startswith("postgresql://"):
url = url[13:]
elif url.startswith('postgres://'):
elif url.startswith("postgres://"):
url = url[11:]
else:
return {}
result = {}
# Split user:password@host:port/dbname
if '@' in url:
auth, hostpart = url.rsplit('@', 1)
if ':' in auth:
result['user'], result['password'] = auth.split(':', 1)
if "@" in url:
auth, hostpart = url.rsplit("@", 1)
if ":" in auth:
result["user"], result["password"] = auth.split(":", 1)
else:
result['user'] = auth
result["user"] = auth
else:
hostpart = url
# Split host:port/dbname
if '/' in hostpart:
hostport, result['dbname'] = hostpart.split('/', 1)
if "/" in hostpart:
hostport, result["dbname"] = hostpart.split("/", 1)
else:
hostport = hostpart
if ':' in hostport:
result['host'], port_str = hostport.split(':', 1)
result['port'] = int(port_str)
if ":" in hostport:
result["host"], port_str = hostport.split(":", 1)
result["port"] = int(port_str)
else:
result['host'] = hostport
result['port'] = 5432
result["host"] = hostport
result["port"] = 5432
return result
def _get_connection_pool():
"""Get or create connection pool"""
global _connection_pool
if _connection_pool is not None:
return _connection_pool
with _pool_lock:
if _connection_pool is not None:
return _connection_pool
if not HAS_PSYCOPG2:
raise RuntimeError("psycopg2 is not installed. Cannot use PostgreSQL.")
db_url = _get_database_url()
if not db_url:
raise RuntimeError("DATABASE_URL environment variable is not set.")
params = _parse_database_url(db_url)
if not params:
raise RuntimeError(f"Invalid DATABASE_URL format: {db_url}")
try:
_connection_pool = pool.ThreadedConnectionPool(
minconn=2,
maxconn=20,
host=params.get('host', 'localhost'),
port=params.get('port', 5432),
user=params.get('user', 'quantdinger'),
password=params.get('password', ''),
dbname=params.get('dbname', 'quantdinger'),
host=params.get("host", "localhost"),
port=params.get("port", 5432),
user=params.get("user", "quantdinger"),
password=params.get("password", ""),
dbname=params.get("dbname", "quantdinger"),
connect_timeout=10,
)
logger.info(f"PostgreSQL connection pool created: {params.get('host')}:{params.get('port')}/{params.get('dbname')}")
logger.info(
f"PostgreSQL connection pool created: {params.get('host')}:{params.get('port')}/{params.get('dbname')}"
)
except Exception as e:
logger.error(f"Failed to create PostgreSQL connection pool: {e}")
raise
return _connection_pool
class PostgresCursor:
"""PostgreSQL cursor wrapper with placeholder conversion for backward compatibility"""
def __init__(self, cursor):
self._cursor = cursor
self._last_insert_id = None
def _convert_placeholders(self, query: str) -> str:
"""
Convert ? placeholders to PostgreSQL %s for backward compatibility.
Also handle some SQL syntax differences.
"""
# Replace ? -> %s
query = query.replace('?', '%s')
query = query.replace("?", "%s")
# INSERT OR IGNORE -> PostgreSQL: INSERT ... ON CONFLICT DO NOTHING
query = query.replace('INSERT OR IGNORE', 'INSERT')
query = query.replace("INSERT OR IGNORE", "INSERT")
return query
def execute(self, query: str, args: Any = None):
"""Execute SQL statement"""
query = self._convert_placeholders(query)
# Check if this is an INSERT and add RETURNING id if not present
is_insert = query.strip().upper().startswith('INSERT')
if is_insert and 'RETURNING' not in query.upper():
query = query.rstrip(';').rstrip() + ' RETURNING id'
is_insert = query.strip().upper().startswith("INSERT")
if is_insert and "RETURNING" not in query.upper():
query = query.rstrip(";").rstrip() + " RETURNING id"
if args:
if not isinstance(args, (tuple, list)):
args = (args,)
result = self._cursor.execute(query, args)
else:
result = self._cursor.execute(query)
# Capture last insert id for INSERT statements
if is_insert:
try:
row = self._cursor.fetchone()
if row and 'id' in row:
self._last_insert_id = row['id']
if row and "id" in row:
self._last_insert_id = row["id"]
except Exception:
pass
return result
def fetchone(self) -> Optional[Dict[str, Any]]:
"""Fetch single row"""
row = self._cursor.fetchone()
@@ -170,7 +174,7 @@ class PostgresCursor:
return None
# RealDictCursor already returns a dict, so return as-is
return row if isinstance(row, dict) else dict(row) if row else None
def fetchall(self) -> List[Dict[str, Any]]:
"""Fetch all rows"""
rows = self._cursor.fetchall()
@@ -178,16 +182,16 @@ class PostgresCursor:
return []
# RealDictCursor already returns dicts, so return as-is
return [row if isinstance(row, dict) else dict(row) for row in rows]
def close(self):
"""Close cursor"""
self._cursor.close()
@property
def lastrowid(self) -> Optional[int]:
"""Get last inserted row ID"""
return self._last_insert_id
@property
def rowcount(self) -> int:
"""Get affected row count"""
@@ -196,23 +200,23 @@ class PostgresCursor:
class PostgresConnection:
"""PostgreSQL connection wrapper"""
def __init__(self, conn):
self._conn = conn
self._pool = _get_connection_pool()
def cursor(self) -> PostgresCursor:
"""Create cursor"""
return PostgresCursor(self._conn.cursor(cursor_factory=RealDictCursor))
def commit(self):
"""Commit transaction"""
self._conn.commit()
def rollback(self):
"""Rollback transaction"""
self._conn.rollback()
def close(self):
"""Return connection to pool"""
if self._pool and self._conn:
@@ -280,7 +284,7 @@ def execute_sql(sql: str, params: tuple = None) -> List[Dict[str, Any]]:
with get_pg_connection() as conn:
cursor = conn.cursor()
cursor.execute(sql, params)
if sql.strip().upper().startswith('SELECT'):
if sql.strip().upper().startswith("SELECT"):
return cursor.fetchall()
conn.commit()
return []
@@ -290,11 +294,11 @@ def is_postgres_available() -> bool:
"""Check if PostgreSQL is available"""
if not HAS_PSYCOPG2:
return False
db_url = _get_database_url()
if not db_url:
return False
try:
with get_pg_connection() as conn:
cursor = conn.cursor()
+6 -8
View File
@@ -1,24 +1,23 @@
"""
HTTP tool module
"""
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def get_retry_session(
retries: int = 3,
backoff_factor: float = 0.5,
status_forcelist: tuple = (500, 502, 503, 504)
retries: int = 3, backoff_factor: float = 0.5, status_forcelist: tuple = (500, 502, 503, 504)
) -> requests.Session:
"""
Get HTTP Session with retry mechanism
Args:
retries: number of retries
backoff_factor: retry interval factor
status_forcelist: HTTP status codes that need to be retried
Returns:
Configured Session instance
"""
@@ -31,11 +30,10 @@ def get_retry_session(
status_forcelist=status_forcelist,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount('http://', adapter)
session.mount('https://', adapter)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
# Global shared session
global_session = get_retry_session()
-3
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
from typing import Optional
SUPPORTED_LANGS = {
"en-US",
"zh-CN",
@@ -82,5 +81,3 @@ def detect_request_language(flask_request, body: Optional[dict] = None, default:
return lang
return default
+17 -20
View File
@@ -1,6 +1,7 @@
"""
Logging utilities (local-only friendly).
"""
import logging
import os
from logging.handlers import RotatingFileHandler
@@ -8,35 +9,32 @@ from logging.handlers import RotatingFileHandler
def setup_logger():
"""Configure global logs"""
log_level = os.getenv('LOG_LEVEL', 'INFO')
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format=log_format
)
log_level = os.getenv("LOG_LEVEL", "INFO")
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
logging.basicConfig(level=getattr(logging, log_level.upper()), format=log_format)
# Filter werkzeug's INFO level logs (reduce noise)
# Only keep WARNING and above levels
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger = logging.getLogger("werkzeug")
werkzeug_logger.setLevel(logging.WARNING)
# Filter INFO level logs for kline routes (reduce noise)
# Only keep WARNING and above levels
kline_logger = logging.getLogger('app.routes.kline')
kline_logger = logging.getLogger("app.routes.kline")
kline_logger.setLevel(logging.WARNING)
# Create log directory
log_dir = 'logs'
log_dir = "logs"
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# Add file handler
file_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'),
maxBytes=10*1024*1024, # 10MB
os.path.join(log_dir, "app.log"),
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
encoding='utf-8'
encoding="utf-8",
)
file_handler.setFormatter(logging.Formatter(log_format))
logging.getLogger().addHandler(file_handler)
@@ -45,12 +43,11 @@ def setup_logger():
def get_logger(name: str) -> logging.Logger:
"""
Get the logger with the specified name
Args:
name: logger name
Returns:
Logger instance
"""
return logging.getLogger(name)
+155 -127
View File
@@ -2,13 +2,14 @@
Secure code execution tools
Provides timeouts, resource limits and a sandbox environment
"""
import os
import signal
import sys
import os
import threading
import traceback
from typing import Dict, Any, Optional, Tuple
from contextlib import contextmanager
from typing import Any, Dict, Optional, Tuple
from app.utils.logger import get_logger
@@ -17,6 +18,7 @@ logger = get_logger(__name__)
class TimeoutError(Exception):
"""Code execution timeout exception"""
pass
@@ -24,39 +26,39 @@ class TimeoutError(Exception):
def timeout_context(seconds: int):
"""
Code execution timeout context manager
Notice:
- Only works on Unix/Linux systems
- Only valid in the main thread, non-main threads will be downgraded to unlimited timeout
- Will downgrade to unlimited timeout on Windows
Args:
seconds: timeout (seconds)
"""
# Check if in main thread
is_main_thread = threading.current_thread() is threading.main_thread()
if sys.platform == 'win32':
if sys.platform == "win32":
# signal.alarm is not supported on Windows, only warnings can be logged
logger.warning("Windows does not support signal-based timeouts; execution time limits may not work")
yield
return
if not is_main_thread:
# Non-main threads cannot use signal. Warnings are logged but timeouts are not limited.
# logger.warning(f"Currently running in a non-main thread (thread: {threading.current_thread().name}),"
# f"signal timeout is not available, code execution may not limit the time")
yield
return
def timeout_handler(signum, frame):
raise TimeoutError(f"Code execution timed out after {seconds} seconds")
try:
# Set up signal handler
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(seconds)
try:
yield
finally:
@@ -74,41 +76,42 @@ def safe_exec_code(
exec_globals: Dict[str, Any],
exec_locals: Optional[Dict[str, Any]] = None,
timeout: int = 30,
max_memory_mb: Optional[int] = None
max_memory_mb: Optional[int] = None,
) -> Dict[str, Any]:
"""
Safe execution of Python code
Args:
code: Python code to execute
exec_globals: dictionary of global variables
exec_locals: dictionary of local variables (if None, exec_globals is used)
timeout: timeout (seconds), default 30 seconds
max_memory_mb: Maximum memory limit (MB), default 500MB
Returns:
Execution result dictionary, including:
- success: bool, whether the execution was successful
- error: str, error message (if failure)
- result: Any, execution result (if any)
Raises:
TimeoutError: If the code execution times out
"""
if exec_locals is None:
exec_locals = exec_globals
# Set memory limit (if supported)
if max_memory_mb is None:
max_memory_mb = 500 # Default 500MB
try:
# Note: resource.setrlimit is process level and affects the entire API process.
# The previous global limit of 500MB could prevent parallel strategies/threads from allocating memory.
# Only set if SAFE_EXEC_ENABLE_RLIMIT is explicitly turned on.
if sys.platform != 'win32' and os.getenv('SAFE_EXEC_ENABLE_RLIMIT', 'false').lower() == 'true':
if sys.platform != "win32" and os.getenv("SAFE_EXEC_ENABLE_RLIMIT", "false").lower() == "true":
try:
import resource
max_memory_bytes = max_memory_mb * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (max_memory_bytes, max_memory_bytes))
logger.debug(f"Memory limit set: {max_memory_mb}MB (SAFE_EXEC_ENABLE_RLIMIT enabled)")
@@ -116,150 +119,159 @@ def safe_exec_code(
logger.warning(f"Failed to set memory limit: {str(e)}")
else:
logger.debug("No resource memory limit (SAFE_EXEC_ENABLE_RLIMIT disabled or unsupported platform)")
# On Windows, timeout_context doesn't really limit the time
# But a warning will be logged
with timeout_context(timeout):
exec(code, exec_globals, exec_locals)
return {
'success': True,
'error': None,
'result': None
}
except MemoryError as e:
return {"success": True, "error": None, "result": None}
except MemoryError:
error_msg = f"Code execution exceeded the memory limit of {max_memory_mb}MB"
logger.error(f"Code execution out of memory (limit={max_memory_mb}MB)")
return {
'success': False,
'error': error_msg,
'result': None
}
return {"success": False, "error": error_msg, "result": None}
except TimeoutError as e:
error_msg = str(e)
logger.error(f"Code execution timed out (timeout={timeout}s)")
return {
'success': False,
'error': error_msg,
'result': None
}
return {"success": False, "error": error_msg, "result": None}
except Exception as e:
error_msg = f"Code execution error: {str(e)}\n{traceback.format_exc()}"
logger.error(f"Code execution error: {str(e)}")
logger.error(traceback.format_exc())
return {
'success': False,
'error': error_msg,
'result': None
}
return {"success": False, "error": error_msg, "result": None}
def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
"""
Verify code security (basic check)
Check your code for dangerous function calls or imports
Args:
code: Python code to check
Returns:
(is_safe: bool, error_message: Optional[str])
"""
import ast
import re
# Dangerous keywords and function names
dangerous_patterns = [
# System command execution
r'\bos\.system\b',
r'\bos\.popen\b',
r'\bos\.spawn\b',
r'\bos\.exec\b',
r'\bos\.fork\b',
r'\bsubprocess\b',
r'\bcommands\b',
r"\bos\.system\b",
r"\bos\.popen\b",
r"\bos\.spawn\b",
r"\bos\.exec\b",
r"\bos\.fork\b",
r"\bsubprocess\b",
r"\bcommands\b",
# code execution
r'\b__import__\s*\(',
r'\beval\s*\(',
r'\bexec\s*\(',
r'\bcompile\s*\(',
r"\b__import__\s*\(",
r"\beval\s*\(",
r"\bexec\s*\(",
r"\bcompile\s*\(",
# File operations
r'\bopen\s*\(',
r'\bfile\s*\(',
r'\b__builtins__\b',
r"\bopen\s*\(",
r"\bfile\s*\(",
r"\b__builtins__\b",
# module import
r'\bimport\s+os\b',
r'\bimport\s+sys\b',
r'\bimport\s+subprocess\b',
r'\bimport\s+pymysql\b',
r'\bimport\s+sqlite3\b',
r'\bimport\s+requests\b',
r'\bimport\s+urllib\b',
r'\bimport\s+http\b',
r'\bimport\s+socket\b',
r'\bimport\s+ftplib\b',
r'\bimport\s+telnetlib\b',
r'\bimport\s+pickle\b',
r'\bimport\s+cpickle\b',
r'\bimport\s+marshal\b',
r'\bimport\s+ctypes\b',
r'\bimport\s+multiprocessing\b',
r'\bimport\s+threading\b',
r'\bimport\s+concurrent\b',
r"\bimport\s+os\b",
r"\bimport\s+sys\b",
r"\bimport\s+subprocess\b",
r"\bimport\s+pymysql\b",
r"\bimport\s+sqlite3\b",
r"\bimport\s+requests\b",
r"\bimport\s+urllib\b",
r"\bimport\s+http\b",
r"\bimport\s+socket\b",
r"\bimport\s+ftplib\b",
r"\bimport\s+telnetlib\b",
r"\bimport\s+pickle\b",
r"\bimport\s+cpickle\b",
r"\bimport\s+marshal\b",
r"\bimport\s+ctypes\b",
r"\bimport\s+multiprocessing\b",
r"\bimport\s+threading\b",
r"\bimport\s+concurrent\b",
# Reflection and metaprogramming (possibly used to bypass restrictions)
r'\bgetattr\s*\(.*__import__',
r'\bgetattr\s*\(.*eval',
r'\bgetattr\s*\(.*exec',
r'\bsetattr\s*\(',
r'\b__getattribute__\b',
r'\b__setattr__\b',
r'\b__dict__\b',
r'\bglobals\s*\(',
r'\blocals\s*\(',
r'\bdir\s*\(',
r'\btype\s*\(.*\)\s*\(', # type() may be used to create new types
r'\b__class__\b',
r'\b__bases__\b',
r'\b__subclasses__\b',
r'\b__mro__\b',
r'\b__init__\b.*__import__',
r'\b__new__\b.*__import__',
r"\bgetattr\s*\(.*__import__",
r"\bgetattr\s*\(.*eval",
r"\bgetattr\s*\(.*exec",
r"\bsetattr\s*\(",
r"\b__getattribute__\b",
r"\b__setattr__\b",
r"\b__dict__\b",
r"\bglobals\s*\(",
r"\blocals\s*\(",
r"\bdir\s*\(",
r"\btype\s*\(.*\)\s*\(", # type() may be used to create new types
r"\b__class__\b",
r"\b__bases__\b",
r"\b__subclasses__\b",
r"\b__mro__\b",
r"\b__init__\b.*__import__",
r"\b__new__\b.*__import__",
# Other dangerous operations
r'\b__builtins__\s*\[',
r'\b__builtins__\s*\.',
r'\b__import__\s*\(',
r'\bimportlib\b',
r'\bimp\b',
r"\b__builtins__\s*\[",
r"\b__builtins__\s*\.",
r"\b__import__\s*\(",
r"\bimportlib\b",
r"\bimp\b",
]
# Check your code for dangerous patterns
for pattern in dangerous_patterns:
if re.search(pattern, code):
return False, f"Dangerous code pattern detected: {pattern}"
# Try parsing the AST, checking for dangerous nodes
try:
tree = ast.parse(code)
# List of dangerous modules (extended)
dangerous_modules = [
'os', 'sys', 'subprocess', 'pymysql', 'sqlite3',
'requests', 'urllib', 'http', 'socket', 'ftplib', 'telnetlib',
'pickle', 'cpickle', 'marshal', 'ctypes',
'multiprocessing', 'threading', 'concurrent',
'importlib', 'imp', 'builtins'
"os",
"sys",
"subprocess",
"pymysql",
"sqlite3",
"requests",
"urllib",
"http",
"socket",
"ftplib",
"telnetlib",
"pickle",
"cpickle",
"marshal",
"ctypes",
"multiprocessing",
"threading",
"concurrent",
"importlib",
"imp",
"builtins",
]
# List of dangerous functions (extended)
# Note: hasattr is safe and is only used to check attributes, not to access
dangerous_functions = [
'eval', 'exec', 'compile', '__import__',
'getattr', 'setattr', 'delattr', # hasattr has been removed, it is safe
'globals', 'locals', 'vars', 'dir', 'type'
"eval",
"exec",
"compile",
"__import__",
"getattr",
"setattr",
"delattr", # hasattr has been removed, it is safe
"globals",
"locals",
"vars",
"dir",
"type",
]
# Check for dangerous function calls
for node in ast.walk(tree):
# Check for calls to dangerous functions
@@ -268,42 +280,58 @@ def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
func_name = node.func.id
if func_name in dangerous_functions:
return False, f"Dangerous function call detected: {func_name}()"
# Check if there are calls to os.system etc.
if isinstance(node.func, ast.Attribute):
if isinstance(node.func.value, ast.Name):
if node.func.value.id in dangerous_modules:
return False, f"Dangerous module call detected: {node.func.value.id}.{node.func.attr}"
# Check if there are bypass methods such as getattr(builtins, '__import__')
if isinstance(node.func, ast.Name) and node.func.id == 'getattr':
if isinstance(node.func, ast.Name) and node.func.id == "getattr":
# Check the parameters of getattr
if len(node.args) >= 2:
if isinstance(node.args[0], ast.Name) and node.args[0].id in ['builtins', '__builtins__']:
if isinstance(node.args[0], ast.Name) and node.args[0].id in ["builtins", "__builtins__"]:
if isinstance(node.args[1], ast.Constant) and node.args[1].value in dangerous_functions:
return False, f"Detected an attempt to bypass restrictions via getattr: getattr({node.args[0].id}, '{node.args[1].value}')"
return (
False,
f"Detected an attempt to bypass restrictions via getattr: getattr({node.args[0].id}, '{node.args[1].value}')",
)
# Check the import statement: the use of import is prohibited in user scripts (security dependencies are uniformly injected by the platform)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
return False, "Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly."
return (
False,
"Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly.",
)
if isinstance(node, ast.ImportFrom):
return False, "Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly."
return (
False,
"Import statements are not allowed in scripts. Use the platform-provided objects such as pd and np directly.",
)
# Check if there is an attempt to access __builtins__
for node in ast.walk(tree):
if isinstance(node, ast.Attribute):
if isinstance(node.attr, str) and node.attr.startswith('__') and node.attr.endswith('__'):
if node.attr in ['__builtins__', '__import__', '__class__', '__bases__', '__subclasses__', '__mro__']:
if isinstance(node.attr, str) and node.attr.startswith("__") and node.attr.endswith("__"):
if node.attr in [
"__builtins__",
"__import__",
"__class__",
"__bases__",
"__subclasses__",
"__mro__",
]:
# Check if used in dangerous context
if isinstance(node.value, ast.Name) and node.value.id in ['builtins', '__builtins__']:
if isinstance(node.value, ast.Name) and node.value.id in ["builtins", "__builtins__"]:
return False, f"Dangerous attribute access detected: {node.value.id}.{node.attr}"
except SyntaxError as e:
return False, f"Code syntax error: {str(e)}"
except Exception as e:
# If AST parsing fails, log a warning but allow to continue (possibly incomplete code)
logger.warning(f"AST parse failed; skipping safety checks: {str(e)}")
return True, None