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 +21 -0
- imgdude-0.1.0/MANIFEST.in +14 -0
- imgdude-0.1.0/PKG-INFO +190 -0
- imgdude-0.1.0/README.md +151 -0
- imgdude-0.1.0/imgdude/__init__.py +3 -0
- imgdude-0.1.0/imgdude/_version.py +5 -0
- imgdude-0.1.0/imgdude/cache.py +302 -0
- imgdude-0.1.0/imgdude/cli.py +145 -0
- imgdude-0.1.0/imgdude/main.py +421 -0
- imgdude-0.1.0/imgdude.egg-info/PKG-INFO +190 -0
- imgdude-0.1.0/imgdude.egg-info/SOURCES.txt +21 -0
- imgdude-0.1.0/imgdude.egg-info/dependency_links.txt +1 -0
- imgdude-0.1.0/imgdude.egg-info/entry_points.txt +2 -0
- imgdude-0.1.0/imgdude.egg-info/requires.txt +14 -0
- imgdude-0.1.0/imgdude.egg-info/top_level.txt +1 -0
- imgdude-0.1.0/pyproject.toml +57 -0
- imgdude-0.1.0/requirements-dev.txt +9 -0
- imgdude-0.1.0/requirements.txt +5 -0
- imgdude-0.1.0/setup.cfg +4 -0
- imgdude-0.1.0/tests/test_cache.py +209 -0
- imgdude-0.1.0/tests/test_cli.py +196 -0
- imgdude-0.1.0/tests/test_image_processing.py +179 -0
- imgdude-0.1.0/tests/test_main.py +310 -0
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
|
+
[](https://badge.fury.io/py/imgdude)
|
|
43
|
+
[](https://pypi.org/project/imgdude/)
|
|
44
|
+
[](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.
|
imgdude-0.1.0/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# ImgDude
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/py/imgdude)
|
|
4
|
+
[](https://pypi.org/project/imgdude/)
|
|
5
|
+
[](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,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
|
+
}
|