fustor-common 0.2.1__tar.gz
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.
- fustor_common-0.2.1/PKG-INFO +9 -0
- fustor_common-0.2.1/README.md +52 -0
- fustor_common-0.2.1/pyproject.toml +23 -0
- fustor_common-0.2.1/setup.cfg +4 -0
- fustor_common-0.2.1/src/fustor_common/__init__.py +4 -0
- fustor_common-0.2.1/src/fustor_common/daemon.py +52 -0
- fustor_common-0.2.1/src/fustor_common/daemon_launcher.py +96 -0
- fustor_common-0.2.1/src/fustor_common/enums.py +5 -0
- fustor_common-0.2.1/src/fustor_common/exceptions.py +3 -0
- fustor_common-0.2.1/src/fustor_common/logging_config.py +161 -0
- fustor_common-0.2.1/src/fustor_common/models.py +75 -0
- fustor_common-0.2.1/src/fustor_common/paths.py +13 -0
- fustor_common-0.2.1/src/fustor_common/schemas.py +9 -0
- fustor_common-0.2.1/src/fustor_common.egg-info/PKG-INFO +9 -0
- fustor_common-0.2.1/src/fustor_common.egg-info/SOURCES.txt +17 -0
- fustor_common-0.2.1/src/fustor_common.egg-info/dependency_links.txt +1 -0
- fustor_common-0.2.1/src/fustor_common.egg-info/requires.txt +3 -0
- fustor_common-0.2.1/src/fustor_common.egg-info/top_level.txt +1 -0
- fustor_common-0.2.1/tests/test_common_models.py +95 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fustor-common
|
|
3
|
+
Version: 0.2.1
|
|
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,52 @@
|
|
|
1
|
+
# fustor-common
|
|
2
|
+
|
|
3
|
+
This package provides common utilities, configurations, exceptions, and data schemas shared across various Fustor services and packages within the monorepo. Its purpose is to promote code reusability and maintain consistency throughout the Fustor ecosystem.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
* **Logging Configuration**: Standardized logging setup for all Fustor components.
|
|
8
|
+
* **Exceptions**: Custom exception classes for consistent error handling.
|
|
9
|
+
* **Schemas**: Shared Pydantic models for common data structures, ensuring data validation and interoperability.
|
|
10
|
+
* **Enums**: Enumerations for common categorical data, such as user levels.
|
|
11
|
+
|
|
12
|
+
## Contents
|
|
13
|
+
|
|
14
|
+
* `logging_config.py`: Contains the `setup_logging` function for configuring application-wide logging.
|
|
15
|
+
* `exceptions.py`: Defines `ConfigurationError` and other custom exceptions.
|
|
16
|
+
* `schemas.py`: Houses `ConfigCreateResponse` and other shared Pydantic schemas.
|
|
17
|
+
* `enums.py`: Contains `UserLevel` and other shared enumerations.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
This package is part of the Fustor monorepo and is typically installed in editable mode within the monorepo's development environment using `uv sync`.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Components from `fustor-common` are imported and utilized by other Fustor services (e.g., `agent`, `registry`, `fusion`) and plugin packages to leverage shared functionalities and maintain consistency.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from fustor_common.logging_config import setup_logging
|
|
31
|
+
from fustor_common.exceptions import ConfigurationError
|
|
32
|
+
from fustor_common.enums import UserLevel
|
|
33
|
+
|
|
34
|
+
# Setup logging
|
|
35
|
+
setup_logging()
|
|
36
|
+
|
|
37
|
+
# Raise a custom exception
|
|
38
|
+
try:
|
|
39
|
+
raise ConfigurationError("Invalid configuration setting.")
|
|
40
|
+
except ConfigurationError as e:
|
|
41
|
+
print(f"Error: {e}")
|
|
42
|
+
|
|
43
|
+
# Use an enum
|
|
44
|
+
if some_user.level == UserLevel.ADMIN:
|
|
45
|
+
print("Admin user detected.")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Dependencies
|
|
49
|
+
|
|
50
|
+
* `colorlog`: For colored and formatted console output.
|
|
51
|
+
* `pydantic`: For data validation and settings management.
|
|
52
|
+
* `pydantic-settings`: For managing application settings.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fustor-common"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Common utilities and models for Fustor services"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
dependencies = [ "colorlog>=6.10.1", "pydantic>=2.11.7", "pydantic-settings>=2.3.4",]
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = [ "setuptools>=61.0", "setuptools-scm>=8.0"]
|
|
11
|
+
build-backend = "setuptools.build_meta"
|
|
12
|
+
|
|
13
|
+
[tool.setuptools_scm]
|
|
14
|
+
root = "../.."
|
|
15
|
+
version_scheme = "post-release"
|
|
16
|
+
local_scheme = "dirty-tag"
|
|
17
|
+
|
|
18
|
+
["project.urls"]
|
|
19
|
+
Homepage = "https://github.com/excelwang/fustor/tree/master/packages/fustor_common"
|
|
20
|
+
"Bug Tracker" = "https://github.com/excelwang/fustor/issues"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = [ "src",]
|
|
@@ -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,96 @@
|
|
|
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
|
+
app_to_run = app
|
|
72
|
+
if args.reload:
|
|
73
|
+
app_to_run = f"{args.module_path}:{args.app_var}"
|
|
74
|
+
|
|
75
|
+
uvicorn.run(
|
|
76
|
+
app_to_run,
|
|
77
|
+
host=args.host,
|
|
78
|
+
port=args.port,
|
|
79
|
+
log_config=None, # Logging handled separately
|
|
80
|
+
access_log=True,
|
|
81
|
+
reload=args.reload,
|
|
82
|
+
)
|
|
83
|
+
except KeyboardInterrupt:
|
|
84
|
+
logger.info(f"{args.display_name} daemon interrupted")
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.critical(f"{args.display_name} daemon error: {e}", exc_info=True)
|
|
87
|
+
print(f"{args.display_name} daemon error: {e}")
|
|
88
|
+
finally:
|
|
89
|
+
# Clean up PID file
|
|
90
|
+
if os.path.exists(PID_FILE):
|
|
91
|
+
os.remove(PID_FILE)
|
|
92
|
+
logger.info("PID file removed")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
|
@@ -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
|
|
@@ -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
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fustor-common
|
|
3
|
+
Version: 0.2.1
|
|
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,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/fustor_common/__init__.py
|
|
4
|
+
src/fustor_common/daemon.py
|
|
5
|
+
src/fustor_common/daemon_launcher.py
|
|
6
|
+
src/fustor_common/enums.py
|
|
7
|
+
src/fustor_common/exceptions.py
|
|
8
|
+
src/fustor_common/logging_config.py
|
|
9
|
+
src/fustor_common/models.py
|
|
10
|
+
src/fustor_common/paths.py
|
|
11
|
+
src/fustor_common/schemas.py
|
|
12
|
+
src/fustor_common.egg-info/PKG-INFO
|
|
13
|
+
src/fustor_common.egg-info/SOURCES.txt
|
|
14
|
+
src/fustor_common.egg-info/dependency_links.txt
|
|
15
|
+
src/fustor_common.egg-info/requires.txt
|
|
16
|
+
src/fustor_common.egg-info/top_level.txt
|
|
17
|
+
tests/test_common_models.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fustor_common
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import ValidationError
|
|
3
|
+
from fustor_common.models import (
|
|
4
|
+
ResponseBase,
|
|
5
|
+
ApiKeyBase,
|
|
6
|
+
MessageResponse,
|
|
7
|
+
DatastoreBase,
|
|
8
|
+
TokenResponse,
|
|
9
|
+
LogoutResponse,
|
|
10
|
+
Password,
|
|
11
|
+
ValidationResponse,
|
|
12
|
+
CleanupResponse,
|
|
13
|
+
AdminCredentials,
|
|
14
|
+
DatastoreConfig,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def test_response_base():
|
|
18
|
+
response = ResponseBase()
|
|
19
|
+
assert isinstance(response, ResponseBase)
|
|
20
|
+
|
|
21
|
+
def test_api_key_base():
|
|
22
|
+
api_key = ApiKeyBase(name="test_key", datastore_id=1)
|
|
23
|
+
assert api_key.name == "test_key"
|
|
24
|
+
assert api_key.datastore_id == 1
|
|
25
|
+
with pytest.raises(ValidationError):
|
|
26
|
+
ApiKeyBase(name="a", datastore_id=1) # Too short
|
|
27
|
+
with pytest.raises(ValidationError):
|
|
28
|
+
ApiKeyBase(name="a"*51, datastore_id=1) # Too long
|
|
29
|
+
|
|
30
|
+
def test_message_response():
|
|
31
|
+
message = MessageResponse(message="Success")
|
|
32
|
+
assert message.message == "Success"
|
|
33
|
+
|
|
34
|
+
def test_datastore_base():
|
|
35
|
+
datastore = DatastoreBase(name="my_datastore")
|
|
36
|
+
assert datastore.name == "my_datastore"
|
|
37
|
+
assert datastore.visible == False
|
|
38
|
+
assert datastore.allow_concurrent_push == False
|
|
39
|
+
assert datastore.session_timeout_seconds == 30
|
|
40
|
+
|
|
41
|
+
def test_token_response():
|
|
42
|
+
token = TokenResponse(access_token="abc", token_type="bearer")
|
|
43
|
+
assert token.access_token == "abc"
|
|
44
|
+
assert token.token_type == "bearer"
|
|
45
|
+
|
|
46
|
+
def test_logout_response():
|
|
47
|
+
logout = LogoutResponse(detail="注销成功")
|
|
48
|
+
assert logout.detail == "注销成功"
|
|
49
|
+
|
|
50
|
+
def test_password_model():
|
|
51
|
+
# Valid password
|
|
52
|
+
password = Password(password="Password123!")
|
|
53
|
+
assert password.password == "Password123!"
|
|
54
|
+
|
|
55
|
+
# Invalid password - too short
|
|
56
|
+
with pytest.raises(ValidationError):
|
|
57
|
+
Password(password="Short1!")
|
|
58
|
+
|
|
59
|
+
# Invalid password - too long
|
|
60
|
+
with pytest.raises(ValidationError):
|
|
61
|
+
Password(password="a"*65)
|
|
62
|
+
|
|
63
|
+
# Invalid password - no letter
|
|
64
|
+
with pytest.raises(ValidationError, match="必须包含至少一个字母"):
|
|
65
|
+
Password(password="12345678!")
|
|
66
|
+
|
|
67
|
+
# Invalid password - no digit
|
|
68
|
+
with pytest.raises(ValidationError, match="必须包含至少一个数字"):
|
|
69
|
+
Password(password="Password!!")
|
|
70
|
+
|
|
71
|
+
# Invalid password - invalid characters
|
|
72
|
+
with pytest.raises(ValidationError):
|
|
73
|
+
Password(password="Password123^") # ^ is not in allowed pattern
|
|
74
|
+
|
|
75
|
+
def test_validation_response():
|
|
76
|
+
response = ValidationResponse(success=True, message="Valid")
|
|
77
|
+
assert response.success == True
|
|
78
|
+
assert response.message == "Valid"
|
|
79
|
+
|
|
80
|
+
def test_cleanup_response():
|
|
81
|
+
response = CleanupResponse(message="Cleaned", deleted_count=5, deleted_ids=["id1", "id2"])
|
|
82
|
+
assert response.message == "Cleaned"
|
|
83
|
+
assert response.deleted_count == 5
|
|
84
|
+
assert response.deleted_ids == ["id1", "id2"]
|
|
85
|
+
|
|
86
|
+
def test_admin_credentials():
|
|
87
|
+
creds = AdminCredentials(user="admin", passwd="password")
|
|
88
|
+
assert creds.user == "admin"
|
|
89
|
+
assert creds.passwd == "password"
|
|
90
|
+
|
|
91
|
+
def test_datastore_config():
|
|
92
|
+
config = DatastoreConfig(datastore_id=1, allow_concurrent_push=True, session_timeout_seconds=60)
|
|
93
|
+
assert config.datastore_id == 1
|
|
94
|
+
assert config.allow_concurrent_push == True
|
|
95
|
+
assert config.session_timeout_seconds == 60
|