fastlib-py 0.2.2__tar.gz → 0.2.3__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.
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/PKG-INFO +1 -1
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/utils.py +1 -1
- fastlib_py-0.2.3/fastlib/decorator/base.py +237 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/enums/__init__.py +1 -1
- fastlib_py-0.2.3/fastlib/enums/base.py +71 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/exception/__init__.py +1 -1
- fastlib_py-0.2.2/fastlib/exception/custom_exception.py → fastlib_py-0.2.3/fastlib/exception/base.py +10 -2
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/exception/exception_handler.py +8 -7
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/exception/exception_manager.py +1 -1
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/middleware/db_session.py +4 -4
- fastlib_py-0.2.3/fastlib/middleware/i18n.py +40 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/middleware/jwt.py +1 -1
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/router/__init__.py +1 -1
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/schema/__init__.py +2 -2
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/schema/response.py +2 -2
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/security/security.py +2 -24
- fastlib_py-0.2.3/fastlib/service/impl/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/session/db_engine.py +0 -4
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib_py.egg-info/PKG-INFO +1 -1
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib_py.egg-info/SOURCES.txt +6 -8
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/pyproject.toml +1 -1
- fastlib_py-0.2.2/fastlib/enums/base_error_code.py +0 -15
- fastlib_py-0.2.2/fastlib/enums/enum.py +0 -33
- fastlib_py-0.2.2/fastlib/middleware/i18n.py +0 -57
- fastlib_py-0.2.2/fastlib/middleware/log.py +0 -49
- fastlib_py-0.2.2/fastlib/model/__init__.py +0 -5
- fastlib_py-0.2.2/fastlib/model/base_model.py +0 -62
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/LICENSE +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/README.md +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/cache/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/cache/base.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/cache/disk_cache.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/cache/manager.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/cache/redis_cache.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/base.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/database_config.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/loader.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/manager.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/registry.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/security_config.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/config/server_config.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/constant/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/constant/constant.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/context/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/context/contextvars.py +0 -0
- {fastlib_py-0.2.2/fastlib/mapper → fastlib_py-0.2.3/fastlib/decorator}/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/i18n/manager.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/i18n/messages.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/i18n/types.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/i18n/utils.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/logging/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/logging/config.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/logging/handlers.py +0 -0
- {fastlib_py-0.2.2/fastlib/mapper/impl → fastlib_py-0.2.3/fastlib/mapper}/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/mapper/base_mapper.py +0 -0
- {fastlib_py-0.2.2/fastlib/service → fastlib_py-0.2.3/fastlib/mapper/impl}/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/mapper/impl/base_mapper_impl.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/middleware/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/migrate/migrate.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/openapi/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/openapi/offline.py +0 -0
- /fastlib_py-0.2.2/fastlib/router/router_v1.py → /fastlib_py-0.2.3/fastlib/router/base.py +0 -0
- /fastlib_py-0.2.2/fastlib/schema/schema.py → /fastlib_py-0.2.3/fastlib/schema/base.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/security/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/security/utils.py +0 -0
- {fastlib_py-0.2.2/fastlib/service/impl → fastlib_py-0.2.3/fastlib/service}/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/service/base_service.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/service/impl/base_service_impl.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/session/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/session/db_session.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/utils/__init__.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/utils/excel_util.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/utils/file_util.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/utils/http_util.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/utils/ip_util.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/utils/snowflake_util.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib/utils/str_util.py +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib_py.egg-info/dependency_links.txt +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib_py.egg-info/requires.txt +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/fastlib_py.egg-info/top_level.txt +0 -0
- {fastlib_py-0.2.2 → fastlib_py-0.2.3}/setup.cfg +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Commonly used decorator modules"""
|
|
3
|
+
import asyncio
|
|
4
|
+
from functools import wraps
|
|
5
|
+
import functools
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable, Optional
|
|
10
|
+
from loguru import Logger
|
|
11
|
+
|
|
12
|
+
from fastlib.cache.manager import get_cache_client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log_request_time(logger_name: Optional[str] = None):
|
|
16
|
+
"""
|
|
17
|
+
Decorator to log API request execution time.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
logger_name: Name of the logger instance to use. If None, uses default logger.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Decorator function
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def decorator(func: Callable):
|
|
27
|
+
@wraps(func)
|
|
28
|
+
async def async_wrapper(*args, **kwargs):
|
|
29
|
+
return await _time_request_async(func, logger_name, *args, **kwargs)
|
|
30
|
+
|
|
31
|
+
@wraps(func)
|
|
32
|
+
def sync_wrapper(*args, **kwargs):
|
|
33
|
+
return _time_request_sync(func, logger_name, *args, **kwargs)
|
|
34
|
+
|
|
35
|
+
# Return appropriate wrapper based on function type
|
|
36
|
+
if asyncio.iscoroutinefunction(func):
|
|
37
|
+
return async_wrapper
|
|
38
|
+
return sync_wrapper
|
|
39
|
+
|
|
40
|
+
return decorator
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _time_request_async(func: Callable, logger_name: str, *args, **kwargs) -> Any:
|
|
44
|
+
"""
|
|
45
|
+
Internal function to time and log async request execution.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
func: The decorated function
|
|
49
|
+
logger_name: Logger instance name
|
|
50
|
+
*args: Function arguments
|
|
51
|
+
**kwargs: Function keyword arguments
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Result of the function execution
|
|
55
|
+
"""
|
|
56
|
+
# Get logger instance
|
|
57
|
+
logger_instance = Logger.get_instance(logger_name)
|
|
58
|
+
|
|
59
|
+
# Record start time
|
|
60
|
+
start_time = time.time()
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Execute the function
|
|
64
|
+
result = await func(*args, **kwargs)
|
|
65
|
+
|
|
66
|
+
# Calculate execution time
|
|
67
|
+
execution_time = time.time() - start_time
|
|
68
|
+
|
|
69
|
+
# Log success
|
|
70
|
+
logger_instance.info(
|
|
71
|
+
f"API endpoint {func.__name__} executed successfully",
|
|
72
|
+
execution_time_ms=round(execution_time * 1000, 2),
|
|
73
|
+
status="success",
|
|
74
|
+
endpoint=func.__name__,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
except Exception as e:
|
|
80
|
+
# Calculate execution time (even if error occurred)
|
|
81
|
+
execution_time = time.time() - start_time
|
|
82
|
+
|
|
83
|
+
# Log error
|
|
84
|
+
logger_instance.error(
|
|
85
|
+
f"API endpoint {func.__name__} execution failed: {str(e)}",
|
|
86
|
+
execution_time_ms=round(execution_time * 1000, 2),
|
|
87
|
+
status="error",
|
|
88
|
+
endpoint=func.__name__,
|
|
89
|
+
exc_info=e,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Re-raise the exception
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _time_request_sync(func: Callable, logger_name: str, *args, **kwargs) -> Any:
|
|
97
|
+
"""
|
|
98
|
+
Internal function to time and log synchronous request execution.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
func: The decorated function
|
|
102
|
+
logger_name: Logger instance name
|
|
103
|
+
*args: Function arguments
|
|
104
|
+
**kwargs: Function keyword arguments
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Result of the function execution
|
|
108
|
+
"""
|
|
109
|
+
# Get logger instance
|
|
110
|
+
logger_instance = Logger.get_instance(logger_name)
|
|
111
|
+
|
|
112
|
+
# Record start time
|
|
113
|
+
start_time = time.time()
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Execute the function
|
|
117
|
+
result = func(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
# Calculate execution time
|
|
120
|
+
execution_time = time.time() - start_time
|
|
121
|
+
|
|
122
|
+
# Log success
|
|
123
|
+
logger_instance.info(
|
|
124
|
+
f"API endpoint {func.__name__} executed successfully",
|
|
125
|
+
execution_time_ms=round(execution_time * 1000, 2),
|
|
126
|
+
status="success",
|
|
127
|
+
endpoint=func.__name__,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
except Exception as e:
|
|
133
|
+
# Calculate execution time (even if error occurred)
|
|
134
|
+
execution_time = time.time() - start_time
|
|
135
|
+
|
|
136
|
+
# Log error
|
|
137
|
+
logger_instance.error(
|
|
138
|
+
f"API endpoint {func.__name__} execution failed: {str(e)}",
|
|
139
|
+
execution_time_ms=round(execution_time * 1000, 2),
|
|
140
|
+
status="error",
|
|
141
|
+
endpoint=func.__name__,
|
|
142
|
+
exc_info=e,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Re-raise the exception
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def prevent_duplicate_submission(
|
|
150
|
+
key_prefix: str = "duplicate_submission",
|
|
151
|
+
ttl: int = 60,
|
|
152
|
+
include_args: bool = True,
|
|
153
|
+
include_kwargs: bool = True,
|
|
154
|
+
custom_key_func: Optional[Callable] = None,
|
|
155
|
+
):
|
|
156
|
+
"""Decorator to prevent duplicate submissions within a specified time window.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
key_prefix: Prefix for the cache key
|
|
160
|
+
ttl: Time to live in seconds for the duplicate check
|
|
161
|
+
include_args: Whether to include function args in the cache key
|
|
162
|
+
include_kwargs: Whether to include function kwargs in the cache key
|
|
163
|
+
custom_key_func: Optional custom function to generate cache key
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Decorated function that prevents duplicate submissions
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def decorator(func: Callable) -> Callable:
|
|
170
|
+
@functools.wraps(func)
|
|
171
|
+
async def async_wrapper(*args, **kwargs):
|
|
172
|
+
# Generate cache key
|
|
173
|
+
if custom_key_func:
|
|
174
|
+
cache_key = custom_key_func(*args, **kwargs)
|
|
175
|
+
else:
|
|
176
|
+
key_parts = [key_prefix, func.__name__]
|
|
177
|
+
|
|
178
|
+
if include_args and args:
|
|
179
|
+
# Skip 'self' for instance methods
|
|
180
|
+
args_to_hash = (
|
|
181
|
+
args[1:] if args and hasattr(args[0], "__class__") else args
|
|
182
|
+
)
|
|
183
|
+
key_parts.append(str(args_to_hash))
|
|
184
|
+
|
|
185
|
+
if include_kwargs and kwargs:
|
|
186
|
+
# Sort kwargs for consistent key generation
|
|
187
|
+
sorted_kwargs = json.dumps(kwargs, sort_keys=True)
|
|
188
|
+
key_parts.append(sorted_kwargs)
|
|
189
|
+
|
|
190
|
+
# Create hash of the key parts for a shorter key
|
|
191
|
+
key_content = ":".join(key_parts)
|
|
192
|
+
cache_key = (
|
|
193
|
+
f"{key_prefix}:{hashlib.md5(key_content.encode()).hexdigest()}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Get cache client
|
|
197
|
+
cache = await get_cache_client()
|
|
198
|
+
|
|
199
|
+
# Check if request is duplicate
|
|
200
|
+
existing = await cache.get(cache_key)
|
|
201
|
+
if existing is not None:
|
|
202
|
+
raise DuplicateSubmissionError(
|
|
203
|
+
f"Duplicate submission detected. Please wait {ttl} seconds before retrying."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Set lock in cache
|
|
207
|
+
await cache.set(cache_key, "locked", ttl=ttl)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
# Execute the original function
|
|
211
|
+
result = await func(*args, **kwargs)
|
|
212
|
+
return result
|
|
213
|
+
except Exception as e:
|
|
214
|
+
# On error, remove the lock to allow retry
|
|
215
|
+
await cache.delete(cache_key)
|
|
216
|
+
raise e
|
|
217
|
+
|
|
218
|
+
@functools.wraps(func)
|
|
219
|
+
def sync_wrapper(*args, **kwargs):
|
|
220
|
+
raise NotImplementedError(
|
|
221
|
+
"Synchronous functions are not supported. "
|
|
222
|
+
"Please use async functions with prevent_duplicate_submission decorator."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Return appropriate wrapper based on function type
|
|
226
|
+
if asyncio.iscoroutinefunction(func):
|
|
227
|
+
return async_wrapper
|
|
228
|
+
else:
|
|
229
|
+
return sync_wrapper
|
|
230
|
+
|
|
231
|
+
return decorator
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class DuplicateSubmissionError(Exception):
|
|
235
|
+
"""Exception raised when a duplicate submission is detected."""
|
|
236
|
+
|
|
237
|
+
pass
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Enumerations module. Contains commonly used enum types for the application."""
|
|
3
|
+
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SortEnum(str, Enum):
|
|
8
|
+
"""Enumeration for sorting directions."""
|
|
9
|
+
|
|
10
|
+
ascending = "asc"
|
|
11
|
+
descending = "desc"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DBTypeEnum(str, Enum):
|
|
15
|
+
"""Enumeration for supported database types."""
|
|
16
|
+
|
|
17
|
+
PGSQL = "postgresql"
|
|
18
|
+
MYSQL = "mysql"
|
|
19
|
+
SQLITE = "sqlite"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MediaTypeEnum(str, Enum):
|
|
23
|
+
"""Enumeration for media/content types."""
|
|
24
|
+
|
|
25
|
+
# === Text Formats ===
|
|
26
|
+
JSON = "application/json"
|
|
27
|
+
JSON_PATCH = "application/json-patch+json"
|
|
28
|
+
XML = "application/xml"
|
|
29
|
+
HTML = "text/html"
|
|
30
|
+
TEXT = "text/plain"
|
|
31
|
+
CSS = "text/css"
|
|
32
|
+
JAVASCRIPT = "application/javascript"
|
|
33
|
+
CSV = "text/csv"
|
|
34
|
+
YAML = "application/yaml"
|
|
35
|
+
YML = "application/yaml" # Alias
|
|
36
|
+
|
|
37
|
+
# === Form Data ===
|
|
38
|
+
FORM_URL_ENCODED = "application/x-www-form-urlencoded"
|
|
39
|
+
MULTIPART_FORM = "multipart/form-data"
|
|
40
|
+
|
|
41
|
+
# === Binary/Data Formats ===
|
|
42
|
+
PDF = "application/pdf"
|
|
43
|
+
ZIP = "application/zip"
|
|
44
|
+
GZIP = "application/gzip"
|
|
45
|
+
OCTET_STREAM = "application/octet-stream"
|
|
46
|
+
|
|
47
|
+
# === Images ===
|
|
48
|
+
PNG = "image/png"
|
|
49
|
+
JPEG = "image/jpeg"
|
|
50
|
+
JPG = "image/jpeg" # Alias
|
|
51
|
+
GIF = "image/gif"
|
|
52
|
+
WEBP = "image/webp"
|
|
53
|
+
SVG = "image/svg+xml"
|
|
54
|
+
|
|
55
|
+
# === Audio ===
|
|
56
|
+
MP3 = "audio/mpeg"
|
|
57
|
+
WAV = "audio/wav"
|
|
58
|
+
WEBM_AUDIO = "audio/webm"
|
|
59
|
+
|
|
60
|
+
# === Video ===
|
|
61
|
+
MP4 = "video/mp4"
|
|
62
|
+
WEBM_VIDEO = "video/webm"
|
|
63
|
+
MPEG = "video/mpeg"
|
|
64
|
+
|
|
65
|
+
# === Protobuf & Other Binary Formats ===
|
|
66
|
+
PROTOBUF = "application/protobuf"
|
|
67
|
+
MSGPACK = "application/msgpack"
|
|
68
|
+
CBOR = "application/cbor"
|
|
69
|
+
|
|
70
|
+
def __str__(self) -> str:
|
|
71
|
+
return self.value
|
fastlib_py-0.2.2/fastlib/exception/custom_exception.py → fastlib_py-0.2.3/fastlib/exception/base.py
RENAMED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
from typing import Any, Optional
|
|
5
5
|
|
|
6
|
-
from
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ErrorDetail(BaseModel):
|
|
10
|
+
"""Base class for error code.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
code: int
|
|
14
|
+
message: str
|
|
7
15
|
|
|
8
16
|
|
|
9
17
|
class HTTPException(Exception):
|
|
@@ -18,7 +26,7 @@ class HTTPException(Exception):
|
|
|
18
26
|
|
|
19
27
|
def __init__(
|
|
20
28
|
self,
|
|
21
|
-
code:
|
|
29
|
+
code: ErrorDetail,
|
|
22
30
|
message: Optional[str] = None,
|
|
23
31
|
details: Optional[Any] = None,
|
|
24
32
|
):
|
|
@@ -16,7 +16,6 @@ from pydantic_core._pydantic_core import ValidationError # noqa
|
|
|
16
16
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
17
17
|
|
|
18
18
|
from fastlib import ConfigManager
|
|
19
|
-
from fastlib.enums.enum import CommonErrorCode
|
|
20
19
|
from fastlib.exception import HTTPException
|
|
21
20
|
|
|
22
21
|
|
|
@@ -69,9 +68,9 @@ def collect_request_info(request: Request) -> dict[str, Any]:
|
|
|
69
68
|
"method": request.method,
|
|
70
69
|
"query_params": dict(request.query_params),
|
|
71
70
|
"headers": dict(request.headers),
|
|
72
|
-
"client":
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
"client": (
|
|
72
|
+
f"{request.client.host}:{request.client.port}" if request.client else None
|
|
73
|
+
),
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
|
|
@@ -80,13 +79,15 @@ def log_exception(exc: Exception, request_info: dict[str, Any]) -> None:
|
|
|
80
79
|
Log exception with full context information.
|
|
81
80
|
"""
|
|
82
81
|
logger.error(
|
|
83
|
-
textwrap.dedent(
|
|
82
|
+
textwrap.dedent(
|
|
83
|
+
f"""\
|
|
84
84
|
Unhandled exception,
|
|
85
85
|
exception_type: {type(exc).__name__},
|
|
86
86
|
exception_message: {str(exc)},
|
|
87
87
|
traceback: {traceback.format_exc()},
|
|
88
88
|
request: {request_info},
|
|
89
|
-
"""
|
|
89
|
+
"""
|
|
90
|
+
)
|
|
90
91
|
)
|
|
91
92
|
|
|
92
93
|
|
|
@@ -109,7 +110,7 @@ def build_error_response(
|
|
|
109
110
|
return JSONResponse(
|
|
110
111
|
content={
|
|
111
112
|
"error": {
|
|
112
|
-
"code":
|
|
113
|
+
"code": -1,
|
|
113
114
|
"message": error_message,
|
|
114
115
|
}
|
|
115
116
|
}
|
|
@@ -8,7 +8,7 @@ from pydantic_core._pydantic_core import ValidationError # noqa
|
|
|
8
8
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
9
9
|
|
|
10
10
|
from fastlib.exception import exception_handler
|
|
11
|
-
from fastlib.exception.
|
|
11
|
+
from fastlib.exception.base import HTTPException
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def register_exception_handlers(app: FastAPI) -> None:
|
|
@@ -45,7 +45,7 @@ def create_middleware_and_session_proxy():
|
|
|
45
45
|
session_args = session_args or {}
|
|
46
46
|
|
|
47
47
|
if not custom_engine and not db_url:
|
|
48
|
-
raise
|
|
48
|
+
raise RuntimeError(
|
|
49
49
|
"You need to pass a db_url or a custom_engine parameter."
|
|
50
50
|
)
|
|
51
51
|
if not custom_engine:
|
|
@@ -73,11 +73,11 @@ def create_middleware_and_session_proxy():
|
|
|
73
73
|
def session(self) -> AsyncSession:
|
|
74
74
|
"""Get the current async context session."""
|
|
75
75
|
if _Session is None:
|
|
76
|
-
raise
|
|
76
|
+
raise RuntimeError("Session is not initialised")
|
|
77
77
|
|
|
78
78
|
session = _session.get()
|
|
79
79
|
if session is None:
|
|
80
|
-
raise
|
|
80
|
+
raise RuntimeError("Session is not initialised")
|
|
81
81
|
|
|
82
82
|
return session
|
|
83
83
|
|
|
@@ -93,7 +93,7 @@ def create_middleware_and_session_proxy():
|
|
|
93
93
|
async def __aenter__(self):
|
|
94
94
|
"""Enter session context."""
|
|
95
95
|
if _Session is None:
|
|
96
|
-
raise
|
|
96
|
+
raise RuntimeError("Session is not initialised")
|
|
97
97
|
|
|
98
98
|
self.token = _session.set(_Session(**self.session_args)) # type: ignore
|
|
99
99
|
return type(self)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
3
|
+
|
|
4
|
+
from fastlib.i18n.manager import reset_language, set_language
|
|
5
|
+
from fastlib.i18n.types import Language
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class I18nMiddleware(BaseHTTPMiddleware):
|
|
9
|
+
"""
|
|
10
|
+
Middleware for automatic language detection and setting based on HTTP headers.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
async def dispatch(self, request, call_next):
|
|
14
|
+
"""
|
|
15
|
+
Process the request and set the language based on Accept-Language header.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
request: The incoming HTTP request
|
|
19
|
+
call_next: The next middleware or route handler in the chain
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The HTTP response from the next handler
|
|
23
|
+
"""
|
|
24
|
+
# Get language preference from request headers
|
|
25
|
+
accept_lang = request.headers.get("Accept-Language", "en")
|
|
26
|
+
|
|
27
|
+
# Set the language for current request
|
|
28
|
+
if accept_lang.startswith("en"):
|
|
29
|
+
language = Language.CHINESE
|
|
30
|
+
elif accept_lang.startswith("zh"):
|
|
31
|
+
language = Language.CHINESE
|
|
32
|
+
|
|
33
|
+
set_language(language)
|
|
34
|
+
try:
|
|
35
|
+
response = await call_next(request)
|
|
36
|
+
finally:
|
|
37
|
+
# Always reset to prevent leaking context between requests
|
|
38
|
+
reset_language()
|
|
39
|
+
|
|
40
|
+
return response
|
|
@@ -11,7 +11,7 @@ from starlette.responses import JSONResponse
|
|
|
11
11
|
|
|
12
12
|
from fastlib import ConfigManager, constant, security
|
|
13
13
|
from fastlib.context.contextvars import current_user_id
|
|
14
|
-
from fastlib.enums.
|
|
14
|
+
from fastlib.enums.base import MediaTypeEnum
|
|
15
15
|
from fastlib.schema import UserCredential
|
|
16
16
|
|
|
17
17
|
# Load configuration
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""Export the core schemas' symbols."""
|
|
2
2
|
|
|
3
|
-
from .
|
|
4
|
-
from .schema import (
|
|
3
|
+
from .base import (
|
|
5
4
|
CurrentUser,
|
|
6
5
|
ListResult,
|
|
7
6
|
PaginationRequest,
|
|
8
7
|
SortItem,
|
|
9
8
|
UserCredential,
|
|
10
9
|
)
|
|
10
|
+
from .response import HttpResponse
|
|
11
11
|
|
|
12
12
|
__all__ = [
|
|
13
13
|
HttpResponse,
|
|
@@ -5,7 +5,7 @@ from typing import Any, Generic, Optional, TypeVar, Union
|
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, model_serializer
|
|
7
7
|
|
|
8
|
-
from fastlib.
|
|
8
|
+
from fastlib.exception.base import ErrorDetail
|
|
9
9
|
|
|
10
10
|
# Define generic type variables for response data
|
|
11
11
|
DataType = TypeVar("DataType")
|
|
@@ -77,7 +77,7 @@ class HttpResponse(BaseModel, Generic[T]):
|
|
|
77
77
|
|
|
78
78
|
@staticmethod
|
|
79
79
|
def fail_with_error(
|
|
80
|
-
error: Union[
|
|
80
|
+
error: Union[ErrorDetail, tuple[int, str]], # Supports multiple error types
|
|
81
81
|
extra_msg: Optional[str] = None,
|
|
82
82
|
) -> "HttpResponse[Any]":
|
|
83
83
|
"""Constructs an error response from various error type inputs.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
from datetime import datetime, timedelta
|
|
3
|
-
from functools import wraps
|
|
4
3
|
from typing import Any, Callable, Optional, Union
|
|
5
4
|
|
|
6
5
|
import jwt
|
|
@@ -13,8 +12,8 @@ from fastlib.config.manager import ConfigManager
|
|
|
13
12
|
from fastlib.schema import CurrentUser
|
|
14
13
|
|
|
15
14
|
# Configuration
|
|
16
|
-
security_config = ConfigManager.get_security_config
|
|
17
|
-
server_config = ConfigManager.get_server_config
|
|
15
|
+
security_config = ConfigManager.get_security_config()
|
|
16
|
+
server_config = ConfigManager.get_server_config()
|
|
18
17
|
|
|
19
18
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
20
19
|
|
|
@@ -177,24 +176,3 @@ def get_user_id(token: str) -> int:
|
|
|
177
176
|
"""
|
|
178
177
|
payload = decode_jwt_token(token)
|
|
179
178
|
return int(payload["sub"])
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def role_required(required_role: str):
|
|
183
|
-
def decorator(func):
|
|
184
|
-
@wraps(func)
|
|
185
|
-
async def wrapper(current_user: CurrentUser, *args, **kwargs):
|
|
186
|
-
# Get the current user
|
|
187
|
-
pass
|
|
188
|
-
# user_id = current_user.user_id
|
|
189
|
-
#
|
|
190
|
-
# # Get user role info or permission info
|
|
191
|
-
# if user.get("role") != required_role:
|
|
192
|
-
# raise HTTPException(
|
|
193
|
-
# status_code=status.HTTP_403_FORBIDDEN,
|
|
194
|
-
# detail=f"Requires {required_role} role"
|
|
195
|
-
# )
|
|
196
|
-
# return await func(request, *args, **kwargs)
|
|
197
|
-
|
|
198
|
-
return wrapper
|
|
199
|
-
|
|
200
|
-
return decorator
|
|
File without changes
|
|
@@ -20,11 +20,12 @@ fastlib/constant/__init__.py
|
|
|
20
20
|
fastlib/constant/constant.py
|
|
21
21
|
fastlib/context/__init__.py
|
|
22
22
|
fastlib/context/contextvars.py
|
|
23
|
+
fastlib/decorator/__init__.py
|
|
24
|
+
fastlib/decorator/base.py
|
|
23
25
|
fastlib/enums/__init__.py
|
|
24
|
-
fastlib/enums/
|
|
25
|
-
fastlib/enums/enum.py
|
|
26
|
+
fastlib/enums/base.py
|
|
26
27
|
fastlib/exception/__init__.py
|
|
27
|
-
fastlib/exception/
|
|
28
|
+
fastlib/exception/base.py
|
|
28
29
|
fastlib/exception/exception_handler.py
|
|
29
30
|
fastlib/exception/exception_manager.py
|
|
30
31
|
fastlib/i18n/manager.py
|
|
@@ -42,17 +43,14 @@ fastlib/middleware/__init__.py
|
|
|
42
43
|
fastlib/middleware/db_session.py
|
|
43
44
|
fastlib/middleware/i18n.py
|
|
44
45
|
fastlib/middleware/jwt.py
|
|
45
|
-
fastlib/middleware/log.py
|
|
46
46
|
fastlib/migrate/migrate.py
|
|
47
|
-
fastlib/model/__init__.py
|
|
48
|
-
fastlib/model/base_model.py
|
|
49
47
|
fastlib/openapi/__init__.py
|
|
50
48
|
fastlib/openapi/offline.py
|
|
51
49
|
fastlib/router/__init__.py
|
|
52
|
-
fastlib/router/
|
|
50
|
+
fastlib/router/base.py
|
|
53
51
|
fastlib/schema/__init__.py
|
|
52
|
+
fastlib/schema/base.py
|
|
54
53
|
fastlib/schema/response.py
|
|
55
|
-
fastlib/schema/schema.py
|
|
56
54
|
fastlib/security/__init__.py
|
|
57
55
|
fastlib/security/security.py
|
|
58
56
|
fastlib/security/utils.py
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: MIT
|
|
2
|
-
"""Base error code enumerations for API responses."""
|
|
3
|
-
|
|
4
|
-
from pydantic import BaseModel
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class ExceptionCode(BaseModel):
|
|
8
|
-
"""Base class for error code enumerations.
|
|
9
|
-
|
|
10
|
-
Provides common interface for all error codes where each enum member is defined as a tuple of
|
|
11
|
-
(code, message). This enables consistent error handling across the codebase.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
code: int
|
|
15
|
-
message: str
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: MIT
|
|
2
|
-
"""Enumerations module. Contains commonly used enum types for the application."""
|
|
3
|
-
|
|
4
|
-
from enum import Enum
|
|
5
|
-
|
|
6
|
-
from fastlib.enums import ExceptionCode
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class SortEnum(str, Enum):
|
|
10
|
-
"""Enumeration for sorting directions."""
|
|
11
|
-
|
|
12
|
-
ascending = "asc"
|
|
13
|
-
descending = "desc"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class DBTypeEnum(str, Enum):
|
|
17
|
-
"""Enumeration for supported database types."""
|
|
18
|
-
|
|
19
|
-
PGSQL = "postgresql"
|
|
20
|
-
MYSQL = "mysql"
|
|
21
|
-
SQLITE = "sqlite"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class MediaTypeEnum(str, Enum):
|
|
25
|
-
"""Enumeration for media/content types."""
|
|
26
|
-
|
|
27
|
-
JSON = ".json"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class CommonErrorCode:
|
|
31
|
-
"""Error codes for core domain."""
|
|
32
|
-
|
|
33
|
-
INTERNAL_SERVER_ERROR = ExceptionCode(code=-1, message="Internal server exception")
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: MIT
|
|
2
|
-
from starlette.middleware.base import BaseHTTPMiddleware
|
|
3
|
-
|
|
4
|
-
from fastlib.i18n.types import Language
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class I18nMiddleware(BaseHTTPMiddleware):
|
|
8
|
-
"""
|
|
9
|
-
Middleware for automatic language detection and setting based on HTTP headers.
|
|
10
|
-
|
|
11
|
-
This middleware inspects the Accept-Language header from incoming requests
|
|
12
|
-
and sets the appropriate language in the request state for use throughout
|
|
13
|
-
the application.
|
|
14
|
-
|
|
15
|
-
The language preference is stored in request.state.language and can be
|
|
16
|
-
accessed in route handlers and other middleware.
|
|
17
|
-
|
|
18
|
-
Example:
|
|
19
|
-
```python
|
|
20
|
-
from fastapi import FastAPI, Request
|
|
21
|
-
|
|
22
|
-
app = FastAPI()
|
|
23
|
-
app.add_middleware(I18nMiddleware)
|
|
24
|
-
|
|
25
|
-
@app.get("/")
|
|
26
|
-
async def root(request: Request):
|
|
27
|
-
language = request.state.language
|
|
28
|
-
return {"language": language.value}
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Note:
|
|
32
|
-
Currently supports only English and Chinese. Defaults to English
|
|
33
|
-
if the Accept-Language header is not present or unrecognized.
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
async def dispatch(self, request, call_next):
|
|
37
|
-
"""
|
|
38
|
-
Process the request and set the language based on Accept-Language header.
|
|
39
|
-
|
|
40
|
-
Args:
|
|
41
|
-
request: The incoming HTTP request
|
|
42
|
-
call_next: The next middleware or route handler in the chain
|
|
43
|
-
|
|
44
|
-
Returns:
|
|
45
|
-
The HTTP response from the next handler
|
|
46
|
-
"""
|
|
47
|
-
# Get language preference from request headers
|
|
48
|
-
accept_lang = request.headers.get("Accept-Language", "en")
|
|
49
|
-
|
|
50
|
-
# Set the language for current request
|
|
51
|
-
if accept_lang.startswith("en"):
|
|
52
|
-
request.state.language = Language.ENGLISH
|
|
53
|
-
else:
|
|
54
|
-
request.state.language = Language.CHINESE
|
|
55
|
-
|
|
56
|
-
response = await call_next(request)
|
|
57
|
-
return response
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: MIT
|
|
2
|
-
"""FastAPI Request Logging Middleware"""
|
|
3
|
-
|
|
4
|
-
import time
|
|
5
|
-
import uuid
|
|
6
|
-
|
|
7
|
-
from fastapi import Request
|
|
8
|
-
from loguru import logger
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
async def log_requests(request: Request, call_next):
|
|
12
|
-
"""Middleware to log all incoming requests and responses"""
|
|
13
|
-
# Generate unique request ID
|
|
14
|
-
request_id = str(uuid.uuid4())
|
|
15
|
-
request.state.request_id = request_id
|
|
16
|
-
|
|
17
|
-
# Log request start information
|
|
18
|
-
start_time = time.time()
|
|
19
|
-
|
|
20
|
-
# Request started log
|
|
21
|
-
logger.info(
|
|
22
|
-
f"Request started - {request.method} {request.url.path} "
|
|
23
|
-
f"(ID: {request_id}, IP: {request.client.host if request.client else None}, "
|
|
24
|
-
f"UA: {request.headers.get('user-agent')})"
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
try:
|
|
28
|
-
response = await call_next(request)
|
|
29
|
-
except Exception as exc:
|
|
30
|
-
# Log exception information
|
|
31
|
-
logger.error(
|
|
32
|
-
f"Request failed - {request.method} {request.url.path} (ID: {request_id}, Error: {str(exc)})",
|
|
33
|
-
exc_info=True,
|
|
34
|
-
)
|
|
35
|
-
raise
|
|
36
|
-
|
|
37
|
-
# Calculate processing time
|
|
38
|
-
process_time = round((time.time() - start_time) * 1000, 2)
|
|
39
|
-
|
|
40
|
-
# Request completed log
|
|
41
|
-
logger.info(
|
|
42
|
-
f"Request completed - {request.method} {request.url.path} "
|
|
43
|
-
f"(ID: {request_id}, Status: {response.status_code}, "
|
|
44
|
-
f"Time: {process_time}ms)"
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
# Add request ID to response headers
|
|
48
|
-
response.headers["X-Request-ID"] = request_id
|
|
49
|
-
return response
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
# SPDX-License-Identifier: MIT
|
|
2
|
-
"""Database Model Base Classes"""
|
|
3
|
-
|
|
4
|
-
from datetime import datetime
|
|
5
|
-
from typing import Optional
|
|
6
|
-
|
|
7
|
-
from sqlalchemy import BigInteger, DateTime, Integer
|
|
8
|
-
from sqlmodel import Field
|
|
9
|
-
from sqlmodel import SQLModel as _SQLModel
|
|
10
|
-
|
|
11
|
-
from fastlib.utils.snowflake_util import snowflake_id
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class BaseModel(_SQLModel):
|
|
15
|
-
"""
|
|
16
|
-
Identifier for a model
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
id: int = Field(
|
|
20
|
-
default_factory=snowflake_id,
|
|
21
|
-
primary_key=True,
|
|
22
|
-
sa_type=BigInteger,
|
|
23
|
-
sa_column_kwargs={"comment": "主键"},
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class TreeModel(BaseModel):
|
|
28
|
-
"""
|
|
29
|
-
Identifier for a model
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
parent_id: int = Field(
|
|
33
|
-
index=True,
|
|
34
|
-
sa_type=BigInteger,
|
|
35
|
-
sa_column_kwargs={"comment": "父id"},
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
sort: int = Field(
|
|
39
|
-
index=True,
|
|
40
|
-
sa_type=Integer,
|
|
41
|
-
sa_column_kwargs={"comment": "排序"},
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class ModelExt(_SQLModel):
|
|
46
|
-
"""
|
|
47
|
-
Create time and update time for a model, can be automatically generated
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
create_at: Optional[datetime] = Field(
|
|
51
|
-
sa_type=DateTime,
|
|
52
|
-
default_factory=datetime.utcnow,
|
|
53
|
-
sa_column_kwargs={"comment": "创建时间"},
|
|
54
|
-
)
|
|
55
|
-
update_at: Optional[datetime] = Field(
|
|
56
|
-
sa_type=DateTime,
|
|
57
|
-
default_factory=datetime.utcnow,
|
|
58
|
-
sa_column_kwargs={
|
|
59
|
-
"onupdate": datetime.utcnow,
|
|
60
|
-
"comment": "更新时间",
|
|
61
|
-
},
|
|
62
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|