fustor-common 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from .logging_config import setup_logging
2
+ from .exceptions import ConfigurationError
3
+ from .schemas import ConfigCreateResponse
4
+ from .enums import UserLevel
@@ -0,0 +1,52 @@
1
+ """
2
+ Common daemon functionality for Fustor services.
3
+ This module provides a generic daemon launcher that can be used by
4
+ any Fustor service without knowing implementation details.
5
+ """
6
+ import os
7
+ import sys
8
+ import subprocess
9
+ from fustor_common.paths import get_fustor_home_dir
10
+
11
+
12
+ def start_daemon(service_module_path, app_var_name, pid_file_name, log_file_name, display_name, port, host='127.0.0.1', verbose=False, reload=False):
13
+ """
14
+ Start a Fustor service as a daemon process.
15
+
16
+ Args:
17
+ service_module_path (str): The Python module path to import (e.g., 'fustor_registry.main')
18
+ app_var_name (str): The name of the application variable in the module (e.g., 'app')
19
+ pid_file_name (str): The name of the PID file (e.g., 'registry.pid')
20
+ log_file_name (str): The name of the log file (e.g., 'registry.log')
21
+ display_name (str): The display name for the service (e.g., 'Fustor Registry')
22
+ port (int): The port to run the service on
23
+ verbose (bool): Whether to enable verbose logging
24
+ """
25
+ # Use the generic daemon launcher script
26
+ daemon_script_path = os.path.join(os.path.dirname(__file__), 'daemon_launcher.py')
27
+
28
+ # Create the command to execute the generic daemon launcher
29
+ command = [
30
+ sys.executable,
31
+ daemon_script_path,
32
+ service_module_path,
33
+ app_var_name,
34
+ pid_file_name,
35
+ log_file_name,
36
+ display_name,
37
+ str(port),
38
+ host
39
+ ]
40
+
41
+ if verbose:
42
+ command.append('--verbose')
43
+
44
+ if reload:
45
+ command.append('--reload')
46
+
47
+ # Set up the environment to ensure the subprocess has correct paths
48
+ env = os.environ.copy()
49
+ # Ensure PYTHONPATH includes the current directory for development
50
+ env['PYTHONPATH'] = os.getcwd() + ':' + env.get('PYTHONPATH', '')
51
+
52
+ subprocess.Popen(command, stdout=None, stderr=None, stdin=None, close_fds=True, env=env)
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Generic daemon launcher for Fustor services.
4
+ This script is used to launch any Fustor service as a daemon.
5
+ It receives service parameters via command line arguments.
6
+ """
7
+ import sys
8
+ import os
9
+ import logging
10
+ import importlib
11
+ import argparse
12
+ from fustor_common.paths import get_fustor_home_dir
13
+ from fustor_common.logging_config import setup_logging
14
+
15
+
16
+ def main():
17
+ parser = argparse.ArgumentParser(description='Generic Fustor Service Daemon Launcher')
18
+ parser.add_argument('module_path', help='Module path to import (e.g., fustor_registry.main)')
19
+ parser.add_argument('app_var', help='Application variable name (e.g., app)')
20
+ parser.add_argument('pid_file', help='PID file name (e.g., registry.pid)')
21
+ parser.add_argument('log_file', help='Log file name (e.g., registry.log)')
22
+ parser.add_argument('display_name', help='Display name for the service')
23
+ parser.add_argument('port', type=int, help='Port to run the service on')
24
+ parser.add_argument('host', nargs='?', default='127.0.0.1', help='Host to bind the service to (default: 127.0.0.1)')
25
+ parser.add_argument('--reload', action='store_true', help='Enable auto-reloading for development')
26
+ parser.add_argument('--verbose', action='store_true', help='Enable verbose logging')
27
+
28
+ args = parser.parse_args()
29
+
30
+ # Setup paths
31
+ HOME_FUSTOR_DIR = get_fustor_home_dir()
32
+ PID_FILE = os.path.join(HOME_FUSTOR_DIR, args.pid_file)
33
+ LOG_FILE = os.path.join(HOME_FUSTOR_DIR, args.log_file)
34
+
35
+ # Setup logging
36
+ log_level = "DEBUG" if args.verbose else "INFO"
37
+ setup_logging(
38
+ log_file_path=LOG_FILE,
39
+ base_logger_name=args.display_name.lower().replace(' ', '_') + '_daemon',
40
+ level=log_level,
41
+ console_output=False # No console output for daemon
42
+ )
43
+ logger = logging.getLogger(args.display_name.lower().replace(' ', '_') + '_daemon')
44
+
45
+ try:
46
+ # Import the service module
47
+ service_module = importlib.import_module(args.module_path)
48
+ app = getattr(service_module, args.app_var)
49
+
50
+ # Ensure directory exists and create PID file
51
+ os.makedirs(HOME_FUSTOR_DIR, exist_ok=True)
52
+ with open(PID_FILE, 'w') as f:
53
+ f.write(str(os.getpid()))
54
+
55
+ print()
56
+ print("="*60)
57
+ print(f"{args.display_name} (Daemon)")
58
+ print(f"Web : http://{args.host}:{args.port}")
59
+ print("="*60)
60
+ print()
61
+
62
+ logger.info(f"{args.display_name} daemon starting on {args.host}:{args.port}")
63
+
64
+ # Import and run uvicorn
65
+ import uvicorn
66
+ # Configure uvicorn to use DEBUG level for access logs to reduce verbosity
67
+ # Need to set this after import but before run, and ensure it persists
68
+ uvicorn_logger = logging.getLogger("uvicorn.access")
69
+ uvicorn_logger.setLevel(logging.DEBUG)
70
+
71
+ uvicorn.run(
72
+ app,
73
+ host=args.host,
74
+ port=args.port,
75
+ log_config=None, # Logging handled separately
76
+ access_log=True,
77
+ reload=args.reload,
78
+ )
79
+ except KeyboardInterrupt:
80
+ logger.info(f"{args.display_name} daemon interrupted")
81
+ except Exception as e:
82
+ logger.critical(f"{args.display_name} daemon error: {e}", exc_info=True)
83
+ print(f"{args.display_name} daemon error: {e}")
84
+ finally:
85
+ # Clean up PID file
86
+ if os.path.exists(PID_FILE):
87
+ os.remove(PID_FILE)
88
+ logger.info("PID file removed")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
fustor_common/enums.py ADDED
@@ -0,0 +1,5 @@
1
+ from enum import Enum
2
+
3
+ class UserLevel(str, Enum):
4
+ NORMAL = "normal"
5
+ ADMIN = "admin"
@@ -0,0 +1,3 @@
1
+ class ConfigurationError(Exception):
2
+ """自定义配置错误异常"""
3
+ pass
@@ -0,0 +1,161 @@
1
+ # src/fustor_common/logging_config.py
2
+
3
+ import logging
4
+ import logging.config
5
+ import os
6
+ import sys
7
+
8
+ class UvicornAccessFilter(logging.Filter):
9
+ """
10
+ Filter to suppress uvicorn.access logs when system level is INFO,
11
+ but allow them when system level is DEBUG.
12
+ """
13
+ def __init__(self, normal_level: int = logging.INFO):
14
+ super().__init__()
15
+ self.normal_level = normal_level
16
+
17
+ def filter(self, record):
18
+ # If it's a uvicorn.access log, only allow it through if system level is DEBUG
19
+ if record.name.startswith('uvicorn.access'):
20
+ return self.normal_level <= logging.DEBUG
21
+ # For other logs, apply normal level filtering
22
+ else:
23
+ return record.levelno >= self.normal_level
24
+
25
+ def setup_logging(
26
+ log_file_path: str, # Now accepts full path
27
+ base_logger_name: str,
28
+ level: int = logging.INFO,
29
+ console_output: bool = True
30
+ ):
31
+ """
32
+ 通用日志配置函数。
33
+
34
+ Args:
35
+ log_file_path (str): 日志文件存放的完整路径。
36
+ base_logger_name (str): 您的应用程序的基础logger名称(例如,"fustor_agent"或"fustor_fusion")。
37
+ level (int): 控制台和文件处理程序的最低日志级别。
38
+ console_output (bool): 是否将日志输出到控制台。
39
+ """
40
+ # 确保日志文件所在的目录存在
41
+ log_directory = os.path.dirname(log_file_path)
42
+ os.makedirs(log_directory, exist_ok=True)
43
+
44
+ # Truncate the log file at startup
45
+ if os.path.exists(log_file_path):
46
+ try:
47
+ with open(log_file_path, 'w', encoding='utf8'):
48
+ pass # Open and immediately close to truncate
49
+ except IOError as e:
50
+ # Log the error, but don't prevent further logging
51
+ logging.getLogger(base_logger_name).error(f"Failed to truncate log file {log_file_path}: {e}")
52
+
53
+
54
+ if isinstance(level, str):
55
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
56
+ else:
57
+ numeric_level = level
58
+
59
+ LOGGING_CONFIG = {
60
+ 'version': 1,
61
+ 'disable_existing_loggers': False,
62
+ 'formatters': {
63
+ 'standard': {
64
+ 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
65
+ 'datefmt': '%Y-%m-%d %H:%M:%S'
66
+ },
67
+ 'color_console': {
68
+ '()': 'colorlog.ColoredFormatter',
69
+ 'format': '%(log_color)s%(asctime)s - %(name)s - %(levelname)s - %(message)s%(reset)s',
70
+ 'datefmt': '%Y-%m-%d %H:%M:%S',
71
+ 'log_colors': {
72
+ 'DEBUG': 'cyan',
73
+ 'INFO': 'green',
74
+ 'WARNING': 'yellow',
75
+ 'ERROR': 'red',
76
+ 'CRITICAL': 'bold_red',
77
+ }
78
+ },
79
+ 'json': {
80
+ '()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
81
+ 'format': '%(asctime)s %(levelname)s %(name)s %(message)s'
82
+ }
83
+ },
84
+ 'handlers': {
85
+ 'file': {
86
+ 'class': 'logging.handlers.RotatingFileHandler',
87
+ 'level': logging.DEBUG, # Set to DEBUG so it can catch DEBUG level logs
88
+ 'formatter': 'standard',
89
+ 'filename': log_file_path,
90
+ 'maxBytes': 10485760, # 10MB
91
+ 'backupCount': 5,
92
+ 'encoding': 'utf8',
93
+ 'filters': ['uvicorn_access_filter'] # Add the custom filter
94
+ },
95
+ 'console': {
96
+ 'class': 'logging.StreamHandler',
97
+ 'level': logging.DEBUG, # Set to DEBUG for the same reason
98
+ 'formatter': 'color_console',
99
+ 'stream': sys.stdout,
100
+ 'filters': ['uvicorn_access_filter'] # Add the custom filter
101
+ }
102
+ },
103
+ 'filters': {
104
+ 'uvicorn_access_filter': {
105
+ '()': UvicornAccessFilter,
106
+ 'normal_level': numeric_level
107
+ }
108
+ },
109
+ 'loggers': {
110
+ base_logger_name: {
111
+ 'handlers': ['file'],
112
+ 'level': numeric_level,
113
+ 'propagate': False
114
+ },
115
+ 'uvicorn': {
116
+ 'handlers': ['file'],
117
+ 'level': numeric_level,
118
+ 'propagate': False
119
+ },
120
+ 'uvicorn.error': {
121
+ 'handlers': ['file'],
122
+ 'level': numeric_level,
123
+ 'propagate': False
124
+ },
125
+ 'uvicorn.access': {
126
+ 'handlers': ['file'],
127
+ 'level': logging.INFO, # Standard level for access logs
128
+ 'propagate': False
129
+ }
130
+ },
131
+ 'root': {
132
+ 'handlers': ['file'],
133
+ 'level': logging.ERROR, # Keep root at ERROR to avoid noise
134
+ 'propagate': True, # Allow root to emit to its handlers for unhandled exceptions
135
+ }
136
+ }
137
+
138
+ if console_output:
139
+ LOGGING_CONFIG['loggers'][base_logger_name]['handlers'].append('console')
140
+ LOGGING_CONFIG['loggers']['uvicorn']['handlers'].append('console')
141
+ LOGGING_CONFIG['loggers']['uvicorn.error']['handlers'].append('console')
142
+ LOGGING_CONFIG['loggers']['uvicorn.access']['handlers'].append('console')
143
+ LOGGING_CONFIG['root']['handlers'].append('console')
144
+
145
+ logging.config.dictConfig(LOGGING_CONFIG)
146
+
147
+ # --- START: Silence Third-Party Loggers (more selectively) ---
148
+ # Only silence if the specified level is not DEBUG, to allow debugging when needed
149
+ if numeric_level > logging.DEBUG:
150
+ third_party_loggers = [
151
+ 'httpx', 'asyncio', 'watchdog', 'sqlalchemy',
152
+ 'alembic', 'requests', 'urllib3', 'multipart' # Add other chatty libraries here
153
+ ]
154
+ for logger_name in third_party_loggers:
155
+ logging.getLogger(logger_name).setLevel(logging.WARNING)
156
+ # --- END: Silence Third-Party Loggers ---
157
+
158
+ # Ensure main logger is available for immediate use
159
+ main_logger = logging.getLogger(base_logger_name)
160
+ main_logger.info(f"Logging configured successfully. Level: {logging.getLevelName(numeric_level)}")
161
+ main_logger.debug(f"Log file: {log_file_path}")
@@ -0,0 +1,75 @@
1
+ from pydantic import BaseModel, Field, ConfigDict, field_validator
2
+ from typing import Optional, Dict, List
3
+
4
+ class ResponseBase(BaseModel):
5
+ # Base model for API responses, can include common fields like status, message
6
+ pass
7
+
8
+ class ApiKeyBase(BaseModel):
9
+ id: Optional[int] = None
10
+ name: str = Field(..., min_length=3, max_length=50)
11
+ key: Optional[str] = None
12
+ datastore_id: int = Field(..., description="关联的存储库ID")
13
+
14
+ class MessageResponse(BaseModel):
15
+ """A simple response model for messages."""
16
+ message: str
17
+
18
+
19
+ class DatastoreBase(BaseModel):
20
+ id: Optional[int] = None
21
+ name: str = Field(..., description="存储库名称")
22
+ visible: bool = Field(False, description="是否对公众可见")
23
+ meta: Optional[Dict] = Field(None, description="存储库描述")
24
+ allow_concurrent_push: bool = Field(False, description="是否允许并发推送")
25
+ session_timeout_seconds: int = Field(30, description="会话超时秒数")
26
+
27
+ class TokenResponse(BaseModel):
28
+ access_token: str
29
+ token_type: str
30
+ refresh_token: str | None = None
31
+
32
+ class LogoutResponse(BaseModel):
33
+ detail: str = Field("注销成功", description="注销操作结果消息")
34
+
35
+ class Password(BaseModel):
36
+ password: str = Field(
37
+ ...,
38
+ min_length=8,
39
+ max_length=64,
40
+ pattern=r'^[A-Za-z\d@$!%*#?&]+$',
41
+ description="8-64位字符,必须包含至少1字母和1数字,允许特殊字符 @$!%*#?&"
42
+ )
43
+
44
+ @field_validator('password')
45
+ @classmethod
46
+ def validate_password_chars(cls, v: str) -> str:
47
+ if not any(c.isalpha() for c in v):
48
+ raise ValueError("必须包含至少一个字母")
49
+ if not any(c.isdigit() for c in v):
50
+ raise ValueError("必须包含至少一个数字")
51
+ return v
52
+
53
+ class ValidationResponse(BaseModel):
54
+ """A standard response model for validation actions."""
55
+ success: bool
56
+ message: str
57
+
58
+ class CleanupResponse(BaseModel):
59
+ """A standard response model for cleanup actions."""
60
+ message: str
61
+ deleted_count: int
62
+ deleted_ids: List[str]
63
+
64
+ class AdminCredentials(BaseModel):
65
+ user: str
66
+ passwd: str
67
+
68
+ class LoginRequest(BaseModel):
69
+ username: str
70
+ password: str
71
+
72
+ class DatastoreConfig(BaseModel):
73
+ datastore_id: int
74
+ allow_concurrent_push: bool
75
+ session_timeout_seconds: int
fustor_common/paths.py ADDED
@@ -0,0 +1,13 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ def get_fustor_home_dir() -> Path:
5
+ """
6
+ Determines the FUSTOR home directory.
7
+ Checks the FUSTOR_HOME environment variable first,
8
+ then defaults to ~/.fustor.
9
+ """
10
+ fustor_home = os.getenv("FUSTOR_HOME")
11
+ if fustor_home:
12
+ return Path(fustor_home).expanduser().resolve()
13
+ return Path.home() / ".fustor"
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel
2
+ from typing import Generic, TypeVar
3
+
4
+ T = TypeVar('T')
5
+
6
+ class ConfigCreateResponse(BaseModel, Generic[T]):
7
+ """A standard response for successfully creating a new configuration."""
8
+ id: str
9
+ config: T
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: fustor-common
3
+ Version: 0.1.8
4
+ Summary: Common utilities and models for Fustor services
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: colorlog>=6.10.1
8
+ Requires-Dist: pydantic>=2.11.7
9
+ Requires-Dist: pydantic-settings>=2.3.4
@@ -0,0 +1,13 @@
1
+ fustor_common/__init__.py,sha256=bj3JlkXf8AGPZz4wiz26wNfyK0mfVJtMj0U1IvotxrQ,156
2
+ fustor_common/daemon.py,sha256=V5UlZqJl9TN_wYO21yXD_Z-4a21YECnDFibOlg7Aioc,1904
3
+ fustor_common/daemon_launcher.py,sha256=gcfwb_X3c-rrNGcLUlBTkYFYvKKX-QvHFS4gKwLJPaw,3536
4
+ fustor_common/enums.py,sha256=CyhLbVhPzonNVny-Tq-XH77SJTFh0xUT40r9eXC9g64,93
5
+ fustor_common/exceptions.py,sha256=jTt9pxL8kNx0997VrSc7-Wyqr8hS3F14INjTNwjgx7g,84
6
+ fustor_common/logging_config.py,sha256=df0RVbOeDhbnJ8iLJJ94VbZ6agh5l-S64YNHW-vzww4,6180
7
+ fustor_common/models.py,sha256=LV3ydnXFqDOOBWKMkrORhFwYKQRdxHjwTuORmJ-5Ltw,2350
8
+ fustor_common/paths.py,sha256=SPoh_UsT6p65fl1DjLvLMfqtmEB40Z0rUFKYvmpUM5E,369
9
+ fustor_common/schemas.py,sha256=bEwg0MFGYqSGGAQqRPGqXBUP1rM24vt_UhfQtHxHYv8,240
10
+ fustor_common-0.1.8.dist-info/METADATA,sha256=Ol88cGXPHgyrz7p-Mqqw4l7ikGUvnWoPfOAO8wi0qcQ,266
11
+ fustor_common-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ fustor_common-0.1.8.dist-info/top_level.txt,sha256=eg0-4Mw7UcXKo62TbE1Mu1yU9IO9-WKR7HDmqjqH_K8,14
13
+ fustor_common-0.1.8.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ fustor_common