@rishibhushan/jenkins-mcp-server 1.0.7 → 1.1.0
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.
- package/README.md +188 -10
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/src/jenkins_mcp_server/__init__.py +19 -11
- package/src/jenkins_mcp_server/cache.py +310 -0
- package/src/jenkins_mcp_server/config.py +54 -1
- package/src/jenkins_mcp_server/jenkins_client.py +69 -80
- package/src/jenkins_mcp_server/metrics.py +358 -0
- package/src/jenkins_mcp_server/server.py +1015 -108
package/README.md
CHANGED
|
@@ -7,6 +7,28 @@ Designed to work seamlessly with automation clients such as:
|
|
|
7
7
|
- 🖥️ **Claude Desktop** - AI-powered Jenkins automation
|
|
8
8
|
- 🔌 **Any MCP-compatible client** - Universal compatibility
|
|
9
9
|
|
|
10
|
+
## ✨ What's New in v1.1.0
|
|
11
|
+
|
|
12
|
+
### 🚀 Performance Enhancements
|
|
13
|
+
- ⚡ **10x faster** - Client connection caching for repeated operations
|
|
14
|
+
- 📊 **Smart caching** - Job list caching with 30-60s TTL (5-10x improvement)
|
|
15
|
+
- 🎯 **Optimized queries** - Reduced API calls by 33-83%
|
|
16
|
+
|
|
17
|
+
### 🛡️ Reliability Improvements
|
|
18
|
+
- ✅ **86% validation coverage** - Input validation on 18/21 tools
|
|
19
|
+
- ⏱️ **Configurable timeouts** - No more hanging API calls
|
|
20
|
+
- 💬 **Better error messages** - Clear troubleshooting steps
|
|
21
|
+
- 🏥 **Health check tool** - Instant diagnostics
|
|
22
|
+
|
|
23
|
+
### 🎛️ Advanced Features
|
|
24
|
+
- 📦 **Batch operations** - Trigger up to 20 builds at once
|
|
25
|
+
- 📈 **Metrics & telemetry** - Track tool usage and performance
|
|
26
|
+
- 🗂️ **Cache management** - Monitor and control caching
|
|
27
|
+
- 📊 **Console improvements** - Line-based truncation with tail mode
|
|
28
|
+
- 🎨 **Structured logging** - Better debugging with JSON logs
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
10
32
|
## ✨ About codebase
|
|
11
33
|
|
|
12
34
|
- ✅ **Codebase** - cleaner, more maintainable
|
|
@@ -15,6 +37,8 @@ Designed to work seamlessly with automation clients such as:
|
|
|
15
37
|
- ✅ **Cross-platform** - Seamless support for Windows, macOS, and Linux
|
|
16
38
|
- ✅ **Logging** - Professional logging with `--verbose` flag
|
|
17
39
|
- ✅ **Dependency management** - Automatic detection and installation
|
|
40
|
+
- ✅ **Performance** - 10x faster with intelligent caching and optimization
|
|
41
|
+
- ✅ **Reliability** - Comprehensive input validation and error handling
|
|
18
42
|
|
|
19
43
|
---
|
|
20
44
|
|
|
@@ -26,25 +50,26 @@ This project includes:
|
|
|
26
50
|
- 🔄 Automatic virtual environment creation + dependency installation
|
|
27
51
|
- 🌐 Corporate proxy/certificate auto-detection support
|
|
28
52
|
- 🪟 Windows, macOS, and Linux support
|
|
29
|
-
- 🛠️ **
|
|
53
|
+
- 🛠️ **26 Jenkins management tools** (upgraded from 20!)
|
|
30
54
|
|
|
31
55
|
### 🧩 Build Operations
|
|
32
56
|
| Tool Name | Description | Required Fields | Optional Fields |
|
|
33
57
|
|---|---|---|---|
|
|
34
58
|
| `trigger-build` | Trigger a Jenkins job build with optional parameters | `job_name` | `parameters` |
|
|
35
59
|
| `stop-build` | Stop a running Jenkins build | `job_name`, `build_number` | *(none)* |
|
|
60
|
+
| `trigger-multiple-builds` | **NEW!** Trigger builds for multiple jobs at once | `job_names` | `parameters`, `wait_for_start` |
|
|
36
61
|
|
|
37
62
|
### 📊 Job Information
|
|
38
63
|
| Tool Name | Description | Required Fields | Optional Fields |
|
|
39
64
|
|---|---|---|---|
|
|
40
|
-
| `list-jobs` | List all Jenkins jobs with optional filtering | *(none)* | `filter` |
|
|
41
|
-
| `get-job-details` | Get detailed information about a Jenkins job | `job_name` |
|
|
65
|
+
| `list-jobs` | List all Jenkins jobs with optional filtering **and caching** | *(none)* | `filter`, `use_cache` |
|
|
66
|
+
| `get-job-details` | Get detailed information about a Jenkins job | `job_name` | `max_recent_builds` |
|
|
42
67
|
|
|
43
68
|
### 🛠️ Build Information
|
|
44
69
|
| Tool Name | Description | Required Fields | Optional Fields |
|
|
45
70
|
|---|---|---|---|
|
|
46
71
|
| `get-build-info` | Get information about a specific build | `job_name`, `build_number` | *(none)* |
|
|
47
|
-
| `get-build-console` | Get console output
|
|
72
|
+
| `get-build-console` | Get console output with **smart truncation** | `job_name`, `build_number` | `max_lines`, `tail_only` |
|
|
48
73
|
| `get-last-build-number` | Get the last build number for a job | `job_name` | *(none)* |
|
|
49
74
|
| `get-last-build-timestamp` | Get the timestamp of the last build | `job_name` | *(none)* |
|
|
50
75
|
|
|
@@ -71,6 +96,17 @@ This project includes:
|
|
|
71
96
|
| `get-queue-info` | Get Jenkins build queue info | *(none)* | *(none)* |
|
|
72
97
|
| `list-nodes` | List all Jenkins nodes | *(none)* | *(none)* |
|
|
73
98
|
| `get-node-info` | Get information about a Jenkins node | `node_name` | *(none)* |
|
|
99
|
+
| `health-check` | **NEW!** Run diagnostics on Jenkins connection | *(none)* | *(none)* |
|
|
100
|
+
|
|
101
|
+
### 📊 Monitoring & Management
|
|
102
|
+
| Tool Name | Description | Required Fields | Optional Fields |
|
|
103
|
+
|---|---|---|---|
|
|
104
|
+
| `get-cache-stats` | **NEW!** Get cache statistics and information | *(none)* | *(none)* |
|
|
105
|
+
| `clear-cache` | **NEW!** Clear all cached data | *(none)* | *(none)* |
|
|
106
|
+
| `get-metrics` | **NEW!** Get usage metrics and performance stats | *(none)* | `tool_name` |
|
|
107
|
+
| `configure-webhook` | **NEW!** Configure webhook notifications | `job_name`, `webhook_url`, `events` | *(none)* |
|
|
108
|
+
|
|
109
|
+
For detailed technical documentation, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
74
110
|
|
|
75
111
|
---
|
|
76
112
|
|
|
@@ -162,12 +198,7 @@ Add to your VS Code `settings.json`:
|
|
|
162
198
|
|
|
163
199
|
### Option 2: Environment File (.env)
|
|
164
200
|
|
|
165
|
-
|
|
166
|
-
```bash
|
|
167
|
-
cp .env.template .env
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
In the `.env` file in your project directory:
|
|
201
|
+
Create a file `.env` in your local:
|
|
171
202
|
|
|
172
203
|
```bash
|
|
173
204
|
JENKINS_URL=http://jenkins.example.com:8080
|
|
@@ -215,7 +246,11 @@ Settings are loaded in this order (later overrides earlier):
|
|
|
215
246
|
|
|
216
247
|
### Option 1: Using npx (No Installation Required)
|
|
217
248
|
```bash
|
|
249
|
+
# if .env file is located in the current directory
|
|
218
250
|
npx @rishibhushan/jenkins-mcp-server --env-file .env
|
|
251
|
+
|
|
252
|
+
# OR if .env file is located in /path/to/.env
|
|
253
|
+
npx @rishibhushan/jenkins-mcp-server --env-file /path/to/.env
|
|
219
254
|
```
|
|
220
255
|
|
|
221
256
|
### Option 2: Global Installation
|
|
@@ -713,6 +748,149 @@ pip install -r requirements.txt
|
|
|
713
748
|
}
|
|
714
749
|
}
|
|
715
750
|
```
|
|
751
|
+
---
|
|
752
|
+
|
|
753
|
+
### Solution 4: Use Global Installation with Direct Python Path
|
|
754
|
+
|
|
755
|
+
If you prefer to use global installation but still face VPN/timeout issues, you can combine both approaches.
|
|
756
|
+
|
|
757
|
+
#### Step 1: Install Globally
|
|
758
|
+
|
|
759
|
+
**Disconnect from VPN first** (to avoid proxy issues during installation):
|
|
760
|
+
```bash
|
|
761
|
+
# Install globally
|
|
762
|
+
npm install -g @rishibhushan/jenkins-mcp-server
|
|
763
|
+
|
|
764
|
+
# Verify installation
|
|
765
|
+
jenkins-mcp-server --version
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
#### Step 2: Locate Global Installation Directory
|
|
769
|
+
|
|
770
|
+
**For macOS/Linux:**
|
|
771
|
+
```bash
|
|
772
|
+
# Find the global npm directory
|
|
773
|
+
npm root -g
|
|
774
|
+
|
|
775
|
+
# This typically returns something like:
|
|
776
|
+
# /usr/local/lib/node_modules
|
|
777
|
+
# or
|
|
778
|
+
# /Users/username/.npm-global/lib/node_modules
|
|
779
|
+
|
|
780
|
+
# Your package will be at:
|
|
781
|
+
# <npm-root>/@rishibhushan/jenkins-mcp-server
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
**For Windows (PowerShell):**
|
|
785
|
+
```powershell
|
|
786
|
+
# Find the global npm directory
|
|
787
|
+
npm root -g
|
|
788
|
+
|
|
789
|
+
# This typically returns something like:
|
|
790
|
+
# C:\Users\username\AppData\Roaming\npm\node_modules
|
|
791
|
+
|
|
792
|
+
# Your package will be at:
|
|
793
|
+
# <npm-root>\@rishibhushan\jenkins-mcp-server
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
#### Step 3: Find the Python Path
|
|
797
|
+
|
|
798
|
+
Once you know the installation directory:
|
|
799
|
+
|
|
800
|
+
**macOS/Linux:**
|
|
801
|
+
```bash
|
|
802
|
+
# If npm root -g shows: /usr/local/lib/node_modules
|
|
803
|
+
# Your Python path will be:
|
|
804
|
+
/usr/local/lib/node_modules/@rishibhushan/jenkins-mcp-server/.venv/bin/python
|
|
805
|
+
|
|
806
|
+
# Verify it exists:
|
|
807
|
+
ls -la /usr/local/lib/node_modules/@rishibhushan/jenkins-mcp-server/.venv/bin/python
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
**Windows:**
|
|
811
|
+
```powershell
|
|
812
|
+
# If npm root -g shows: C:\Users\username\AppData\Roaming\npm\node_modules
|
|
813
|
+
# Your Python path will be:
|
|
814
|
+
C:\Users\username\AppData\Roaming\npm\node_modules\@rishibhushan\jenkins-mcp-server\.venv\Scripts\python.exe
|
|
815
|
+
|
|
816
|
+
# Verify it exists:
|
|
817
|
+
Test-Path "C:\Users\username\AppData\Roaming\npm\node_modules\@rishibhushan\jenkins-mcp-server\.venv\Scripts\python.exe"
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
#### Step 4: Configure Your MCP Client
|
|
821
|
+
|
|
822
|
+
**For Claude Desktop (macOS/Linux):**
|
|
823
|
+
```json
|
|
824
|
+
{
|
|
825
|
+
"mcpServers": {
|
|
826
|
+
"jenkins": {
|
|
827
|
+
"command": "/usr/local/lib/node_modules/@rishibhushan/jenkins-mcp-server/.venv/bin/python",
|
|
828
|
+
"args": [
|
|
829
|
+
"-m",
|
|
830
|
+
"jenkins_mcp_server",
|
|
831
|
+
"--env-file",
|
|
832
|
+
"/path/to/your/.env"
|
|
833
|
+
],
|
|
834
|
+
"env": {
|
|
835
|
+
"PYTHONPATH": "/usr/local/lib/node_modules/@rishibhushan/jenkins-mcp-server/src"
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
**For Claude Desktop (Windows):**
|
|
843
|
+
```json
|
|
844
|
+
{
|
|
845
|
+
"mcpServers": {
|
|
846
|
+
"jenkins": {
|
|
847
|
+
"command": "C:\\Users\\username\\AppData\\Roaming\\npm\\node_modules\\@rishibhushan\\jenkins-mcp-server\\.venv\\Scripts\\python.exe",
|
|
848
|
+
"args": [
|
|
849
|
+
"-m",
|
|
850
|
+
"jenkins_mcp_server",
|
|
851
|
+
"--env-file",
|
|
852
|
+
"C:\\path\\to\\your\\.env"
|
|
853
|
+
],
|
|
854
|
+
"env": {
|
|
855
|
+
"PYTHONPATH": "C:\\Users\\username\\AppData\\Roaming\\npm\\node_modules\\@rishibhushan\\jenkins-mcp-server\\src"
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
**For Other MCP Clients:**
|
|
863
|
+
|
|
864
|
+
Use the same pattern - replace the `command` with the direct Python path and set the `PYTHONPATH` environment variable.
|
|
865
|
+
|
|
866
|
+
#### Step 5: Test and Use
|
|
867
|
+
|
|
868
|
+
1. **Connect to your VPN**
|
|
869
|
+
2. **Restart your MCP client** (Claude Desktop, VS Code, etc.)
|
|
870
|
+
3. **Verify the connection** works
|
|
871
|
+
|
|
872
|
+
#### Why This Solution Works
|
|
873
|
+
|
|
874
|
+
- ✅ **Persistent installation** - No need to download on each use
|
|
875
|
+
- ✅ **Proper network routing** - Python process inherits VPN routing
|
|
876
|
+
- ✅ **Works across sessions** - Survives VPN connect/disconnect cycles
|
|
877
|
+
- ✅ **Easy updates** - Just run `npm update -g @rishibhushan/jenkins-mcp-server`
|
|
878
|
+
- ✅ **Universal** - Works with any MCP client (Claude, VS Code, etc.)
|
|
879
|
+
|
|
880
|
+
#### Quick Reference Commands
|
|
881
|
+
```bash
|
|
882
|
+
# Find your global installation
|
|
883
|
+
npm root -g
|
|
884
|
+
|
|
885
|
+
# Update global installation
|
|
886
|
+
npm update -g @rishibhushan/jenkins-mcp-server
|
|
887
|
+
|
|
888
|
+
# Uninstall if needed
|
|
889
|
+
npm uninstall -g @rishibhushan/jenkins-mcp-server
|
|
890
|
+
|
|
891
|
+
# Test the Python path works
|
|
892
|
+
/path/to/global/installation/.venv/bin/python -m jenkins_mcp_server --help
|
|
893
|
+
```
|
|
716
894
|
|
|
717
895
|
---
|
|
718
896
|
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
|
@@ -14,19 +14,27 @@ from . import server
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def setup_logging(verbose: bool = False) -> None:
|
|
17
|
-
"""Configure logging
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
"""Configure structured logging"""
|
|
18
|
+
import structlog
|
|
19
|
+
|
|
20
|
+
# Configure structlog
|
|
21
|
+
structlog.configure(
|
|
22
|
+
processors=[
|
|
23
|
+
structlog.stdlib.filter_by_level,
|
|
24
|
+
structlog.stdlib.add_logger_name,
|
|
25
|
+
structlog.stdlib.add_log_level,
|
|
26
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
27
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
28
|
+
structlog.processors.StackInfoRenderer(),
|
|
29
|
+
structlog.processors.format_exc_info,
|
|
30
|
+
structlog.processors.UnicodeDecoder(),
|
|
31
|
+
structlog.processors.JSONRenderer() if not verbose else structlog.dev.ConsoleRenderer()
|
|
32
|
+
],
|
|
33
|
+
context_class=dict,
|
|
34
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
35
|
+
cache_logger_on_first_use=True,
|
|
24
36
|
)
|
|
25
37
|
|
|
26
|
-
# Reduce noise from libraries
|
|
27
|
-
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
|
28
|
-
logging.getLogger('requests').setLevel(logging.WARNING)
|
|
29
|
-
|
|
30
38
|
|
|
31
39
|
def main():
|
|
32
40
|
"""
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Caching Module for Jenkins MCP Server
|
|
3
|
+
|
|
4
|
+
Provides TTL-based caching for frequently accessed Jenkins data
|
|
5
|
+
to reduce API calls and improve performance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
T = TypeVar('T')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CachedData:
|
|
22
|
+
"""Container for cached data with expiration"""
|
|
23
|
+
data: Any
|
|
24
|
+
cached_at: float
|
|
25
|
+
ttl_seconds: int
|
|
26
|
+
key: str
|
|
27
|
+
|
|
28
|
+
def is_expired(self) -> bool:
|
|
29
|
+
"""Check if cached data has expired"""
|
|
30
|
+
age = time.time() - self.cached_at
|
|
31
|
+
return age >= self.ttl_seconds
|
|
32
|
+
|
|
33
|
+
def age_seconds(self) -> float:
|
|
34
|
+
"""Get age of cached data in seconds"""
|
|
35
|
+
return time.time() - self.cached_at
|
|
36
|
+
|
|
37
|
+
def time_until_expiry(self) -> float:
|
|
38
|
+
"""Get seconds until expiry (negative if expired)"""
|
|
39
|
+
return self.ttl_seconds - self.age_seconds()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CacheManager:
|
|
43
|
+
"""
|
|
44
|
+
Thread-safe cache manager with TTL support.
|
|
45
|
+
|
|
46
|
+
Features:
|
|
47
|
+
- TTL-based expiration
|
|
48
|
+
- Thread-safe operations with async locks
|
|
49
|
+
- Cache statistics
|
|
50
|
+
- Automatic cleanup of expired entries
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
"""Initialize cache manager"""
|
|
55
|
+
self._cache: Dict[str, CachedData] = {}
|
|
56
|
+
self._lock = asyncio.Lock()
|
|
57
|
+
|
|
58
|
+
# Statistics
|
|
59
|
+
self._hits = 0
|
|
60
|
+
self._misses = 0
|
|
61
|
+
self._evictions = 0
|
|
62
|
+
|
|
63
|
+
logger.info("Cache manager initialized")
|
|
64
|
+
|
|
65
|
+
async def get(self, key: str) -> Optional[Any]:
|
|
66
|
+
"""
|
|
67
|
+
Get cached data if available and not expired.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
key: Cache key
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Cached data if available and valid, None otherwise
|
|
74
|
+
"""
|
|
75
|
+
async with self._lock:
|
|
76
|
+
if key not in self._cache:
|
|
77
|
+
self._misses += 1
|
|
78
|
+
logger.debug(f"Cache miss: {key}")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
cached = self._cache[key]
|
|
82
|
+
|
|
83
|
+
if cached.is_expired():
|
|
84
|
+
# Remove expired entry
|
|
85
|
+
del self._cache[key]
|
|
86
|
+
self._evictions += 1
|
|
87
|
+
self._misses += 1
|
|
88
|
+
logger.debug(f"Cache expired: {key} (age: {cached.age_seconds():.1f}s)")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
self._hits += 1
|
|
92
|
+
logger.debug(f"Cache hit: {key} (age: {cached.age_seconds():.1f}s, ttl: {cached.ttl_seconds}s)")
|
|
93
|
+
return cached.data
|
|
94
|
+
|
|
95
|
+
async def set(self, key: str, data: Any, ttl_seconds: int = 30) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Store data in cache with TTL.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
key: Cache key
|
|
101
|
+
data: Data to cache
|
|
102
|
+
ttl_seconds: Time-to-live in seconds
|
|
103
|
+
"""
|
|
104
|
+
async with self._lock:
|
|
105
|
+
self._cache[key] = CachedData(
|
|
106
|
+
data=data,
|
|
107
|
+
cached_at=time.time(),
|
|
108
|
+
ttl_seconds=ttl_seconds,
|
|
109
|
+
key=key
|
|
110
|
+
)
|
|
111
|
+
logger.debug(f"Cached: {key} (ttl: {ttl_seconds}s)")
|
|
112
|
+
|
|
113
|
+
async def invalidate(self, key: str) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Invalidate (remove) a specific cache entry.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
key: Cache key to invalidate
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True if key was in cache, False otherwise
|
|
122
|
+
"""
|
|
123
|
+
async with self._lock:
|
|
124
|
+
if key in self._cache:
|
|
125
|
+
del self._cache[key]
|
|
126
|
+
self._evictions += 1
|
|
127
|
+
logger.debug(f"Invalidated: {key}")
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
async def invalidate_pattern(self, pattern: str) -> int:
|
|
132
|
+
"""
|
|
133
|
+
Invalidate all cache entries matching a pattern.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
pattern: String pattern to match (substring match)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Number of entries invalidated
|
|
140
|
+
"""
|
|
141
|
+
async with self._lock:
|
|
142
|
+
keys_to_remove = [
|
|
143
|
+
key for key in self._cache.keys()
|
|
144
|
+
if pattern in key
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
for key in keys_to_remove:
|
|
148
|
+
del self._cache[key]
|
|
149
|
+
self._evictions += 1
|
|
150
|
+
|
|
151
|
+
if keys_to_remove:
|
|
152
|
+
logger.debug(f"Invalidated {len(keys_to_remove)} entries matching '{pattern}'")
|
|
153
|
+
|
|
154
|
+
return len(keys_to_remove)
|
|
155
|
+
|
|
156
|
+
async def clear(self) -> int:
|
|
157
|
+
"""
|
|
158
|
+
Clear all cached data.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Number of entries cleared
|
|
162
|
+
"""
|
|
163
|
+
async with self._lock:
|
|
164
|
+
count = len(self._cache)
|
|
165
|
+
self._cache.clear()
|
|
166
|
+
self._evictions += count
|
|
167
|
+
logger.info(f"Cache cleared: {count} entries removed")
|
|
168
|
+
return count
|
|
169
|
+
|
|
170
|
+
async def cleanup_expired(self) -> int:
|
|
171
|
+
"""
|
|
172
|
+
Remove all expired entries from cache.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Number of expired entries removed
|
|
176
|
+
"""
|
|
177
|
+
async with self._lock:
|
|
178
|
+
expired_keys = [
|
|
179
|
+
key for key, cached in self._cache.items()
|
|
180
|
+
if cached.is_expired()
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
for key in expired_keys:
|
|
184
|
+
del self._cache[key]
|
|
185
|
+
self._evictions += 1
|
|
186
|
+
|
|
187
|
+
if expired_keys:
|
|
188
|
+
logger.debug(f"Cleaned up {len(expired_keys)} expired entries")
|
|
189
|
+
|
|
190
|
+
return len(expired_keys)
|
|
191
|
+
|
|
192
|
+
async def get_or_fetch(
|
|
193
|
+
self,
|
|
194
|
+
key: str,
|
|
195
|
+
fetch_func: Callable[[], Any],
|
|
196
|
+
ttl_seconds: int = 30
|
|
197
|
+
) -> Any:
|
|
198
|
+
"""
|
|
199
|
+
Get cached data or fetch and cache if not available.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
key: Cache key
|
|
203
|
+
fetch_func: Function to call to fetch data if not cached
|
|
204
|
+
ttl_seconds: TTL for newly cached data
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Cached or freshly fetched data
|
|
208
|
+
"""
|
|
209
|
+
# Try cache first
|
|
210
|
+
cached_data = await self.get(key)
|
|
211
|
+
if cached_data is not None:
|
|
212
|
+
return cached_data
|
|
213
|
+
|
|
214
|
+
# Fetch and cache
|
|
215
|
+
data = fetch_func()
|
|
216
|
+
await self.set(key, data, ttl_seconds)
|
|
217
|
+
return data
|
|
218
|
+
|
|
219
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
220
|
+
"""
|
|
221
|
+
Get cache statistics.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Dictionary with cache statistics
|
|
225
|
+
"""
|
|
226
|
+
total_requests = self._hits + self._misses
|
|
227
|
+
hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
"size": len(self._cache),
|
|
231
|
+
"hits": self._hits,
|
|
232
|
+
"misses": self._misses,
|
|
233
|
+
"evictions": self._evictions,
|
|
234
|
+
"total_requests": total_requests,
|
|
235
|
+
"hit_rate_percent": round(hit_rate, 2)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
def reset_stats(self) -> None:
|
|
239
|
+
"""Reset cache statistics"""
|
|
240
|
+
self._hits = 0
|
|
241
|
+
self._misses = 0
|
|
242
|
+
self._evictions = 0
|
|
243
|
+
logger.debug("Cache statistics reset")
|
|
244
|
+
|
|
245
|
+
async def get_all_keys(self) -> list[str]:
|
|
246
|
+
"""Get all cache keys"""
|
|
247
|
+
async with self._lock:
|
|
248
|
+
return list(self._cache.keys())
|
|
249
|
+
|
|
250
|
+
async def get_cache_info(self) -> Dict[str, Any]:
|
|
251
|
+
"""
|
|
252
|
+
Get detailed cache information.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Dictionary with cache details including per-entry info
|
|
256
|
+
"""
|
|
257
|
+
async with self._lock:
|
|
258
|
+
entries = []
|
|
259
|
+
for key, cached in self._cache.items():
|
|
260
|
+
entries.append({
|
|
261
|
+
"key": key,
|
|
262
|
+
"age_seconds": round(cached.age_seconds(), 2),
|
|
263
|
+
"ttl_seconds": cached.ttl_seconds,
|
|
264
|
+
"expires_in_seconds": round(cached.time_until_expiry(), 2),
|
|
265
|
+
"is_expired": cached.is_expired(),
|
|
266
|
+
"cached_at": datetime.fromtimestamp(cached.cached_at).isoformat()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"stats": self.get_stats(),
|
|
271
|
+
"entries": entries
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# Global cache manager instance
|
|
276
|
+
_cache_manager: Optional[CacheManager] = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_cache_manager() -> CacheManager:
|
|
280
|
+
"""Get or create the global cache manager instance"""
|
|
281
|
+
global _cache_manager
|
|
282
|
+
if _cache_manager is None:
|
|
283
|
+
_cache_manager = CacheManager()
|
|
284
|
+
return _cache_manager
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# Convenience functions
|
|
288
|
+
async def get_cached(key: str) -> Optional[Any]:
|
|
289
|
+
"""Get cached data"""
|
|
290
|
+
return await get_cache_manager().get(key)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def set_cached(key: str, data: Any, ttl_seconds: int = 30) -> None:
|
|
294
|
+
"""Set cached data"""
|
|
295
|
+
await get_cache_manager().set(key, data, ttl_seconds)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def invalidate_cache(key: str) -> bool:
|
|
299
|
+
"""Invalidate cache entry"""
|
|
300
|
+
return await get_cache_manager().invalidate(key)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def clear_cache() -> int:
|
|
304
|
+
"""Clear all cache"""
|
|
305
|
+
return await get_cache_manager().clear()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def get_cache_stats() -> Dict[str, Any]:
|
|
309
|
+
"""Get cache statistics"""
|
|
310
|
+
return get_cache_manager().get_stats()
|