confluence-markdown 0.1.0__py3-none-any.whl
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.
- confluence_markdown/__init__.py +3 -0
- confluence_markdown/cache.py +173 -0
- confluence_markdown/cli.py +1014 -0
- confluence_markdown/client.py +2487 -0
- confluence_markdown/config.py +201 -0
- confluence_markdown/exceptions.py +45 -0
- confluence_markdown/main.py +12 -0
- confluence_markdown/mcp_server.py +634 -0
- confluence_markdown-0.1.0.dist-info/METADATA +748 -0
- confluence_markdown-0.1.0.dist-info/RECORD +13 -0
- confluence_markdown-0.1.0.dist-info/WHEEL +4 -0
- confluence_markdown-0.1.0.dist-info/entry_points.txt +3 -0
- confluence_markdown-0.1.0.dist-info/licenses/LICENSE +13 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Simple file-based cache for API responses."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Default cache directory
|
|
13
|
+
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "confluence-markdown"
|
|
14
|
+
|
|
15
|
+
# Default TTL in seconds (1 hour)
|
|
16
|
+
DEFAULT_TTL = 3600
|
|
17
|
+
|
|
18
|
+
# Default max number of cache entries before oldest are evicted
|
|
19
|
+
DEFAULT_MAX_ENTRIES = 500
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Cache:
|
|
23
|
+
"""Simple file-based cache with TTL support."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
cache_dir: Optional[Path] = None,
|
|
28
|
+
ttl: int = DEFAULT_TTL,
|
|
29
|
+
enabled: bool = True,
|
|
30
|
+
max_entries: int = DEFAULT_MAX_ENTRIES,
|
|
31
|
+
):
|
|
32
|
+
self.cache_dir = cache_dir or DEFAULT_CACHE_DIR
|
|
33
|
+
self.ttl = ttl
|
|
34
|
+
self.enabled = enabled
|
|
35
|
+
self.max_entries = max_entries
|
|
36
|
+
|
|
37
|
+
if self.enabled:
|
|
38
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
def _get_cache_key(self, key: str) -> str:
|
|
41
|
+
"""Generate a safe filename from cache key."""
|
|
42
|
+
return hashlib.md5(key.encode()).hexdigest()
|
|
43
|
+
|
|
44
|
+
def _get_cache_path(self, key: str) -> Path:
|
|
45
|
+
"""Get full path for a cache key."""
|
|
46
|
+
return self.cache_dir / f"{self._get_cache_key(key)}.json"
|
|
47
|
+
|
|
48
|
+
def get(self, key: str) -> Optional[Any]:
|
|
49
|
+
"""
|
|
50
|
+
Get value from cache.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
key: Cache key
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Cached value or None if not found/expired
|
|
57
|
+
"""
|
|
58
|
+
if not self.enabled:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
cache_path = self._get_cache_path(key)
|
|
62
|
+
if not cache_path.exists():
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(cache_path) as f:
|
|
67
|
+
data = json.load(f)
|
|
68
|
+
|
|
69
|
+
# Check TTL
|
|
70
|
+
if time.time() - data.get("timestamp", 0) > self.ttl:
|
|
71
|
+
logger.debug("Cache expired for key: %s", key[:50])
|
|
72
|
+
cache_path.unlink(missing_ok=True)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
logger.debug("Cache hit for key: %s", key[:50])
|
|
76
|
+
return data.get("value")
|
|
77
|
+
|
|
78
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
79
|
+
logger.debug("Cache read error: %s", e)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def set(self, key: str, value: Any) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Store value in cache.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key: Cache key
|
|
88
|
+
value: Value to cache (must be JSON-serializable)
|
|
89
|
+
"""
|
|
90
|
+
if not self.enabled:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
cache_path = self._get_cache_path(key)
|
|
94
|
+
try:
|
|
95
|
+
data = {
|
|
96
|
+
"timestamp": time.time(),
|
|
97
|
+
"key": key[:100],
|
|
98
|
+
"value": value,
|
|
99
|
+
}
|
|
100
|
+
with open(cache_path, "w") as f:
|
|
101
|
+
json.dump(data, f)
|
|
102
|
+
logger.debug("Cached value for key: %s", key[:50])
|
|
103
|
+
self._evict_if_needed()
|
|
104
|
+
except (TypeError, OSError) as e:
|
|
105
|
+
logger.debug("Cache write error: %s", e)
|
|
106
|
+
|
|
107
|
+
def delete(self, key: str) -> None:
|
|
108
|
+
"""Delete a cached value."""
|
|
109
|
+
if not self.enabled:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
cache_path = self._get_cache_path(key)
|
|
113
|
+
cache_path.unlink(missing_ok=True)
|
|
114
|
+
|
|
115
|
+
def clear(self) -> int:
|
|
116
|
+
"""
|
|
117
|
+
Clear all cached values.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Number of cache files deleted
|
|
121
|
+
"""
|
|
122
|
+
if not self.enabled or not self.cache_dir.exists():
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
count = 0
|
|
126
|
+
for cache_file in self.cache_dir.glob("*.json"):
|
|
127
|
+
try:
|
|
128
|
+
cache_file.unlink()
|
|
129
|
+
count += 1
|
|
130
|
+
except OSError:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
logger.info("Cleared %d cache files", count)
|
|
134
|
+
return count
|
|
135
|
+
|
|
136
|
+
def _evict_if_needed(self) -> None:
|
|
137
|
+
"""Remove oldest entries when cache exceeds max_entries."""
|
|
138
|
+
cache_files = list(self.cache_dir.glob("*.json"))
|
|
139
|
+
if len(cache_files) <= self.max_entries:
|
|
140
|
+
return
|
|
141
|
+
# Sort by mtime, remove oldest
|
|
142
|
+
cache_files.sort(key=lambda p: p.stat().st_mtime)
|
|
143
|
+
to_remove = cache_files[: len(cache_files) - self.max_entries]
|
|
144
|
+
for f in to_remove:
|
|
145
|
+
f.unlink(missing_ok=True)
|
|
146
|
+
logger.debug("Evicted %d cache entries (limit: %d)", len(to_remove), self.max_entries)
|
|
147
|
+
|
|
148
|
+
def cleanup_expired(self) -> int:
|
|
149
|
+
"""
|
|
150
|
+
Remove expired cache entries.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Number of expired entries removed
|
|
154
|
+
"""
|
|
155
|
+
if not self.enabled or not self.cache_dir.exists():
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
count = 0
|
|
159
|
+
for cache_file in self.cache_dir.glob("*.json"):
|
|
160
|
+
try:
|
|
161
|
+
with open(cache_file) as f:
|
|
162
|
+
data = json.load(f)
|
|
163
|
+
if time.time() - data.get("timestamp", 0) > self.ttl:
|
|
164
|
+
cache_file.unlink()
|
|
165
|
+
count += 1
|
|
166
|
+
except (json.JSONDecodeError, OSError):
|
|
167
|
+
# Remove corrupted cache files
|
|
168
|
+
cache_file.unlink(missing_ok=True)
|
|
169
|
+
count += 1
|
|
170
|
+
|
|
171
|
+
if count > 0:
|
|
172
|
+
logger.debug("Cleaned up %d expired cache entries", count)
|
|
173
|
+
return count
|