imgdude 0.1.0__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.
imgdude-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ImgDude Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ include LICENSE
2
+ include README.md
3
+ include requirements.txt
4
+ include requirements-dev.txt
5
+ include pyproject.toml
6
+ graft imgdude
7
+ global-exclude __pycache__
8
+ global-exclude *.py[cod]
9
+ global-exclude *.so
10
+ global-exclude .git*
11
+ global-exclude .pytest_cache
12
+ global-exclude .coverage
13
+ global-exclude htmlcov
14
+ global-exclude .DS_Store
imgdude-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: imgdude
3
+ Version: 0.1.0
4
+ Summary: Fast image resizing proxy for Nginx with FastAPI
5
+ Author-email: ASafarzadeh <includesafarof@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ASafarzadeh/imgdude
8
+ Project-URL: Documentation, https://github.com/ASafarzadeh/imgdude#readme
9
+ Project-URL: Bug Tracker, https://github.com/ASafarzadeh/imgdude/issues
10
+ Project-URL: Source, https://github.com/ASafarzadeh/imgdude
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Multimedia :: Graphics
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: fastapi>=0.100.0
26
+ Requires-Dist: pillow>=9.0.0
27
+ Requires-Dist: uvicorn[standard]>=0.23.0
28
+ Requires-Dist: aiofiles>=23.0.0
29
+ Requires-Dist: python-multipart>=0.0.5
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
32
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
33
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
34
+ Requires-Dist: black>=23.0.0; extra == "dev"
35
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
36
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
37
+ Requires-Dist: httpx>=0.24.0; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # ImgDude
41
+
42
+ [![PyPI version](https://badge.fury.io/py/imgdude.svg)](https://badge.fury.io/py/imgdude)
43
+ [![Python Version](https://img.shields.io/pypi/pyversions/imgdude.svg)](https://pypi.org/project/imgdude/)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
45
+
46
+ A high-performance image resizing proxy for Nginx, built with FastAPI. ImgDude provides on-the-fly image resizing with efficient caching capabilities.
47
+
48
+ ## Features
49
+
50
+ - Dynamic image resizing with aspect ratio preservation
51
+ - Intelligent caching system for optimized performance
52
+ - Seamless Nginx integration
53
+ - Built on FastAPI with async I/O support
54
+ - Simple URL-based resizing API
55
+ - Comprehensive security measures
56
+ - Performance optimizations for production use
57
+
58
+ ## Installation
59
+
60
+ ### PyPI
61
+
62
+ ```bash
63
+ pip install imgdude
64
+ ```
65
+
66
+ ### Source
67
+
68
+ ```bash
69
+ git clone https://github.com/ASafarzadeh/imgdude.git
70
+ cd imgdude
71
+ pip install -e .
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ### Basic Server
77
+
78
+ ```bash
79
+ # Default configuration (127.0.0.1:12312)
80
+ imgdude
81
+
82
+ # Custom configuration
83
+ imgdude --host 0.0.0.0 --port 8080 --media-root /path/to/images --cache-dir /path/to/cache
84
+ ```
85
+
86
+ ### Docker Deployment
87
+
88
+ 1. Build and run using Docker Compose:
89
+
90
+ ```bash
91
+ # Update volume paths in docker-compose.yml
92
+ docker-compose up -d
93
+ ```
94
+
95
+ 2. Or build and run manually:
96
+
97
+ ```bash
98
+ # Build the image
99
+ docker build -t imgdude .
100
+
101
+ # Run the container
102
+ docker run -d \
103
+ -p 12312:12312 \
104
+ -v /path/to/images:/app/media \
105
+ -v /path/to/cache:/app/cache \
106
+ imgdude
107
+ ```
108
+
109
+ ### Nginx Integration
110
+
111
+ Add to your Nginx configuration:
112
+
113
+ ```nginx
114
+ location /images/ {
115
+ proxy_pass http://127.0.0.1:12312/image/;
116
+ proxy_set_header Host $host;
117
+ proxy_set_header X-Real-IP $remote_addr;
118
+ proxy_cache_valid 200 7d;
119
+ expires max;
120
+ }
121
+ ```
122
+
123
+ ### Image Resizing
124
+
125
+ Resize images by adding the width parameter:
126
+
127
+ ```
128
+ http://yourdomain.com/images/path/to/image.jpg?w=300
129
+ ```
130
+
131
+ ## Configuration
132
+
133
+ | Setting | Environment Variable | Default | Description |
134
+ | --------------- | ----------------------- | --------- | ------------------------ |
135
+ | Host | - | 127.0.0.1 | Server host |
136
+ | Port | - | 12312 | Server port |
137
+ | Media Root | IMGDUDE_MEDIA_ROOT | ./media | Media directory |
138
+ | Cache Directory | IMGDUDE_CACHE_DIR | ./cache | Cache directory |
139
+ | Cache Max Age | IMGDUDE_CACHE_MAX_AGE | 604800 | Cache duration (seconds) |
140
+ | Max Width | IMGDUDE_MAX_WIDTH | 2000 | Maximum image width |
141
+ | Trusted Hosts | IMGDUDE_TRUSTED_HOSTS | \* | Allowed IP addresses |
142
+ | Image Workers | IMGDUDE_IMAGE_WORKERS | CPU-based | Image processing threads |
143
+ | I/O Workers | IMGDUDE_IO_WORKERS | CPU-based | File I/O threads |
144
+ | Max Connections | IMGDUDE_MAX_CONNECTIONS | 100 | Concurrent connections |
145
+ | Workers | IMGDUDE_WORKERS | 1 | Uvicorn workers |
146
+
147
+ ## API Reference
148
+
149
+ - `GET /image/{path}` - Original image
150
+ - `GET /image/{path}?w=WIDTH` - Resized image
151
+ - `GET /cache/stats` - Cache statistics
152
+ - `POST /cache/clean` - Clean expired cache
153
+ - `GET /health` - Health check
154
+
155
+ ## Security
156
+
157
+ - Trusted host verification
158
+ - CORS protection
159
+ - Path traversal prevention
160
+ - File extension validation
161
+ - Input parameter validation
162
+ - Absolute path blocking
163
+
164
+ ## Performance
165
+
166
+ - Smart resize skipping
167
+ - Dedicated thread pools
168
+ - Image optimization
169
+ - Cache control headers
170
+ - Size-based cache management
171
+ - Performance monitoring
172
+
173
+ ## Development
174
+
175
+ ```bash
176
+ # Setup
177
+ git clone https://github.com/ASafarzadeh/imgdude.git
178
+ cd imgdude
179
+ python -m venv venv
180
+ source venv/bin/activate
181
+ pip install -e ".[dev]"
182
+
183
+ # Testing
184
+ pytest
185
+ pytest --cov=imgdude
186
+ ```
187
+
188
+ ## License
189
+
190
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,151 @@
1
+ # ImgDude
2
+
3
+ [![PyPI version](https://badge.fury.io/py/imgdude.svg)](https://badge.fury.io/py/imgdude)
4
+ [![Python Version](https://img.shields.io/pypi/pyversions/imgdude.svg)](https://pypi.org/project/imgdude/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ A high-performance image resizing proxy for Nginx, built with FastAPI. ImgDude provides on-the-fly image resizing with efficient caching capabilities.
8
+
9
+ ## Features
10
+
11
+ - Dynamic image resizing with aspect ratio preservation
12
+ - Intelligent caching system for optimized performance
13
+ - Seamless Nginx integration
14
+ - Built on FastAPI with async I/O support
15
+ - Simple URL-based resizing API
16
+ - Comprehensive security measures
17
+ - Performance optimizations for production use
18
+
19
+ ## Installation
20
+
21
+ ### PyPI
22
+
23
+ ```bash
24
+ pip install imgdude
25
+ ```
26
+
27
+ ### Source
28
+
29
+ ```bash
30
+ git clone https://github.com/ASafarzadeh/imgdude.git
31
+ cd imgdude
32
+ pip install -e .
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### Basic Server
38
+
39
+ ```bash
40
+ # Default configuration (127.0.0.1:12312)
41
+ imgdude
42
+
43
+ # Custom configuration
44
+ imgdude --host 0.0.0.0 --port 8080 --media-root /path/to/images --cache-dir /path/to/cache
45
+ ```
46
+
47
+ ### Docker Deployment
48
+
49
+ 1. Build and run using Docker Compose:
50
+
51
+ ```bash
52
+ # Update volume paths in docker-compose.yml
53
+ docker-compose up -d
54
+ ```
55
+
56
+ 2. Or build and run manually:
57
+
58
+ ```bash
59
+ # Build the image
60
+ docker build -t imgdude .
61
+
62
+ # Run the container
63
+ docker run -d \
64
+ -p 12312:12312 \
65
+ -v /path/to/images:/app/media \
66
+ -v /path/to/cache:/app/cache \
67
+ imgdude
68
+ ```
69
+
70
+ ### Nginx Integration
71
+
72
+ Add to your Nginx configuration:
73
+
74
+ ```nginx
75
+ location /images/ {
76
+ proxy_pass http://127.0.0.1:12312/image/;
77
+ proxy_set_header Host $host;
78
+ proxy_set_header X-Real-IP $remote_addr;
79
+ proxy_cache_valid 200 7d;
80
+ expires max;
81
+ }
82
+ ```
83
+
84
+ ### Image Resizing
85
+
86
+ Resize images by adding the width parameter:
87
+
88
+ ```
89
+ http://yourdomain.com/images/path/to/image.jpg?w=300
90
+ ```
91
+
92
+ ## Configuration
93
+
94
+ | Setting | Environment Variable | Default | Description |
95
+ | --------------- | ----------------------- | --------- | ------------------------ |
96
+ | Host | - | 127.0.0.1 | Server host |
97
+ | Port | - | 12312 | Server port |
98
+ | Media Root | IMGDUDE_MEDIA_ROOT | ./media | Media directory |
99
+ | Cache Directory | IMGDUDE_CACHE_DIR | ./cache | Cache directory |
100
+ | Cache Max Age | IMGDUDE_CACHE_MAX_AGE | 604800 | Cache duration (seconds) |
101
+ | Max Width | IMGDUDE_MAX_WIDTH | 2000 | Maximum image width |
102
+ | Trusted Hosts | IMGDUDE_TRUSTED_HOSTS | \* | Allowed IP addresses |
103
+ | Image Workers | IMGDUDE_IMAGE_WORKERS | CPU-based | Image processing threads |
104
+ | I/O Workers | IMGDUDE_IO_WORKERS | CPU-based | File I/O threads |
105
+ | Max Connections | IMGDUDE_MAX_CONNECTIONS | 100 | Concurrent connections |
106
+ | Workers | IMGDUDE_WORKERS | 1 | Uvicorn workers |
107
+
108
+ ## API Reference
109
+
110
+ - `GET /image/{path}` - Original image
111
+ - `GET /image/{path}?w=WIDTH` - Resized image
112
+ - `GET /cache/stats` - Cache statistics
113
+ - `POST /cache/clean` - Clean expired cache
114
+ - `GET /health` - Health check
115
+
116
+ ## Security
117
+
118
+ - Trusted host verification
119
+ - CORS protection
120
+ - Path traversal prevention
121
+ - File extension validation
122
+ - Input parameter validation
123
+ - Absolute path blocking
124
+
125
+ ## Performance
126
+
127
+ - Smart resize skipping
128
+ - Dedicated thread pools
129
+ - Image optimization
130
+ - Cache control headers
131
+ - Size-based cache management
132
+ - Performance monitoring
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ # Setup
138
+ git clone https://github.com/ASafarzadeh/imgdude.git
139
+ cd imgdude
140
+ python -m venv venv
141
+ source venv/bin/activate
142
+ pip install -e ".[dev]"
143
+
144
+ # Testing
145
+ pytest
146
+ pytest --cov=imgdude
147
+ ```
148
+
149
+ ## License
150
+
151
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,3 @@
1
+ """Image resizing proxy for Nginx with FastAPI."""
2
+
3
+ from ._version import __version__
@@ -0,0 +1,5 @@
1
+ """Version information for ImgDude."""
2
+
3
+ __all__ = ["__version__", "version"]
4
+
5
+ __version__ = version = '0.1.0'
@@ -0,0 +1,302 @@
1
+ """Cache management for imgdude."""
2
+
3
+ import os
4
+ import time
5
+ import asyncio
6
+ import logging
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Dict, Any, Union, List, Set, Optional, OrderedDict
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from collections import OrderedDict
12
+
13
+ logger = logging.getLogger("imgdude.cache")
14
+
15
+ class CacheManager:
16
+ """Handles image cache operations and cleanup."""
17
+ def __init__(self, cache_dir: str, max_age_seconds: int = 604800, max_size_mb: int = 1024):
18
+ """Initialize the cache manager.
19
+ Args:
20
+ cache_dir: Directory to store cached images
21
+ max_age_seconds: Maximum age of cached files in seconds
22
+ max_size_mb: Maximum size of cache in MB
23
+ """
24
+ self.cache_dir = Path(cache_dir)
25
+ self.max_age_seconds = max_age_seconds
26
+ self.max_size_bytes = max_size_mb * 1024 * 1024
27
+ self.cache_dir.mkdir(exist_ok=True, parents=True)
28
+ self._thread_pool = ThreadPoolExecutor(
29
+ max_workers=2,
30
+ thread_name_prefix="cache_worker"
31
+ )
32
+ # Use an OrderedDict with a max size for recently accessed files
33
+ # This is more efficient than maintaining separate deque and set
34
+ self._recently_accessed: OrderedDict[str, None] = OrderedDict()
35
+ self._max_recently_accessed = 1000
36
+ self._cleanup_lock = asyncio.Lock()
37
+
38
+ def mark_accessed(self, file_path: str) -> None:
39
+ """Mark a file as recently accessed to help prioritize cache cleanup."""
40
+ # If the file is already in recently accessed, remove it to add it at the end
41
+ if file_path in self._recently_accessed:
42
+ del self._recently_accessed[file_path]
43
+
44
+ # Add to the OrderedDict (most recently accessed is at the end)
45
+ self._recently_accessed[file_path] = None
46
+
47
+ # Trim if exceeds max size
48
+ while len(self._recently_accessed) > self._max_recently_accessed:
49
+ self._recently_accessed.popitem(last=False) # Remove oldest item (first)
50
+
51
+ async def clean_cache(self) -> Dict[str, Union[int, str, float]]:
52
+ """Remove expired or excess items from the cache. Returns cleanup statistics."""
53
+ async with self._cleanup_lock:
54
+ try:
55
+ logger.info("Starting cache cleanup")
56
+ start_time = time.time()
57
+ now = time.time()
58
+ count = 0
59
+ failed = 0
60
+ removed_size = 0
61
+ total_size = await self._get_cache_size()
62
+
63
+ # Process files in batches to reduce memory usage
64
+ # First, handle expired files
65
+ expired_stats = await self._clean_expired_files(now, total_size)
66
+ count += expired_stats["removed"]
67
+ failed += expired_stats["failed"]
68
+ removed_size += expired_stats["removed_size"]
69
+ total_size = expired_stats["new_total_size"]
70
+
71
+ # Then handle size-based cleanup if still needed
72
+ if total_size > self.max_size_bytes:
73
+ size_cleanup_stats = await self._clean_by_size(total_size)
74
+ count += size_cleanup_stats["removed"]
75
+ failed += size_cleanup_stats["failed"]
76
+ removed_size += size_cleanup_stats["removed_size"]
77
+ total_size = size_cleanup_stats["new_total_size"]
78
+
79
+ process_time = time.time() - start_time
80
+ logger.info(f"Cache cleanup complete: removed {count} files ({removed_size / (1024*1024):.2f} MB) in {process_time:.2f}s")
81
+ return {
82
+ "removed_files": count,
83
+ "failed_files": failed,
84
+ "removed_size_bytes": removed_size,
85
+ "removed_size_mb": round(removed_size / (1024 * 1024), 2),
86
+ "total_size_mb": round(total_size / (1024 * 1024), 2),
87
+ "max_size_mb": self.max_size_bytes // (1024 * 1024),
88
+ "process_time_seconds": round(process_time, 2)
89
+ }
90
+ except Exception as e:
91
+ logger.error(f"Cache cleanup error: {str(e)}")
92
+ return {
93
+ "error": str(e),
94
+ "removed_files": 0,
95
+ "failed_files": 0
96
+ }
97
+
98
+ async def _clean_expired_files(self, now: float, total_size: int) -> Dict[str, Union[int, float]]:
99
+ """Clean expired files from the cache."""
100
+ count = 0
101
+ failed = 0
102
+ removed_size = 0
103
+
104
+ # Process files but don't store them all in memory
105
+ for file_path in self.cache_dir.glob("*"):
106
+ if not file_path.is_file():
107
+ continue
108
+
109
+ try:
110
+ mtime = os.path.getmtime(file_path)
111
+ file_age = now - mtime
112
+
113
+ # Remove if expired
114
+ if file_age > self.max_age_seconds:
115
+ file_size = file_path.stat().st_size
116
+ path_str = str(file_path)
117
+ is_recently_accessed = path_str in self._recently_accessed
118
+
119
+ # Skip recently accessed files unless we're critically low on space
120
+ if is_recently_accessed and total_size < (self.max_size_bytes * 0.9):
121
+ continue
122
+
123
+ # Remove file
124
+ loop = asyncio.get_event_loop()
125
+ await loop.run_in_executor(self._thread_pool, os.remove, file_path)
126
+ count += 1
127
+ removed_size += file_size
128
+ total_size -= file_size
129
+
130
+ # Remove from recently accessed if present
131
+ if path_str in self._recently_accessed:
132
+ del self._recently_accessed[path_str]
133
+ except OSError as e:
134
+ logger.error(f"Error accessing/removing file {file_path}: {str(e)}")
135
+ failed += 1
136
+
137
+ return {
138
+ "removed": count,
139
+ "failed": failed,
140
+ "removed_size": removed_size,
141
+ "new_total_size": total_size
142
+ }
143
+
144
+ async def _clean_by_size(self, total_size: int) -> Dict[str, Union[int, float]]:
145
+ """Clean files based on size limits, starting with the oldest."""
146
+ count = 0
147
+ failed = 0
148
+ removed_size = 0
149
+
150
+ # Get files with their metadata
151
+ files_info = []
152
+
153
+ for file_path in self.cache_dir.glob("*"):
154
+ if not file_path.is_file():
155
+ continue
156
+
157
+ try:
158
+ path_str = str(file_path)
159
+ mtime = os.path.getmtime(file_path)
160
+ size = file_path.stat().st_size
161
+ is_recently_accessed = path_str in self._recently_accessed
162
+
163
+ files_info.append({
164
+ "path": file_path,
165
+ "mtime": mtime,
166
+ "size": size,
167
+ "is_recently_accessed": is_recently_accessed
168
+ })
169
+ except OSError as e:
170
+ logger.error(f"Error accessing file {file_path}: {str(e)}")
171
+ failed += 1
172
+
173
+ # Sort by recently accessed (preserve), then by age (oldest first)
174
+ files_info.sort(key=lambda x: (x["is_recently_accessed"], -x["mtime"]))
175
+
176
+ # Remove files until we're under the size limit
177
+ for file_info in files_info:
178
+ if total_size <= self.max_size_bytes:
179
+ break
180
+
181
+ file_path = file_info["path"]
182
+ file_size = file_info["size"]
183
+ path_str = str(file_path)
184
+
185
+ # Skip recently accessed files unless we're critically low on space
186
+ if file_info["is_recently_accessed"] and total_size < (self.max_size_bytes * 0.9):
187
+ continue
188
+
189
+ try:
190
+ # Remove file
191
+ loop = asyncio.get_event_loop()
192
+ await loop.run_in_executor(self._thread_pool, os.remove, file_path)
193
+ count += 1
194
+ removed_size += file_size
195
+ total_size -= file_size
196
+
197
+ # Remove from recently accessed if present
198
+ if path_str in self._recently_accessed:
199
+ del self._recently_accessed[path_str]
200
+ except OSError as e:
201
+ logger.error(f"Error removing cache file {file_path}: {str(e)}")
202
+ failed += 1
203
+
204
+ return {
205
+ "removed": count,
206
+ "failed": failed,
207
+ "removed_size": removed_size,
208
+ "new_total_size": total_size
209
+ }
210
+
211
+ async def start_periodic_cleanup(self, interval_seconds: int = 3600) -> None:
212
+ """Periodically clean the cache in the background."""
213
+ try:
214
+ while True:
215
+ await self.clean_cache()
216
+ await asyncio.sleep(interval_seconds)
217
+ except asyncio.CancelledError:
218
+ logger.info("Periodic cache cleanup task cancelled")
219
+ self._thread_pool.shutdown(wait=False)
220
+ raise
221
+ except Exception as e:
222
+ logger.error(f"Error in periodic cache cleanup: {str(e)}")
223
+ raise
224
+
225
+ async def _get_cache_size(self) -> int:
226
+ """Calculate the total size of the cache in bytes."""
227
+ try:
228
+ loop = asyncio.get_event_loop()
229
+ total_size = await loop.run_in_executor(
230
+ self._thread_pool,
231
+ lambda: sum(f.stat().st_size for f in self.cache_dir.glob("*") if f.is_file())
232
+ )
233
+ return total_size
234
+ except Exception as e:
235
+ logger.error(f"Error calculating cache size: {str(e)}")
236
+ return 0
237
+
238
+ async def get_cache_stats(self) -> Dict[str, Union[int, float, str, List[str]]]:
239
+ """Return statistics about the cache, including file types and disk usage."""
240
+ try:
241
+ start_time = time.time()
242
+ total_files = 0
243
+ total_size = 0
244
+ oldest_file_age = 0
245
+ newest_file_age = float('inf')
246
+ now = time.time()
247
+ file_exts: Dict[str, int] = {}
248
+ for file_path in self.cache_dir.glob("*"):
249
+ if file_path.is_file():
250
+ total_files += 1
251
+ size = file_path.stat().st_size
252
+ total_size += size
253
+ ext = file_path.suffix.lower()
254
+ file_exts[ext] = file_exts.get(ext, 0) + 1
255
+ try:
256
+ file_age = now - os.path.getmtime(file_path)
257
+ oldest_file_age = max(oldest_file_age, file_age)
258
+ newest_file_age = min(newest_file_age, file_age)
259
+ except OSError:
260
+ pass
261
+ disk_usage = self._get_disk_usage(self.cache_dir)
262
+ extensions = [f"{ext}: {count}" for ext, count in file_exts.items()]
263
+ process_time = time.time() - start_time
264
+ return {
265
+ "total_files": total_files,
266
+ "total_size_bytes": total_size,
267
+ "total_size_mb": round(total_size / (1024 * 1024), 2),
268
+ "cache_dir": str(self.cache_dir),
269
+ "max_age_seconds": self.max_age_seconds,
270
+ "max_size_mb": self.max_size_bytes // (1024 * 1024),
271
+ "oldest_file_age_hours": round(oldest_file_age / 3600, 1) if oldest_file_age > 0 else 0,
272
+ "newest_file_age_hours": round(newest_file_age / 3600, 1) if newest_file_age < float('inf') else 0,
273
+ "file_types": extensions,
274
+ "disk_usage": disk_usage,
275
+ "process_time_seconds": round(process_time, 3)
276
+ }
277
+ except Exception as e:
278
+ logger.error(f"Error getting cache stats: {str(e)}")
279
+ return {
280
+ "error": str(e),
281
+ "total_files": 0,
282
+ "total_size_bytes": 0
283
+ }
284
+
285
+ def _get_disk_usage(self, path: Path) -> Dict[str, Union[int, float]]:
286
+ """Get disk usage information for the given path."""
287
+ try:
288
+ usage = shutil.disk_usage(path)
289
+ return {
290
+ "total_bytes": usage.total,
291
+ "used_bytes": usage.used,
292
+ "free_bytes": usage.free,
293
+ "total_gb": round(usage.total / (1024**3), 2),
294
+ "used_gb": round(usage.used / (1024**3), 2),
295
+ "free_gb": round(usage.free / (1024**3), 2),
296
+ "percent_used": round((usage.used / usage.total) * 100, 1)
297
+ }
298
+ except Exception as e:
299
+ logger.error(f"Error getting disk usage: {str(e)}")
300
+ return {
301
+ "error": str(e)
302
+ }