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.
@@ -0,0 +1,164 @@
1
+ """Concurrent download executor for the planned sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+
8
+ import httpx
9
+
10
+ from redfetch import download
11
+ from redfetch.sync_types import (
12
+ DesiredInstallTarget,
13
+ DesiredSet,
14
+ ExecutionPlan,
15
+ ExecutionResult,
16
+ ExecutionResultItem,
17
+ PlannedAction,
18
+ PlanReason,
19
+ RemoteResourceState,
20
+ RemoteSnapshot,
21
+ ResultOutcome,
22
+ SyncEvent,
23
+ SyncEventCallback,
24
+ )
25
+
26
+ DOWNLOAD_CONCURRENCY = 6
27
+
28
+ _INSTANT_OUTCOMES = {"skip": "skipped", "block": "blocked", "untrack": "untracked"}
29
+
30
+ DownloadSuccessHook = Callable[
31
+ [DesiredInstallTarget, PlannedAction, RemoteResourceState], Awaitable[None]
32
+ ]
33
+
34
+
35
+ def _make_item(
36
+ action: PlannedAction,
37
+ outcome: ResultOutcome,
38
+ *,
39
+ reason: PlanReason | None = None,
40
+ version: int | None = None,
41
+ error: str | None = None,
42
+ ) -> ExecutionResultItem:
43
+ """Fill out a result record for the resource. Auto-copies identity fields so callers only pass what varies."""
44
+ return ExecutionResultItem(
45
+ target_key=action.target_key,
46
+ resource_id=action.resource_id,
47
+ outcome=outcome,
48
+ reason=reason or action.reason,
49
+ written_version=version,
50
+ error_detail=error,
51
+ )
52
+
53
+
54
+ async def _do_download(
55
+ client: httpx.AsyncClient, action: PlannedAction,
56
+ ) -> tuple[bool, str | None]:
57
+ """Perform one download attempt, returning success and any error."""
58
+ try:
59
+ ok = await download.download_install_target_async(
60
+ client=client,
61
+ resource_id=action.resource_id,
62
+ download_url=action.artifact.download_url,
63
+ filename=action.artifact.filename,
64
+ file_hash=action.artifact.file_hash,
65
+ folder_path=action.resolved_path,
66
+ should_flatten=action.flatten,
67
+ protected_files=action.protected_files,
68
+ )
69
+ except asyncio.CancelledError:
70
+ raise
71
+ except Exception as exc:
72
+ return False, str(exc)
73
+ return ok, (None if ok else "download failed")
74
+
75
+
76
+ async def execute_plan(
77
+ *,
78
+ headers: dict,
79
+ desired_set: DesiredSet,
80
+ remote_snapshot: RemoteSnapshot,
81
+ execution_plan: ExecutionPlan,
82
+ on_event: SyncEventCallback | None = None,
83
+ on_download_success: DownloadSuccessHook | None = None,
84
+ ) -> ExecutionResult:
85
+ """Execute planned actions (skips, downloads, etc.) and build the result report."""
86
+ result = ExecutionResult(items={})
87
+ satisfied: set[str] = set()
88
+
89
+ def emit(event: SyncEvent) -> None:
90
+ if on_event:
91
+ on_event(event)
92
+
93
+ for action in execution_plan.actions.values():
94
+ outcome = _INSTANT_OUTCOMES.get(action.action)
95
+ if outcome is None:
96
+ continue
97
+ version = action.remote_version if action.action == "skip" else None
98
+ result.items[action.target_key] = _make_item(action, outcome, version=version)
99
+ if action.action == "skip":
100
+ satisfied.add(action.target_key)
101
+ emit(("done", action.resource_id, outcome))
102
+
103
+ download_actions = {
104
+ k: a for k, a in execution_plan.actions.items() if a.action == "download"
105
+ }
106
+ if not download_actions:
107
+ return result
108
+
109
+ sem = asyncio.Semaphore(DOWNLOAD_CONCURRENCY)
110
+ gate: dict[str, asyncio.Event] = {k: asyncio.Event() for k in download_actions}
111
+
112
+ async def run_one(key: str, client: httpx.AsyncClient) -> None:
113
+ action = download_actions[key]
114
+ parent = action.parent_target_key
115
+ try:
116
+ if parent and parent in gate:
117
+ await gate[parent].wait()
118
+
119
+ if parent and parent not in satisfied:
120
+ result.items[key] = _make_item(action, "blocked", reason="parent_failed")
121
+ emit(("done", action.resource_id, "blocked"))
122
+ return
123
+
124
+ if action.artifact is None or action.resolved_path is None:
125
+ result.items[key] = _make_item(
126
+ action, "error", error="missing artifact or resolved path",
127
+ )
128
+ emit(("done", action.resource_id, "error"))
129
+ return
130
+
131
+ async with sem:
132
+ emit(("start", action.resource_id, action.title))
133
+ ok, error_detail = await _do_download(client, action)
134
+
135
+ if ok:
136
+ result.items[key] = _make_item(
137
+ action, "downloaded", version=action.remote_version,
138
+ )
139
+ satisfied.add(key)
140
+ if on_download_success:
141
+ await on_download_success(
142
+ desired_set.install_targets[key],
143
+ action,
144
+ remote_snapshot.resources[action.resource_id],
145
+ )
146
+ emit(("done", action.resource_id, "downloaded"))
147
+ else:
148
+ result.items[key] = _make_item(action, "error", error=error_detail)
149
+ emit(("done", action.resource_id, "error"))
150
+ finally:
151
+ gate[key].set()
152
+
153
+ async with httpx.AsyncClient(
154
+ headers=headers,
155
+ http2=True,
156
+ timeout=60.0,
157
+ limits=httpx.Limits(
158
+ max_connections=DOWNLOAD_CONCURRENCY,
159
+ max_keepalive_connections=DOWNLOAD_CONCURRENCY,
160
+ ),
161
+ ) as client:
162
+ await asyncio.gather(*(run_one(k, client) for k in download_actions))
163
+
164
+ return result
@@ -0,0 +1,196 @@
1
+ """Decides download/skip/block actions for the planned sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from redfetch import config
6
+ from redfetch.sync_discovery import (
7
+ is_special_resource,
8
+ resolve_dependency_path,
9
+ resolve_root_path,
10
+ )
11
+ from redfetch.sync_types import (
12
+ ActionType,
13
+ DesiredInstallTarget,
14
+ DesiredSet,
15
+ ExecutionPlan,
16
+ LocalInstallState,
17
+ LocalSnapshot,
18
+ PlannedAction,
19
+ PlanReason,
20
+ RemoteResourceState,
21
+ RemoteSnapshot,
22
+ parse_target_key,
23
+ target_depth,
24
+ )
25
+
26
+
27
+ BLOCKING_STATUSES = {"access_denied", "no_files", "multiple_files", "not_found", "fetch_error"}
28
+
29
+
30
+ def _desired_targets_in_order(desired_set: DesiredSet) -> list[DesiredInstallTarget]:
31
+ """Sort targets so parents are always planned before their children."""
32
+ return sorted(
33
+ desired_set.install_targets.values(),
34
+ key=lambda target: (target_depth(target.target_key), target.target_key),
35
+ )
36
+
37
+
38
+ def _cycle_block_keys(desired_set: DesiredSet) -> set[str]:
39
+ """Block targets that would cause circular dependency loops."""
40
+ cycle_keys: set[str] = set()
41
+ for target in desired_set.install_targets.values():
42
+ segments = parse_target_key(target.target_key)
43
+ if len(segments) == len(set(segments)):
44
+ continue
45
+ repeated_resource_id = segments[-1]
46
+ first_index = segments[:-1].index(repeated_resource_id)
47
+ first_blocked_prefix = first_index + 2
48
+ for prefix_length in range(first_blocked_prefix, len(segments) + 1):
49
+ candidate_key = f"/{'/'.join(segments[:prefix_length])}/"
50
+ if candidate_key in desired_set.install_targets:
51
+ cycle_keys.add(candidate_key)
52
+ return cycle_keys
53
+
54
+
55
+ def _resolve_target_path(
56
+ target: DesiredInstallTarget,
57
+ *,
58
+ parent_action: PlannedAction | None,
59
+ remote_state: RemoteResourceState | None,
60
+ settings_env: str,
61
+ ) -> tuple[str | None, str | None]:
62
+ """Figure out where on disk this target should be installed."""
63
+ if target.resolved_path is not None:
64
+ return target.resolved_path, target.subfolder
65
+
66
+ if target.target_kind == "root":
67
+ category_id = target.category_id
68
+ if category_id is None and remote_state is not None:
69
+ category_id = remote_state.category_id
70
+ if not is_special_resource(target.resource_id, settings_env):
71
+ if category_id is None or category_id not in config.CATEGORY_MAP:
72
+ return None, target.subfolder
73
+ return resolve_root_path(target.resource_id, category_id, settings_env), None
74
+
75
+ if parent_action is None or not parent_action.resolved_path or target.parent_id is None:
76
+ return None, target.subfolder
77
+ resolved_path, subfolder = resolve_dependency_path(
78
+ parent_action.resolved_path,
79
+ target.parent_id,
80
+ target.resource_id,
81
+ settings_env,
82
+ )
83
+ return resolved_path, (target.subfolder or subfolder)
84
+
85
+
86
+ def _install_context_changed(
87
+ local_state: LocalInstallState | None,
88
+ *,
89
+ resolved_path: str | None,
90
+ subfolder: str | None,
91
+ target: DesiredInstallTarget,
92
+ ) -> bool:
93
+ """Check if path, subfolder, or install settings have changed since the last sync."""
94
+ if local_state is None:
95
+ return False
96
+ return (
97
+ local_state.resolved_path != resolved_path
98
+ or local_state.subfolder != subfolder
99
+ or local_state.flatten != target.flatten
100
+ or local_state.protected_files != target.protected_files
101
+ )
102
+
103
+
104
+ def _decide_action(
105
+ target: DesiredInstallTarget,
106
+ *,
107
+ local_state: LocalInstallState | None,
108
+ remote_state: RemoteResourceState | None,
109
+ parent_action: PlannedAction | None,
110
+ cycle_block_keys: set[str],
111
+ resolved_path: str | None,
112
+ subfolder: str | None,
113
+ ) -> tuple[ActionType, PlanReason]:
114
+ """Decide whether a single target should be downloaded, skipped, or blocked, and why."""
115
+ if target.target_key in cycle_block_keys:
116
+ return "block", "dependency_cycle"
117
+ if target.parent_target_key and (
118
+ parent_action is None or parent_action.action in {"block", "untrack"}
119
+ ):
120
+ return "block", "parent_blocked"
121
+ if target.discovery_block:
122
+ return "block", target.discovery_block
123
+ if remote_state is None:
124
+ return "block", "fetch_error"
125
+ if remote_state.status in BLOCKING_STATUSES:
126
+ return "block", remote_state.status # type: ignore[return-value]
127
+ if resolved_path is None and target.target_kind == "root":
128
+ return "block", "unknown_category"
129
+ if _install_context_changed(
130
+ local_state, resolved_path=resolved_path, subfolder=subfolder, target=target,
131
+ ):
132
+ return "download", "install_context_changed"
133
+ if (
134
+ local_state is not None
135
+ and remote_state.version_id is not None
136
+ and local_state.version_local == remote_state.version_id
137
+ ):
138
+ return "skip", "already_current"
139
+ if local_state is None or local_state.version_local is None:
140
+ return "download", "not_installed"
141
+ return "download", "outdated"
142
+
143
+
144
+ def build_execution_plan(
145
+ *,
146
+ desired_set: DesiredSet,
147
+ remote_snapshot: RemoteSnapshot,
148
+ local_snapshot: LocalSnapshot,
149
+ settings_env: str,
150
+ ) -> ExecutionPlan:
151
+ """Main planner entry point: decide the action for every target, then mark untracked leftovers."""
152
+ actions: dict[str, PlannedAction] = {}
153
+ cycle_keys = _cycle_block_keys(desired_set)
154
+
155
+ for target in _desired_targets_in_order(desired_set):
156
+ local_state = local_snapshot.install_targets.get(target.target_key)
157
+ remote_state = remote_snapshot.resources.get(target.resource_id)
158
+ parent_action = actions.get(target.parent_target_key) if target.parent_target_key else None
159
+ resolved_path, subfolder = _resolve_target_path(
160
+ target,
161
+ parent_action=parent_action,
162
+ remote_state=remote_state,
163
+ settings_env=settings_env,
164
+ )
165
+ action, reason = _decide_action(
166
+ target,
167
+ local_state=local_state,
168
+ remote_state=remote_state,
169
+ parent_action=parent_action,
170
+ cycle_block_keys=cycle_keys,
171
+ resolved_path=resolved_path,
172
+ subfolder=subfolder,
173
+ )
174
+ actions[target.target_key] = PlannedAction.from_desired(
175
+ target,
176
+ action=action,
177
+ reason=reason,
178
+ title=remote_state.title if remote_state else target.title,
179
+ category_id=remote_state.category_id if remote_state else target.category_id,
180
+ remote_version=remote_state.version_id if remote_state else None,
181
+ artifact=remote_state.artifact if remote_state and action == "download" else None,
182
+ resolved_path=resolved_path,
183
+ subfolder=subfolder,
184
+ )
185
+
186
+ if desired_set.mode == "full":
187
+ local_scope = local_snapshot.install_targets.values()
188
+ else:
189
+ local_scope = local_snapshot.roots_in_closure(desired_set.requested_root_ids)
190
+
191
+ for local_state in local_scope:
192
+ if local_state.target_key in actions:
193
+ continue
194
+ actions[local_state.target_key] = PlannedAction.untrack_from_local(local_state)
195
+
196
+ return ExecutionPlan(actions=actions)
@@ -0,0 +1,182 @@
1
+ """Fetches info about resources from the website."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import NamedTuple
6
+
7
+ import httpx
8
+
9
+ from redfetch import api
10
+ from redfetch import net
11
+ from redfetch.sync_discovery import payload_category_id, payload_title, payload_version_id
12
+ from redfetch.sync_types import (
13
+ DesiredInstallTarget,
14
+ DesiredSet,
15
+ LocalSnapshot,
16
+ RemoteArtifact,
17
+ RemoteResourceState,
18
+ RemoteSnapshot,
19
+ )
20
+
21
+
22
+ def _normalize_hash(raw_hash: str | None) -> str | None:
23
+ """Sanitize the xenforo file hash to a standard format."""
24
+ if not raw_hash:
25
+ return None
26
+ cleaned = str(raw_hash).strip().lower()
27
+ if len(cleaned) == 32 and all(char in "0123456789abcdef" for char in cleaned):
28
+ return cleaned
29
+ return None
30
+
31
+
32
+ class _PayloadDetails(NamedTuple):
33
+ """Bundle of fields extracted from the API for building a RemoteResourceState."""
34
+ title: str | None
35
+ category_id: int | None
36
+ version_id: int | None
37
+ artifact: RemoteArtifact | None
38
+
39
+
40
+ def _extract_artifact(payload: dict) -> RemoteArtifact | None:
41
+ """Extract downloadable file info."""
42
+ current_files = payload.get("current_files")
43
+ if not isinstance(current_files, list) or len(current_files) != 1:
44
+ return None
45
+ file_info = current_files[0]
46
+ if file_info.get("id") is None or not file_info.get("filename") or not file_info.get("download_url"):
47
+ return None
48
+ return RemoteArtifact(
49
+ file_id=int(file_info["id"]),
50
+ filename=str(file_info["filename"]),
51
+ download_url=str(file_info["download_url"]),
52
+ file_hash=_normalize_hash(file_info.get("hash")),
53
+ )
54
+
55
+
56
+ def _payload_details(payload: dict | None) -> _PayloadDetails:
57
+ """Bundle all fields extracted from a raw API payload."""
58
+ if not payload:
59
+ return _PayloadDetails(None, None, None, None)
60
+ return _PayloadDetails(
61
+ title=payload_title(payload),
62
+ category_id=payload_category_id(payload),
63
+ version_id=payload_version_id(payload),
64
+ artifact=_extract_artifact(payload),
65
+ )
66
+
67
+
68
+ def _needs_live_check(
69
+ *,
70
+ desired_targets: list[DesiredInstallTarget],
71
+ local_snapshot: LocalSnapshot,
72
+ manifest_details: _PayloadDetails | None,
73
+ ) -> bool:
74
+ """Return True if the resource needs an update, or data is incomplete, or config has changed."""
75
+ if manifest_details is None or manifest_details.version_id is None or manifest_details.artifact is None:
76
+ return True
77
+
78
+ for target in desired_targets:
79
+ local_state = local_snapshot.install_targets.get(target.target_key)
80
+ if local_state is None:
81
+ return True
82
+ if local_state.version_local is None:
83
+ return True
84
+ if local_state.version_local != manifest_details.version_id:
85
+ return True
86
+ if (
87
+ local_state.resolved_path != target.resolved_path
88
+ or local_state.subfolder != target.subfolder
89
+ or local_state.flatten != target.flatten
90
+ or local_state.protected_files != target.protected_files
91
+ ):
92
+ return True
93
+
94
+ return False
95
+
96
+
97
+ def _blocked_state(
98
+ resource_id: str,
99
+ *,
100
+ status: str,
101
+ manifest_details: _PayloadDetails | None,
102
+ live_title: str | None = None,
103
+ live_category_id: int | None = None,
104
+ ) -> RemoteResourceState:
105
+ """Build a RemoteResourceState for a resource that is blocked."""
106
+ return RemoteResourceState(
107
+ resource_id=resource_id,
108
+ title=(manifest_details.title if manifest_details else None) or live_title,
109
+ category_id=(manifest_details.category_id if manifest_details and manifest_details.category_id is not None else live_category_id),
110
+ version_id=manifest_details.version_id if manifest_details else None,
111
+ status=status,
112
+ artifact=None,
113
+ source_note="manifest" if manifest_details else "live_access_only",
114
+ )
115
+
116
+
117
+ async def fetch_remote_snapshot(
118
+ *,
119
+ client: httpx.AsyncClient,
120
+ desired_set: DesiredSet,
121
+ local_snapshot: LocalSnapshot,
122
+ ) -> RemoteSnapshot:
123
+ """Assemble the remote half of the sync picture: version, status, and download info per resource."""
124
+ manifest = await net.fetch_manifest_cached(client)
125
+ manifest_resources = manifest.get("resources", {}) or {}
126
+
127
+ remote_resources: dict[str, RemoteResourceState] = {}
128
+ ids_needing_live_check: list[str] = []
129
+ manifest_cache: dict[str, _PayloadDetails | None] = {}
130
+
131
+ for resource_id in desired_set.resource_ids:
132
+ manifest_entry = manifest_resources.get(resource_id)
133
+ manifest_details = _payload_details(manifest_entry) if manifest_entry else None
134
+ manifest_cache[resource_id] = manifest_details
135
+ desired_targets = desired_set.resource_targets(resource_id)
136
+ if _needs_live_check(
137
+ desired_targets=desired_targets,
138
+ local_snapshot=local_snapshot,
139
+ manifest_details=manifest_details,
140
+ ):
141
+ ids_needing_live_check.append(resource_id)
142
+ continue
143
+
144
+ remote_resources[resource_id] = RemoteResourceState(
145
+ resource_id=resource_id,
146
+ title=manifest_details.title,
147
+ category_id=manifest_details.category_id,
148
+ version_id=manifest_details.version_id,
149
+ status="manifest_current",
150
+ artifact=manifest_details.artifact,
151
+ source_note="manifest_only",
152
+ )
153
+
154
+ if ids_needing_live_check:
155
+ live_records = await api.fetch_resource_records_batch(client, ids_needing_live_check)
156
+ for record in live_records:
157
+ resource_id = record.resource_id
158
+ manifest_details = manifest_cache.get(resource_id)
159
+ live_title = payload_title(record.resource)
160
+ live_category_id = payload_category_id(record.resource)
161
+
162
+ if record.status != "downloadable":
163
+ remote_resources[resource_id] = _blocked_state(
164
+ resource_id,
165
+ status=record.status,
166
+ manifest_details=manifest_details,
167
+ live_title=live_title,
168
+ live_category_id=live_category_id,
169
+ )
170
+ continue
171
+
172
+ remote_resources[resource_id] = RemoteResourceState(
173
+ resource_id=resource_id,
174
+ title=(manifest_details.title if manifest_details else None) or live_title,
175
+ category_id=(manifest_details.category_id if manifest_details and manifest_details.category_id is not None else live_category_id),
176
+ version_id=manifest_details.version_id if manifest_details else None,
177
+ status="downloadable",
178
+ artifact=manifest_details.artifact if manifest_details else None,
179
+ source_note="manifest_plus_access_check" if manifest_details else "live_access_only",
180
+ )
181
+
182
+ return RemoteSnapshot(resources=remote_resources)