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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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,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
@@ -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.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,3 @@
1
+ colorlog>=6.10.1
2
+ pydantic>=2.11.7
3
+ pydantic-settings>=2.3.4
@@ -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