redfetch 1.3.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.
redfetch/special.py ADDED
@@ -0,0 +1,81 @@
1
+ """For handling special resources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Iterable, Set, TypedDict
7
+
8
+ from redfetch import config
9
+
10
+
11
+ class SpecialResourceInfo(TypedDict):
12
+ is_special: bool
13
+ is_dependency: bool
14
+ parent_ids: Set[str]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SpecialIndexes:
19
+ opted_in_specials: Set[str]
20
+ # Map dependency_id -> set(parent_ids) where parent is opted-in and dependency is opted-in
21
+ dependency_parents: Dict[str, Set[str]]
22
+
23
+
24
+ def _get_special_resources() -> Dict[str, dict]:
25
+ """Return the SPECIAL_RESOURCES mapping for the current environment."""
26
+ return config.settings.from_env(config.settings.ENV).SPECIAL_RESOURCES
27
+
28
+
29
+ def _build_indexes(special_resources: Dict[str, dict]) -> SpecialIndexes:
30
+ opted_in_specials: Set[str] = set()
31
+ dependency_parents: Dict[str, Set[str]] = {}
32
+
33
+ for parent_id, parent_details in special_resources.items():
34
+ if not parent_details.get('opt_in', False):
35
+ continue
36
+ opted_in_specials.add(parent_id)
37
+ for dep_id, dep_details in parent_details.get('dependencies', {}).items():
38
+ if dep_details and dep_details.get('opt_in', False):
39
+ dependency_parents.setdefault(dep_id, set()).add(parent_id)
40
+
41
+ return SpecialIndexes(opted_in_specials=opted_in_specials, dependency_parents=dependency_parents)
42
+
43
+
44
+ def is_resource_opted_in(resource_id: str) -> bool:
45
+ """Return True if the given resource is an opted-in special resource."""
46
+ special_resources = _get_special_resources()
47
+ details = special_resources.get(resource_id)
48
+ return bool(details and details.get('opt_in', False))
49
+
50
+
51
+ def compute_special_status(resource_ids: Iterable[str] | None = None) -> Dict[str, SpecialResourceInfo]:
52
+ """Compute special/dependency status for IDs (or all opted-in if None)."""
53
+ special_resources = _get_special_resources()
54
+ indexes = _build_indexes(special_resources)
55
+
56
+ # Determine the candidate ids to include
57
+ if resource_ids is None:
58
+ candidate_ids: Set[str] = set(indexes.opted_in_specials) | set(indexes.dependency_parents.keys())
59
+ else:
60
+ candidate_ids = set(resource_ids)
61
+ # For each provided parent id, if opted-in, include its opted-in dependencies
62
+ for rid in list(candidate_ids):
63
+ parent_details = special_resources.get(rid)
64
+ if parent_details and parent_details.get('opt_in', False):
65
+ for dep_id, dep_details in parent_details.get('dependencies', {}).items():
66
+ if dep_details and dep_details.get('opt_in', False):
67
+ candidate_ids.add(dep_id)
68
+
69
+ status: Dict[str, SpecialResourceInfo] = {}
70
+ for rid in candidate_ids:
71
+ is_special = rid in indexes.opted_in_specials
72
+ parent_ids = indexes.dependency_parents.get(rid, set())
73
+ is_dependency = bool(parent_ids)
74
+ status[rid] = SpecialResourceInfo(
75
+ is_special=is_special,
76
+ is_dependency=is_dependency,
77
+ parent_ids=set(parent_ids),
78
+ )
79
+
80
+ return status
81
+
redfetch/store.py ADDED
@@ -0,0 +1,505 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ import json
5
+ import os
6
+ import sqlite3
7
+
8
+ import aiosqlite
9
+
10
+ from redfetch import config
11
+ from redfetch import meta
12
+ from redfetch.sync_types import (
13
+ DesiredInstallTarget,
14
+ DesiredSet,
15
+ ExecutionPlan,
16
+ ExecutionResult,
17
+ LocalInstallState,
18
+ LocalSnapshot,
19
+ PlannedAction,
20
+ RemoteResourceState,
21
+ RemoteSnapshot,
22
+ )
23
+
24
+
25
+ SCHEMA_VERSION = 2
26
+
27
+
28
+ def _get_cache_dir() -> str:
29
+ base = getattr(config, "config_dir", None) or os.getenv("REDFETCH_CONFIG_DIR")
30
+ if not base:
31
+ base = os.getcwd()
32
+ cache_dir = os.path.join(base, ".cache")
33
+ os.makedirs(cache_dir, exist_ok=True)
34
+ return cache_dir
35
+
36
+
37
+ def get_db_path(db_name: str) -> str:
38
+ return os.path.join(_get_cache_dir(), db_name)
39
+
40
+
41
+ def get_db_connection(db_name: str):
42
+ db_path = get_db_path(db_name)
43
+ conn = sqlite3.connect(db_path, timeout=30.0, check_same_thread=False, isolation_level=None)
44
+ try:
45
+ conn.execute("PRAGMA journal_mode=WAL")
46
+ conn.execute("PRAGMA busy_timeout=5000")
47
+ conn.execute("PRAGMA synchronous=NORMAL")
48
+ except Exception:
49
+ pass
50
+ return conn
51
+
52
+
53
+ def initialize_db(db_name: str):
54
+ db_path = get_db_path(db_name)
55
+ with sqlite3.connect(db_path, timeout=30.0) as conn:
56
+ cursor = conn.cursor()
57
+ initialize_schema(cursor)
58
+ conn.commit()
59
+
60
+
61
+ def _ensure_metadata(cursor) -> None:
62
+ try:
63
+ cursor.execute("SELECT schema_version FROM metadata WHERE id = 1")
64
+ row = cursor.fetchone()
65
+ if row and row[0] is not None and int(row[0]) >= SCHEMA_VERSION:
66
+ return
67
+ except Exception:
68
+ pass
69
+ _reset_sync_schema(cursor)
70
+
71
+
72
+ def _ensure_downloads_table(cursor) -> None:
73
+ cursor.execute(
74
+ """
75
+ CREATE TABLE IF NOT EXISTS downloads (
76
+ id INTEGER PRIMARY KEY,
77
+ target_key TEXT UNIQUE,
78
+ resource_id INTEGER NOT NULL,
79
+ parent_id INTEGER NOT NULL DEFAULT 0,
80
+ parent_target_key TEXT,
81
+ root_resource_id INTEGER NOT NULL DEFAULT 0,
82
+ target_kind TEXT NOT NULL DEFAULT 'root',
83
+ category_id INTEGER,
84
+ title TEXT,
85
+ version_remote INTEGER,
86
+ version_local INTEGER,
87
+ resolved_path TEXT,
88
+ subfolder TEXT,
89
+ flatten INTEGER NOT NULL DEFAULT 0,
90
+ protected_files TEXT NOT NULL DEFAULT '[]',
91
+ remote_status TEXT,
92
+ is_special INTEGER NOT NULL DEFAULT 0,
93
+ is_watching INTEGER NOT NULL DEFAULT 0,
94
+ is_licensed INTEGER NOT NULL DEFAULT 0,
95
+ is_explicit INTEGER NOT NULL DEFAULT 0,
96
+ is_dependency INTEGER NOT NULL DEFAULT 0
97
+ )
98
+ """
99
+ )
100
+
101
+
102
+ def _ensure_indexes(cursor) -> None:
103
+ cursor.execute(
104
+ "CREATE INDEX IF NOT EXISTS idx_downloads_resource_id ON downloads(resource_id)"
105
+ )
106
+ cursor.execute(
107
+ "CREATE INDEX IF NOT EXISTS idx_downloads_root_resource_id ON downloads(root_resource_id)"
108
+ )
109
+ cursor.execute(
110
+ "CREATE INDEX IF NOT EXISTS idx_downloads_parent_target_key ON downloads(parent_target_key)"
111
+ )
112
+ cursor.execute(
113
+ "CREATE INDEX IF NOT EXISTS idx_downloads_parent_id ON downloads(parent_id)"
114
+ )
115
+
116
+
117
+ def _ensure_navmesh_tables(cursor) -> None:
118
+ cursor.execute(
119
+ """
120
+ CREATE TABLE IF NOT EXISTS navmesh_files (
121
+ filename TEXT PRIMARY KEY,
122
+ md5_hash TEXT NOT NULL,
123
+ file_size INTEGER NOT NULL,
124
+ mtime_ns INTEGER NOT NULL
125
+ )
126
+ """
127
+ )
128
+ cursor.execute(
129
+ """
130
+ CREATE TABLE IF NOT EXISTS navmesh_cache (
131
+ env TEXT PRIMARY KEY,
132
+ etag TEXT,
133
+ last_modified TEXT,
134
+ manifest_json TEXT
135
+ )
136
+ """
137
+ )
138
+
139
+
140
+ def _reset_sync_schema(cursor) -> None:
141
+ cursor.execute("DROP TABLE IF EXISTS downloads")
142
+ cursor.execute("DROP TABLE IF EXISTS resources")
143
+ cursor.execute("DROP TABLE IF EXISTS dependencies")
144
+ cursor.execute("DROP TABLE IF EXISTS metadata")
145
+ cursor.execute(
146
+ """
147
+ CREATE TABLE metadata (
148
+ id INTEGER PRIMARY KEY,
149
+ schema_version INTEGER
150
+ )
151
+ """
152
+ )
153
+ cursor.execute(
154
+ "INSERT INTO metadata (id, schema_version) VALUES (1, ?)",
155
+ (SCHEMA_VERSION,),
156
+ )
157
+
158
+
159
+ def initialize_schema(cursor) -> None:
160
+ _ensure_metadata(cursor)
161
+ _ensure_downloads_table(cursor)
162
+ _ensure_indexes(cursor)
163
+ _ensure_navmesh_tables(cursor)
164
+
165
+
166
+ def reset_all_versions(cursor) -> None:
167
+ cursor.execute("UPDATE downloads SET version_local = 0")
168
+
169
+
170
+ def reset_versions_for_resource(cursor, resource_id: str) -> None:
171
+ key = f"/{resource_id}/"
172
+ cursor.execute(
173
+ """
174
+ UPDATE downloads
175
+ SET version_local = 0
176
+ WHERE target_key = ?
177
+ OR target_key LIKE ?
178
+ """,
179
+ (
180
+ key,
181
+ f"{key}%",
182
+ ),
183
+ )
184
+
185
+
186
+ def _decode_protected_files(raw: str | None) -> list[str]:
187
+ if not raw:
188
+ return []
189
+ try:
190
+ value = json.loads(raw)
191
+ except json.JSONDecodeError:
192
+ return []
193
+ if not isinstance(value, list):
194
+ return []
195
+ return [str(item) for item in value]
196
+
197
+
198
+ def _row_to_local_state(row: sqlite3.Row | tuple) -> LocalInstallState:
199
+ (
200
+ target_key,
201
+ resource_id,
202
+ parent_id,
203
+ parent_target_key,
204
+ root_resource_id,
205
+ target_kind,
206
+ category_id,
207
+ title,
208
+ version_remote,
209
+ version_local,
210
+ resolved_path,
211
+ subfolder,
212
+ flatten,
213
+ protected_files,
214
+ _remote_status,
215
+ is_special,
216
+ is_watching,
217
+ is_licensed,
218
+ is_explicit,
219
+ is_dependency,
220
+ ) = row
221
+
222
+ return LocalInstallState(
223
+ target_key=str(target_key),
224
+ resource_id=str(resource_id),
225
+ parent_id=str(parent_id) if parent_id not in (None, 0) else None,
226
+ parent_target_key=parent_target_key,
227
+ root_resource_id=str(root_resource_id),
228
+ target_kind=str(target_kind),
229
+ category_id=category_id,
230
+ title=title,
231
+ version_local=version_local,
232
+ version_remote=version_remote,
233
+ resolved_path=resolved_path,
234
+ subfolder=subfolder,
235
+ flatten=bool(flatten),
236
+ protected_files=_decode_protected_files(protected_files),
237
+ is_special=bool(is_special),
238
+ is_watching=bool(is_watching),
239
+ is_licensed=bool(is_licensed),
240
+ is_explicit=bool(is_explicit),
241
+ is_dependency=bool(is_dependency),
242
+ )
243
+
244
+
245
+ async def load_local_snapshot(db_path: str) -> LocalSnapshot:
246
+ """Load all tracked install targets from the DB so the planner knows what's installed."""
247
+ async with aiosqlite.connect(db_path, timeout=30.0) as conn:
248
+ async with conn.execute(
249
+ """
250
+ SELECT
251
+ target_key, resource_id, parent_id, parent_target_key, root_resource_id,
252
+ target_kind, category_id, title, version_remote, version_local,
253
+ resolved_path, subfolder, flatten,
254
+ protected_files, remote_status,
255
+ is_special, is_watching, is_licensed, is_explicit, is_dependency
256
+ FROM downloads
257
+ """
258
+ ) as cursor:
259
+ rows = await cursor.fetchall()
260
+
261
+ return LocalSnapshot(
262
+ install_targets={
263
+ str(row[0]): _row_to_local_state(row)
264
+ for row in rows
265
+ }
266
+ )
267
+
268
+
269
+ def _desired_flags(target: DesiredInstallTarget) -> dict[str, int]:
270
+ """Convert a target's source set into integer flags for the DB."""
271
+ return {
272
+ "is_special": int("special" in target.sources),
273
+ "is_watching": int("watching" in target.sources),
274
+ "is_licensed": int("licensed" in target.sources),
275
+ "is_explicit": int(target.explicit_root or "explicit" in target.sources),
276
+ "is_dependency": int(target.target_kind == "dependency"),
277
+ }
278
+
279
+
280
+ async def _upsert_download_row(
281
+ conn: aiosqlite.Connection,
282
+ *,
283
+ target: DesiredInstallTarget,
284
+ action: PlannedAction | None,
285
+ remote_state: RemoteResourceState | None,
286
+ version_local: int | None,
287
+ ) -> None:
288
+ """Save or overwrite a single download row with the best-known values from each source."""
289
+ flags = _desired_flags(target)
290
+ persisted_category_id = (
291
+ action.category_id
292
+ if action and action.category_id is not None
293
+ else remote_state.category_id if remote_state and remote_state.category_id is not None
294
+ else target.category_id
295
+ )
296
+ persisted_title = (
297
+ action.title
298
+ if action and action.title is not None
299
+ else remote_state.title if remote_state and remote_state.title is not None
300
+ else target.title
301
+ )
302
+ persisted_resolved_path = (
303
+ action.resolved_path
304
+ if action and action.resolved_path is not None
305
+ else target.resolved_path
306
+ )
307
+ persisted_subfolder = (
308
+ action.subfolder
309
+ if action and action.subfolder is not None
310
+ else target.subfolder
311
+ )
312
+ persisted_flatten = action.flatten if action is not None else target.flatten
313
+ persisted_protected_files = action.protected_files if action is not None else target.protected_files
314
+ await conn.execute(
315
+ """
316
+ INSERT INTO downloads (
317
+ target_key, resource_id, parent_id, parent_target_key, root_resource_id,
318
+ target_kind, category_id, title, version_remote, version_local,
319
+ resolved_path, subfolder, flatten,
320
+ protected_files, remote_status,
321
+ is_special, is_watching, is_licensed, is_explicit, is_dependency
322
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
323
+ ON CONFLICT(target_key) DO UPDATE SET
324
+ resource_id = excluded.resource_id,
325
+ parent_id = excluded.parent_id,
326
+ parent_target_key = excluded.parent_target_key,
327
+ root_resource_id = excluded.root_resource_id,
328
+ target_kind = excluded.target_kind,
329
+ category_id = excluded.category_id,
330
+ title = excluded.title,
331
+ version_remote = excluded.version_remote,
332
+ version_local = excluded.version_local,
333
+ resolved_path = excluded.resolved_path,
334
+ subfolder = excluded.subfolder,
335
+ flatten = excluded.flatten,
336
+ protected_files = excluded.protected_files,
337
+ remote_status = excluded.remote_status,
338
+ is_special = excluded.is_special,
339
+ is_watching = excluded.is_watching,
340
+ is_licensed = excluded.is_licensed,
341
+ is_explicit = excluded.is_explicit,
342
+ is_dependency = excluded.is_dependency
343
+ """,
344
+ (
345
+ target.target_key,
346
+ int(target.resource_id),
347
+ int(target.parent_id) if target.parent_id is not None else 0,
348
+ target.parent_target_key,
349
+ int(target.root_resource_id),
350
+ target.target_kind,
351
+ persisted_category_id,
352
+ persisted_title,
353
+ remote_state.version_id if remote_state else None,
354
+ version_local,
355
+ persisted_resolved_path,
356
+ persisted_subfolder,
357
+ int(persisted_flatten),
358
+ json.dumps(persisted_protected_files),
359
+ remote_state.status if remote_state else None,
360
+ flags["is_special"],
361
+ flags["is_watching"],
362
+ flags["is_licensed"],
363
+ flags["is_explicit"],
364
+ flags["is_dependency"],
365
+ ),
366
+ )
367
+
368
+
369
+ async def record_download_success(
370
+ db_path: str,
371
+ *,
372
+ target: DesiredInstallTarget,
373
+ action: PlannedAction,
374
+ remote_state: RemoteResourceState,
375
+ ) -> None:
376
+ """Persist one target immediately after download so interrupted syncs keep progress."""
377
+ async with aiosqlite.connect(db_path, timeout=30.0) as conn:
378
+ await _upsert_download_row(
379
+ conn,
380
+ target=target,
381
+ action=action,
382
+ remote_state=remote_state,
383
+ version_local=remote_state.version_id,
384
+ )
385
+ await conn.commit()
386
+
387
+
388
+ async def record_installed_state(
389
+ db_path: str,
390
+ *,
391
+ desired_set: DesiredSet,
392
+ remote_snapshot: RemoteSnapshot,
393
+ local_snapshot: LocalSnapshot,
394
+ execution_plan: ExecutionPlan,
395
+ execution_result: ExecutionResult,
396
+ ) -> None:
397
+ """End-of-run batch write for all outcomes: skips, blocks, and untracks."""
398
+ async with aiosqlite.connect(db_path, timeout=30.0) as conn:
399
+ for target_key, action in execution_plan.actions.items():
400
+ result_item = execution_result.items[target_key]
401
+ existing = local_snapshot.install_targets.get(target_key)
402
+
403
+ if action.action == "untrack":
404
+ await conn.execute("DELETE FROM downloads WHERE target_key = ?", (target_key,))
405
+ continue
406
+
407
+ if result_item.outcome == "downloaded":
408
+ continue
409
+
410
+ desired_target = desired_set.install_targets.get(target_key)
411
+ if desired_target is None:
412
+ continue
413
+
414
+ remote_state = remote_snapshot.resources.get(action.resource_id)
415
+ existing_local_version = existing.version_local if existing else None
416
+ version_local = existing_local_version
417
+
418
+ if result_item.outcome == "skipped" and remote_state and remote_state.version_id is not None:
419
+ version_local = existing_local_version if existing_local_version is not None else remote_state.version_id
420
+
421
+ await _upsert_download_row(
422
+ conn,
423
+ target=desired_target,
424
+ action=action,
425
+ remote_state=remote_state,
426
+ version_local=version_local,
427
+ )
428
+
429
+ await conn.commit()
430
+
431
+
432
+ def reset_download_dates(cursor) -> None:
433
+ reset_all_versions(cursor)
434
+ cursor.execute("DELETE FROM navmesh_files")
435
+ cursor.execute("DELETE FROM navmesh_cache")
436
+ try:
437
+ meta.clear_pypi_cache()
438
+ except Exception:
439
+ pass
440
+
441
+
442
+ def reset_download_dates_for_resources(db_name: str, resource_ids: Iterable[str]) -> bool:
443
+ """Force re-download of selected resources without touching anything else."""
444
+ try:
445
+ with get_db_connection(db_name) as conn:
446
+ cursor = conn.cursor()
447
+ for resource_id in resource_ids:
448
+ reset_versions_for_resource(cursor, resource_id)
449
+ conn.commit()
450
+ return True
451
+ except Exception as exc:
452
+ print(f"Error resetting download dates: {exc}")
453
+ return False
454
+
455
+
456
+ async def reset_download_dates_async(db_path: str) -> None:
457
+ async with aiosqlite.connect(db_path, timeout=30.0) as conn:
458
+ await conn.execute("UPDATE downloads SET version_local = 0")
459
+ await conn.execute("DELETE FROM navmesh_files")
460
+ await conn.execute("DELETE FROM navmesh_cache")
461
+ await conn.commit()
462
+ try:
463
+ meta.clear_pypi_cache()
464
+ except Exception:
465
+ pass
466
+
467
+
468
+ def list_resources(cursor) -> list[tuple[int, str]]:
469
+ cursor.execute(
470
+ """
471
+ SELECT resource_id, title
472
+ FROM downloads
473
+ WHERE parent_target_key IS NULL
474
+ ORDER BY resource_id
475
+ """
476
+ )
477
+ return cursor.fetchall()
478
+
479
+
480
+ def list_dependencies(cursor) -> list[tuple[int, str]]:
481
+ cursor.execute(
482
+ """
483
+ SELECT resource_id, title
484
+ FROM downloads
485
+ WHERE parent_target_key IS NOT NULL
486
+ ORDER BY root_resource_id, target_key
487
+ """
488
+ )
489
+ return cursor.fetchall()
490
+
491
+
492
+ async def fetch_root_version_local(db_path: str, resource_id: str) -> int | None:
493
+ """Return the local version stamp for a resource, or None if not installed."""
494
+ async with aiosqlite.connect(db_path, timeout=30.0) as conn:
495
+ async with conn.execute(
496
+ """
497
+ SELECT version_local
498
+ FROM downloads
499
+ WHERE resource_id = ? AND parent_target_key IS NULL
500
+ LIMIT 1
501
+ """,
502
+ (int(resource_id),),
503
+ ) as cursor:
504
+ row = await cursor.fetchone()
505
+ return row[0] if row else None