gtg 0.4.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.
@@ -0,0 +1,208 @@
1
+ """In-memory cache adapter for testing.
2
+
3
+ This module provides a simple dict-based cache implementation that implements
4
+ the CachePort interface. It is primarily intended for testing and development
5
+ scenarios where persistence is not required.
6
+
7
+ The cache stores entries with expiration timestamps and tracks hit/miss
8
+ statistics for performance monitoring.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from fnmatch import fnmatch
15
+ from typing import Optional
16
+
17
+ from goodtogo.adapters.time_provider import SystemTimeProvider
18
+ from goodtogo.core.interfaces import CachePort, TimeProvider
19
+ from goodtogo.core.models import CacheStats
20
+
21
+
22
+ @dataclass
23
+ class CacheEntry:
24
+ """A single cache entry with value and expiration.
25
+
26
+ Attributes:
27
+ value: The cached string value.
28
+ expires_at: Unix timestamp when this entry expires.
29
+ """
30
+
31
+ value: str
32
+ expires_at: float
33
+
34
+
35
+ class InMemoryCacheAdapter(CachePort):
36
+ """Simple dict-based cache implementation for testing.
37
+
38
+ This adapter provides an in-memory cache that:
39
+ - Stores entries with expiration timestamps
40
+ - Tracks cache hit/miss statistics
41
+ - Supports glob-style pattern matching for invalidation
42
+ - Automatically excludes expired entries from get operations
43
+
44
+ The cache is not thread-safe and is intended for single-threaded
45
+ test scenarios. For production use, consider SqliteCacheAdapter
46
+ or RedisCacheAdapter.
47
+
48
+ Example:
49
+ cache = InMemoryCacheAdapter()
50
+ cache.set("key", "value", ttl_seconds=300)
51
+ value = cache.get("key") # Returns "value"
52
+ cache.invalidate_pattern("key:*") # Invalidate matching keys
53
+
54
+ Attributes:
55
+ _store: Internal dictionary storing CacheEntry objects.
56
+ _hits: Counter for cache hits.
57
+ _misses: Counter for cache misses.
58
+ """
59
+
60
+ def __init__(self, time_provider: Optional[TimeProvider] = None) -> None:
61
+ """Initialize an empty in-memory cache.
62
+
63
+ Args:
64
+ time_provider: TimeProvider for getting current time.
65
+ Defaults to SystemTimeProvider if not provided.
66
+ """
67
+ self._store: dict[str, CacheEntry] = {}
68
+ self._hits: int = 0
69
+ self._misses: int = 0
70
+ self._time_provider: TimeProvider = time_provider or SystemTimeProvider()
71
+
72
+ def get(self, key: str) -> Optional[str]:
73
+ """Get cached value if it exists and has not expired.
74
+
75
+ Retrieves a value from the cache. If the entry exists but has
76
+ expired, it is treated as a cache miss and the entry is removed.
77
+
78
+ This method updates hit/miss statistics.
79
+
80
+ Args:
81
+ key: Cache key to retrieve.
82
+
83
+ Returns:
84
+ The cached string value if found and not expired, None otherwise.
85
+ """
86
+ entry = self._store.get(key)
87
+
88
+ if entry is None:
89
+ self._misses += 1
90
+ return None
91
+
92
+ # Check if entry has expired
93
+ if entry.expires_at <= self._time_provider.now():
94
+ # Remove expired entry
95
+ del self._store[key]
96
+ self._misses += 1
97
+ return None
98
+
99
+ self._hits += 1
100
+ return entry.value
101
+
102
+ def set(self, key: str, value: str, ttl_seconds: int) -> None:
103
+ """Set cached value with TTL.
104
+
105
+ Stores a value in the cache with an expiration time. If an entry
106
+ with the same key already exists, it is overwritten.
107
+
108
+ Args:
109
+ key: Cache key to store under.
110
+ value: String value to cache.
111
+ ttl_seconds: Time to live in seconds. The entry will be
112
+ considered expired after this duration.
113
+ """
114
+ expires_at = self._time_provider.now() + ttl_seconds
115
+ self._store[key] = CacheEntry(value=value, expires_at=expires_at)
116
+
117
+ def delete(self, key: str) -> None:
118
+ """Delete cached value.
119
+
120
+ Removes a specific entry from the cache. If the key does not
121
+ exist, this is a no-op.
122
+
123
+ Args:
124
+ key: Cache key to delete.
125
+ """
126
+ self._store.pop(key, None)
127
+
128
+ def invalidate_pattern(self, pattern: str) -> None:
129
+ """Invalidate all keys matching pattern.
130
+
131
+ Removes all cache entries whose keys match the given glob-style
132
+ pattern. Uses fnmatch for pattern matching, which supports:
133
+ - * matches everything
134
+ - ? matches any single character
135
+ - [seq] matches any character in seq
136
+ - [!seq] matches any character not in seq
137
+
138
+ Args:
139
+ pattern: Glob-style pattern to match keys against.
140
+ Example: 'pr:myorg:myrepo:123:*' matches all keys
141
+ starting with 'pr:myorg:myrepo:123:'.
142
+ """
143
+ # Collect keys to delete (avoid modifying dict during iteration)
144
+ keys_to_delete = [key for key in self._store if fnmatch(key, pattern)]
145
+
146
+ for key in keys_to_delete:
147
+ del self._store[key]
148
+
149
+ def cleanup_expired(self) -> None:
150
+ """Remove all expired entries.
151
+
152
+ Scans the entire cache and removes any entries whose TTL has
153
+ expired. This should be called periodically to prevent unbounded
154
+ memory growth from accumulated expired entries.
155
+ """
156
+ current_time = self._time_provider.now()
157
+
158
+ # Collect expired keys (avoid modifying dict during iteration)
159
+ expired_keys = [
160
+ key for key, entry in self._store.items() if entry.expires_at <= current_time
161
+ ]
162
+
163
+ for key in expired_keys:
164
+ del self._store[key]
165
+
166
+ def get_stats(self) -> CacheStats:
167
+ """Get cache hit/miss statistics.
168
+
169
+ Returns metrics about cache performance. The hit rate is calculated
170
+ as hits / (hits + misses). If no operations have been performed,
171
+ the hit rate is 0.0.
172
+
173
+ Returns:
174
+ CacheStats object containing hits, misses, and hit_rate.
175
+ """
176
+ total = self._hits + self._misses
177
+ hit_rate = self._hits / total if total > 0 else 0.0
178
+
179
+ return CacheStats(hits=self._hits, misses=self._misses, hit_rate=hit_rate)
180
+
181
+ def clear(self) -> None:
182
+ """Clear all entries and reset statistics.
183
+
184
+ Removes all cached entries and resets hit/miss counters to zero.
185
+ This is useful for test setup/teardown.
186
+ """
187
+ self._store.clear()
188
+ self._hits = 0
189
+ self._misses = 0
190
+
191
+ def __len__(self) -> int:
192
+ """Return the number of entries in the cache.
193
+
194
+ Note that this includes potentially expired entries that have
195
+ not yet been cleaned up.
196
+
197
+ Returns:
198
+ Number of entries currently in the cache.
199
+ """
200
+ return len(self._store)
201
+
202
+ def __repr__(self) -> str:
203
+ """Return a string representation of the cache.
204
+
205
+ Returns:
206
+ String showing cache type and entry count.
207
+ """
208
+ return f"InMemoryCacheAdapter(entries={len(self._store)})"
@@ -0,0 +1,305 @@
1
+ """SQLite cache adapter for GoodToMerge.
2
+
3
+ This module provides a SQLite-based implementation of the CachePort interface.
4
+ It offers zero-configuration local caching with automatic TTL expiration,
5
+ secure file permissions, and pattern-based invalidation.
6
+
7
+ Security features:
8
+ - Cache directory created with 0700 permissions (owner only)
9
+ - Cache file created with 0600 permissions (owner read/write only)
10
+ - Existing permissive permissions are fixed with a warning
11
+ - All inputs validated before use
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sqlite3
17
+ import stat
18
+ import warnings
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Optional
21
+
22
+ from goodtogo.adapters.time_provider import SystemTimeProvider
23
+ from goodtogo.core.interfaces import CachePort, TimeProvider
24
+
25
+ if TYPE_CHECKING:
26
+ from goodtogo.core.models import CacheStats
27
+
28
+
29
+ # SQL statements for schema creation and operations
30
+ _CREATE_TABLES_SQL = """
31
+ CREATE TABLE IF NOT EXISTS pr_cache (
32
+ key TEXT PRIMARY KEY,
33
+ value TEXT NOT NULL,
34
+ expires_at INTEGER NOT NULL,
35
+ created_at INTEGER NOT NULL
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_expires_at ON pr_cache(expires_at);
39
+
40
+ CREATE TABLE IF NOT EXISTS cache_stats (
41
+ key_prefix TEXT PRIMARY KEY,
42
+ hits INTEGER DEFAULT 0,
43
+ misses INTEGER DEFAULT 0
44
+ );
45
+
46
+ -- Initialize global stats if not exists
47
+ INSERT OR IGNORE INTO cache_stats (key_prefix, hits, misses) VALUES ('global', 0, 0);
48
+ """
49
+
50
+
51
+ class SqliteCacheAdapter(CachePort):
52
+ """SQLite-based cache adapter implementing the CachePort interface.
53
+
54
+ This adapter provides persistent local caching using SQLite with:
55
+ - Automatic TTL-based expiration
56
+ - Pattern-based key invalidation using SQL LIKE
57
+ - Hit/miss statistics tracking
58
+ - Secure file permissions
59
+
60
+ The cache database is created automatically on first use with
61
+ appropriate file permissions to protect cached data.
62
+
63
+ Example:
64
+ >>> cache = SqliteCacheAdapter(".goodtogo/cache.db")
65
+ >>> cache.set("pr:myorg:myrepo:123:meta", '{"title": "My PR"}', ttl_seconds=300)
66
+ >>> value = cache.get("pr:myorg:myrepo:123:meta")
67
+ >>> cache.invalidate_pattern("pr:myorg:myrepo:123:%")
68
+
69
+ Attributes:
70
+ db_path: Path to the SQLite database file.
71
+ """
72
+
73
+ def __init__(self, db_path: str, time_provider: Optional[TimeProvider] = None) -> None:
74
+ """Initialize the SQLite cache adapter.
75
+
76
+ Creates the cache directory and database file with secure permissions
77
+ if they don't exist. If the file exists with permissive permissions,
78
+ they are tightened and a warning is issued.
79
+
80
+ Args:
81
+ db_path: Path to the SQLite database file. Parent directories
82
+ will be created if they don't exist.
83
+ time_provider: Optional TimeProvider for time operations.
84
+ Defaults to SystemTimeProvider if not provided.
85
+
86
+ Raises:
87
+ OSError: If unable to create directory or set permissions.
88
+ """
89
+ self.db_path = db_path
90
+ self._time_provider = time_provider or SystemTimeProvider()
91
+ self._connection: Optional[sqlite3.Connection] = None
92
+ self._ensure_secure_path()
93
+ self._init_database()
94
+
95
+ def _ensure_secure_path(self) -> None:
96
+ """Ensure cache directory and file have secure permissions.
97
+
98
+ Creates the directory with 0700 permissions and ensures the file
99
+ (if it exists) has 0600 permissions. Issues a warning if existing
100
+ permissions were too permissive.
101
+ """
102
+ path = Path(self.db_path)
103
+ cache_dir = path.parent
104
+
105
+ # Create directory with secure permissions if needed
106
+ if cache_dir and not cache_dir.exists():
107
+ cache_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
108
+ elif cache_dir and cache_dir.exists():
109
+ # Ensure existing directory has correct permissions
110
+ current_mode = stat.S_IMODE(cache_dir.stat().st_mode)
111
+ if current_mode != 0o700:
112
+ cache_dir.chmod(0o700)
113
+
114
+ # Check existing file permissions and fix if necessary
115
+ if path.exists():
116
+ current_mode = stat.S_IMODE(path.stat().st_mode)
117
+ # Check if group or others have any permissions
118
+ if current_mode & (stat.S_IRWXG | stat.S_IRWXO):
119
+ warnings.warn(
120
+ f"Cache file {self.db_path} had permissive permissions "
121
+ f"({oct(current_mode)}). Fixing to 0600.",
122
+ UserWarning,
123
+ stacklevel=2,
124
+ )
125
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
126
+
127
+ def _init_database(self) -> None:
128
+ """Initialize the database schema.
129
+
130
+ Creates the pr_cache and cache_stats tables if they don't exist.
131
+ Sets file permissions to 0600 after creation.
132
+ """
133
+ conn = self._get_connection()
134
+ conn.executescript(_CREATE_TABLES_SQL)
135
+ conn.commit()
136
+
137
+ # Ensure file has correct permissions after creation
138
+ path = Path(self.db_path)
139
+ if path.exists():
140
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
141
+
142
+ def _get_connection(self) -> sqlite3.Connection:
143
+ """Get or create a database connection.
144
+
145
+ Returns:
146
+ Active SQLite connection with row factory set to sqlite3.Row.
147
+ """
148
+ if self._connection is None:
149
+ self._connection = sqlite3.connect(self.db_path)
150
+ self._connection.row_factory = sqlite3.Row
151
+ return self._connection
152
+
153
+ def get(self, key: str) -> Optional[str]:
154
+ """Get cached value.
155
+
156
+ Retrieves a value from the cache if it exists and has not expired.
157
+ Updates hit/miss statistics accordingly.
158
+
159
+ Args:
160
+ key: Cache key (e.g., 'pr:myorg:myrepo:123:meta').
161
+
162
+ Returns:
163
+ Cached value as string if found and not expired, None otherwise.
164
+ """
165
+ conn = self._get_connection()
166
+ current_time = self._time_provider.now_int()
167
+
168
+ cursor = conn.execute(
169
+ "SELECT value FROM pr_cache WHERE key = ? AND expires_at > ?",
170
+ (key, current_time),
171
+ )
172
+ row = cursor.fetchone()
173
+
174
+ if row is not None:
175
+ # Cache hit
176
+ conn.execute("UPDATE cache_stats SET hits = hits + 1 WHERE key_prefix = 'global'")
177
+ conn.commit()
178
+ return str(row["value"])
179
+ else:
180
+ # Cache miss
181
+ conn.execute("UPDATE cache_stats SET misses = misses + 1 WHERE key_prefix = 'global'")
182
+ conn.commit()
183
+ return None
184
+
185
+ def set(self, key: str, value: str, ttl_seconds: int) -> None:
186
+ """Set cached value with TTL.
187
+
188
+ Stores a value in the cache with an expiration time. If a value
189
+ already exists for the key, it is replaced.
190
+
191
+ Args:
192
+ key: Cache key (e.g., 'pr:myorg:myrepo:123:meta').
193
+ value: Value to cache (typically JSON string).
194
+ ttl_seconds: Time to live in seconds. After this duration,
195
+ the entry is considered expired.
196
+ """
197
+ conn = self._get_connection()
198
+ current_time = self._time_provider.now_int()
199
+ expires_at = current_time + ttl_seconds
200
+
201
+ conn.execute(
202
+ """
203
+ INSERT OR REPLACE INTO pr_cache (key, value, expires_at, created_at)
204
+ VALUES (?, ?, ?, ?)
205
+ """,
206
+ (key, value, expires_at, current_time),
207
+ )
208
+ conn.commit()
209
+
210
+ def delete(self, key: str) -> None:
211
+ """Delete cached value.
212
+
213
+ Removes a specific entry from the cache.
214
+
215
+ Args:
216
+ key: Cache key to delete.
217
+ """
218
+ conn = self._get_connection()
219
+ conn.execute("DELETE FROM pr_cache WHERE key = ?", (key,))
220
+ conn.commit()
221
+
222
+ def invalidate_pattern(self, pattern: str) -> None:
223
+ """Invalidate all keys matching pattern.
224
+
225
+ Removes all cache entries whose keys match the given pattern.
226
+ Uses SQL LIKE pattern matching where '%' matches any sequence
227
+ of characters and '_' matches any single character.
228
+
229
+ For glob-style patterns using '*', convert to SQL LIKE:
230
+ - 'pr:myorg:myrepo:123:*' -> 'pr:myorg:myrepo:123:%'
231
+
232
+ Args:
233
+ pattern: Pattern to match keys against. Use '%' as wildcard
234
+ for SQL LIKE matching (e.g., 'pr:myorg:myrepo:123:%').
235
+
236
+ Note:
237
+ The pattern is converted from glob-style to SQL LIKE:
238
+ - '*' is converted to '%'
239
+ - '?' is converted to '_'
240
+ """
241
+ conn = self._get_connection()
242
+
243
+ # Convert glob-style wildcards to SQL LIKE pattern
244
+ sql_pattern = pattern.replace("*", "%").replace("?", "_")
245
+
246
+ conn.execute("DELETE FROM pr_cache WHERE key LIKE ?", (sql_pattern,))
247
+ conn.commit()
248
+
249
+ def cleanup_expired(self) -> None:
250
+ """Remove expired entries.
251
+
252
+ Deletes all entries whose TTL has expired. This should be called
253
+ periodically to prevent unbounded cache growth.
254
+ """
255
+ conn = self._get_connection()
256
+ current_time = self._time_provider.now_int()
257
+ conn.execute("DELETE FROM pr_cache WHERE expires_at <= ?", (current_time,))
258
+ conn.commit()
259
+
260
+ def get_stats(self) -> CacheStats:
261
+ """Get cache hit/miss statistics.
262
+
263
+ Returns metrics about cache performance for monitoring and
264
+ debugging purposes.
265
+
266
+ Returns:
267
+ CacheStats object containing:
268
+ - hits: Number of successful cache lookups
269
+ - misses: Number of cache misses
270
+ - hit_rate: Ratio of hits to total lookups (0.0 to 1.0)
271
+ """
272
+ # Import here to avoid circular imports
273
+ from goodtogo.core.models import CacheStats
274
+
275
+ conn = self._get_connection()
276
+ cursor = conn.execute("SELECT hits, misses FROM cache_stats WHERE key_prefix = 'global'")
277
+ row = cursor.fetchone()
278
+
279
+ if row is None:
280
+ return CacheStats(hits=0, misses=0, hit_rate=0.0)
281
+
282
+ hits = row["hits"]
283
+ misses = row["misses"]
284
+ total = hits + misses
285
+ hit_rate = hits / total if total > 0 else 0.0
286
+
287
+ return CacheStats(hits=hits, misses=misses, hit_rate=hit_rate)
288
+
289
+ def close(self) -> None:
290
+ """Close the database connection.
291
+
292
+ Should be called when the cache is no longer needed to release
293
+ database resources.
294
+ """
295
+ if self._connection is not None:
296
+ self._connection.close()
297
+ self._connection = None
298
+
299
+ def __del__(self) -> None:
300
+ """Ensure connection is closed on garbage collection."""
301
+ self.close()
302
+
303
+ def __repr__(self) -> str:
304
+ """Return string representation of the adapter."""
305
+ return f"SqliteCacheAdapter(db_path={self.db_path!r})"