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/__about__.py +24 -0
- redfetch/__init__.py +0 -0
- redfetch/api.py +134 -0
- redfetch/auth.py +573 -0
- redfetch/config.py +483 -0
- redfetch/config_firstrun.py +455 -0
- redfetch/desktop_shortcut.py +81 -0
- redfetch/detecteq.py +116 -0
- redfetch/download.py +312 -0
- redfetch/listener.py +216 -0
- redfetch/main.py +744 -0
- redfetch/meta.py +561 -0
- redfetch/navmesh.py +371 -0
- redfetch/net.py +109 -0
- redfetch/processes.py +118 -0
- redfetch/push.py +246 -0
- redfetch/redfetch.ico +0 -0
- redfetch/runtime_errors.py +96 -0
- redfetch/settings.toml +303 -0
- redfetch/special.py +81 -0
- redfetch/store.py +505 -0
- redfetch/sync.py +261 -0
- redfetch/sync_discovery.py +352 -0
- redfetch/sync_executor.py +164 -0
- redfetch/sync_planner.py +196 -0
- redfetch/sync_remote.py +182 -0
- redfetch/sync_types.py +348 -0
- redfetch/terminal_ui.py +2318 -0
- redfetch/terminal_ui.tcss +569 -0
- redfetch/unloadmq.py +148 -0
- redfetch/update_status.py +62 -0
- redfetch/utils.py +256 -0
- redfetch-1.3.0.dist-info/METADATA +257 -0
- redfetch-1.3.0.dist-info/RECORD +37 -0
- redfetch-1.3.0.dist-info/WHEEL +4 -0
- redfetch-1.3.0.dist-info/entry_points.txt +2 -0
- redfetch-1.3.0.dist-info/licenses/LICENSE +674 -0
redfetch/sync_types.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Models and data types shared across the sync pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SyncMode = Literal["full", "targeted"]
|
|
13
|
+
DesiredSource = Literal["watching", "licensed", "special", "explicit", "dependency"]
|
|
14
|
+
TargetKind = Literal["root", "dependency"]
|
|
15
|
+
RemoteStatus = Literal[
|
|
16
|
+
"manifest_current",
|
|
17
|
+
"downloadable",
|
|
18
|
+
"access_denied",
|
|
19
|
+
"no_files",
|
|
20
|
+
"multiple_files",
|
|
21
|
+
"not_found",
|
|
22
|
+
"fetch_error",
|
|
23
|
+
]
|
|
24
|
+
ActionType = Literal["download", "skip", "block", "untrack"]
|
|
25
|
+
PlanReason = Literal[
|
|
26
|
+
"outdated",
|
|
27
|
+
"not_installed",
|
|
28
|
+
"already_current",
|
|
29
|
+
"install_context_changed",
|
|
30
|
+
"not_desired",
|
|
31
|
+
"access_denied",
|
|
32
|
+
"no_files",
|
|
33
|
+
"multiple_files",
|
|
34
|
+
"not_found",
|
|
35
|
+
"fetch_error",
|
|
36
|
+
"parent_blocked",
|
|
37
|
+
"parent_failed",
|
|
38
|
+
"dependency_cycle",
|
|
39
|
+
"unknown_category",
|
|
40
|
+
"license_expired",
|
|
41
|
+
]
|
|
42
|
+
ResultOutcome = Literal["downloaded", "skipped", "blocked", "untracked", "error"]
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ReasonInfo:
|
|
46
|
+
"""Display metadata for a single PlanReason value."""
|
|
47
|
+
|
|
48
|
+
message: str
|
|
49
|
+
quiet: bool = False
|
|
50
|
+
summary_label: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
PLAN_REASON_META: dict[PlanReason, ReasonInfo] = {
|
|
54
|
+
"access_denied": ReasonInfo("You don't have permission to download this resource."),
|
|
55
|
+
"no_files": ReasonInfo("This resource has no downloadable files.", quiet=True, summary_label="Resources with no files"),
|
|
56
|
+
"multiple_files": ReasonInfo("This resource has multiple files and cannot be auto-synced. Ask the author to release it as a .zip file."),
|
|
57
|
+
"not_found": ReasonInfo("This resource was not found."),
|
|
58
|
+
"fetch_error": ReasonInfo("Failed to retrieve this resource from the server."),
|
|
59
|
+
"unknown_category": ReasonInfo("This resource's category is not mapped to an install location, but you can specify one manually in settings.local.toml"),
|
|
60
|
+
"parent_blocked": ReasonInfo("Skipped because its parent resource is blocked.", quiet=True),
|
|
61
|
+
"parent_failed": ReasonInfo("Skipped because its parent resource failed to download."),
|
|
62
|
+
"dependency_cycle": ReasonInfo("Skipped due to a circular dependency."),
|
|
63
|
+
"not_desired": ReasonInfo("No longer watched or licensed; untracking."),
|
|
64
|
+
"outdated": ReasonInfo("A newer version is available."),
|
|
65
|
+
"not_installed": ReasonInfo("Not yet installed locally."),
|
|
66
|
+
"already_current": ReasonInfo("Already up to date."),
|
|
67
|
+
"install_context_changed": ReasonInfo("Install location or settings changed; re-downloading."),
|
|
68
|
+
"license_expired": ReasonInfo("Your license for this resource has expired.", quiet=True, summary_label="Licenses expired"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def reason_message(reason: PlanReason) -> str:
|
|
73
|
+
meta = PLAN_REASON_META.get(reason)
|
|
74
|
+
return meta.message if meta else reason
|
|
75
|
+
|
|
76
|
+
SyncEvent = (
|
|
77
|
+
tuple[Literal["total"], int, None]
|
|
78
|
+
| tuple[Literal["add_total"], int, None]
|
|
79
|
+
| tuple[Literal["start"], str, str | None]
|
|
80
|
+
| tuple[Literal["done"], str, ResultOutcome]
|
|
81
|
+
)
|
|
82
|
+
SyncEventCallback = Callable[[SyncEvent], None]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def make_root_target_key(resource_id: str) -> str:
|
|
86
|
+
return f"/{resource_id}/"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_child_target_key(parent_target_key: str, resource_id: str) -> str:
|
|
90
|
+
if not parent_target_key.startswith("/") or not parent_target_key.endswith("/"):
|
|
91
|
+
raise ValueError(f"Invalid parent target key: {parent_target_key}")
|
|
92
|
+
return f"{parent_target_key}{resource_id}/"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_target_key(target_key: str) -> list[str]:
|
|
96
|
+
if not target_key.startswith("/") or not target_key.endswith("/"):
|
|
97
|
+
raise ValueError(f"Invalid target key: {target_key}")
|
|
98
|
+
return [segment for segment in target_key.strip("/").split("/") if segment]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def target_depth(target_key: str) -> int:
|
|
102
|
+
return len(parse_target_key(target_key))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SyncModel(BaseModel):
|
|
106
|
+
"""Defensive, allows only known fields to be set."""
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(extra="forbid")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TargetIdentity(SyncModel):
|
|
112
|
+
"""Which target is this? Identifies a single install target"""
|
|
113
|
+
|
|
114
|
+
target_key: str
|
|
115
|
+
resource_id: str
|
|
116
|
+
parent_id: str | None = None
|
|
117
|
+
parent_target_key: str | None = None
|
|
118
|
+
root_resource_id: str
|
|
119
|
+
target_kind: TargetKind
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DesiredInstallTarget(TargetIdentity):
|
|
124
|
+
"""One concrete place a resource should be installed this run."""
|
|
125
|
+
|
|
126
|
+
sources: set[DesiredSource] = Field(default_factory=set)
|
|
127
|
+
title: str | None = None
|
|
128
|
+
category_id: int | None = None
|
|
129
|
+
resolved_path: str | None = None
|
|
130
|
+
subfolder: str | None = None
|
|
131
|
+
flatten: bool = False
|
|
132
|
+
protected_files: list[str] = Field(default_factory=list)
|
|
133
|
+
explicit_root: bool = False
|
|
134
|
+
discovery_block: PlanReason | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class DesiredSet(SyncModel):
|
|
138
|
+
"""The full discovered target set for the run."""
|
|
139
|
+
|
|
140
|
+
mode: SyncMode
|
|
141
|
+
requested_root_ids: set[str] = Field(default_factory=set)
|
|
142
|
+
resource_ids: set[str] = Field(default_factory=set)
|
|
143
|
+
install_targets: dict[str, DesiredInstallTarget] = Field(default_factory=dict)
|
|
144
|
+
|
|
145
|
+
def add_target(self, target: DesiredInstallTarget) -> DesiredInstallTarget:
|
|
146
|
+
self.resource_ids.add(target.resource_id)
|
|
147
|
+
existing = self.install_targets.get(target.target_key)
|
|
148
|
+
if existing is None:
|
|
149
|
+
self.install_targets[target.target_key] = target
|
|
150
|
+
return target
|
|
151
|
+
|
|
152
|
+
# Same target_key seen again (e.g. both watched and licensed) -- merge
|
|
153
|
+
existing.sources.update(target.sources)
|
|
154
|
+
existing.title = existing.title or target.title
|
|
155
|
+
existing.category_id = existing.category_id if existing.category_id is not None else target.category_id
|
|
156
|
+
existing.resolved_path = existing.resolved_path or target.resolved_path
|
|
157
|
+
existing.subfolder = existing.subfolder or target.subfolder
|
|
158
|
+
existing.flatten = existing.flatten or target.flatten
|
|
159
|
+
existing.protected_files = existing.protected_files or target.protected_files
|
|
160
|
+
existing.explicit_root = existing.explicit_root or target.explicit_root
|
|
161
|
+
existing.discovery_block = existing.discovery_block or target.discovery_block
|
|
162
|
+
return existing
|
|
163
|
+
|
|
164
|
+
def resource_targets(self, resource_id: str) -> list[DesiredInstallTarget]:
|
|
165
|
+
return [
|
|
166
|
+
target
|
|
167
|
+
for target in self.install_targets.values()
|
|
168
|
+
if target.resource_id == resource_id
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class RemoteArtifact(SyncModel):
|
|
173
|
+
"""What to actually download for a resource."""
|
|
174
|
+
|
|
175
|
+
file_id: int
|
|
176
|
+
filename: str
|
|
177
|
+
download_url: str
|
|
178
|
+
file_hash: str | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class RemoteResourceState(SyncModel):
|
|
182
|
+
"""Server-side view of a resource: version, status, and optional artifact."""
|
|
183
|
+
|
|
184
|
+
resource_id: str
|
|
185
|
+
title: str | None = None
|
|
186
|
+
category_id: int | None = None
|
|
187
|
+
version_id: int | None = None
|
|
188
|
+
status: RemoteStatus
|
|
189
|
+
artifact: RemoteArtifact | None = None
|
|
190
|
+
source_note: str | None = None
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def is_resolved(self) -> bool:
|
|
194
|
+
return self.version_id is not None and self.artifact is not None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class RemoteSnapshot(SyncModel):
|
|
198
|
+
"""All remote resource states collected for this run."""
|
|
199
|
+
|
|
200
|
+
resources: dict[str, RemoteResourceState] = Field(default_factory=dict)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class LocalInstallState(TargetIdentity):
|
|
204
|
+
"""Local DB record for a single install target."""
|
|
205
|
+
|
|
206
|
+
title: str | None = None
|
|
207
|
+
category_id: int | None = None
|
|
208
|
+
version_local: int | None = None
|
|
209
|
+
version_remote: int | None = None
|
|
210
|
+
resolved_path: str | None = None
|
|
211
|
+
subfolder: str | None = None
|
|
212
|
+
flatten: bool = False
|
|
213
|
+
protected_files: list[str] = Field(default_factory=list)
|
|
214
|
+
is_special: bool = False
|
|
215
|
+
is_watching: bool = False
|
|
216
|
+
is_licensed: bool = False
|
|
217
|
+
is_explicit: bool = False
|
|
218
|
+
is_dependency: bool = False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class LocalSnapshot(SyncModel):
|
|
222
|
+
"""Everything we think is installed according to the DB."""
|
|
223
|
+
|
|
224
|
+
install_targets: dict[str, LocalInstallState] = Field(default_factory=dict)
|
|
225
|
+
|
|
226
|
+
def roots_in_closure(self, root_ids: set[str]) -> list[LocalInstallState]:
|
|
227
|
+
return [
|
|
228
|
+
state
|
|
229
|
+
for state in self.install_targets.values()
|
|
230
|
+
if state.root_resource_id in root_ids
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class PlannedAction(TargetIdentity):
|
|
235
|
+
"""What the planner decided to do with one install target, and why."""
|
|
236
|
+
|
|
237
|
+
action: ActionType
|
|
238
|
+
reason: PlanReason
|
|
239
|
+
title: str | None = None
|
|
240
|
+
category_id: int | None = None
|
|
241
|
+
remote_version: int | None = None
|
|
242
|
+
artifact: RemoteArtifact | None = None
|
|
243
|
+
resolved_path: str | None = None
|
|
244
|
+
subfolder: str | None = None
|
|
245
|
+
flatten: bool = False
|
|
246
|
+
protected_files: list[str] = Field(default_factory=list)
|
|
247
|
+
explicit_root: bool = False
|
|
248
|
+
|
|
249
|
+
@classmethod
|
|
250
|
+
def from_desired(
|
|
251
|
+
cls,
|
|
252
|
+
target: DesiredInstallTarget,
|
|
253
|
+
*,
|
|
254
|
+
action: ActionType,
|
|
255
|
+
reason: PlanReason,
|
|
256
|
+
title: str | None,
|
|
257
|
+
category_id: int | None,
|
|
258
|
+
remote_version: int | None,
|
|
259
|
+
artifact: RemoteArtifact | None,
|
|
260
|
+
resolved_path: str | None,
|
|
261
|
+
subfolder: str | None,
|
|
262
|
+
) -> PlannedAction:
|
|
263
|
+
return cls(
|
|
264
|
+
target_key=target.target_key,
|
|
265
|
+
resource_id=target.resource_id,
|
|
266
|
+
parent_id=target.parent_id,
|
|
267
|
+
parent_target_key=target.parent_target_key,
|
|
268
|
+
root_resource_id=target.root_resource_id,
|
|
269
|
+
target_kind=target.target_kind,
|
|
270
|
+
action=action,
|
|
271
|
+
reason=reason,
|
|
272
|
+
title=title,
|
|
273
|
+
category_id=category_id,
|
|
274
|
+
remote_version=remote_version,
|
|
275
|
+
artifact=artifact,
|
|
276
|
+
resolved_path=resolved_path,
|
|
277
|
+
subfolder=subfolder,
|
|
278
|
+
flatten=target.flatten,
|
|
279
|
+
protected_files=target.protected_files,
|
|
280
|
+
explicit_root=target.explicit_root,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@classmethod
|
|
284
|
+
def untrack_from_local(cls, local_state: LocalInstallState) -> PlannedAction:
|
|
285
|
+
return cls(
|
|
286
|
+
target_key=local_state.target_key,
|
|
287
|
+
resource_id=local_state.resource_id,
|
|
288
|
+
parent_id=local_state.parent_id,
|
|
289
|
+
parent_target_key=local_state.parent_target_key,
|
|
290
|
+
root_resource_id=local_state.root_resource_id,
|
|
291
|
+
target_kind=local_state.target_kind,
|
|
292
|
+
action="untrack",
|
|
293
|
+
reason="not_desired",
|
|
294
|
+
title=local_state.title,
|
|
295
|
+
category_id=local_state.category_id,
|
|
296
|
+
remote_version=local_state.version_remote,
|
|
297
|
+
artifact=None,
|
|
298
|
+
resolved_path=local_state.resolved_path,
|
|
299
|
+
subfolder=local_state.subfolder,
|
|
300
|
+
flatten=local_state.flatten,
|
|
301
|
+
protected_files=local_state.protected_files,
|
|
302
|
+
explicit_root=local_state.is_explicit,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class ExecutionPlan(SyncModel):
|
|
307
|
+
"""The full set of decisions handed to the executor."""
|
|
308
|
+
|
|
309
|
+
actions: dict[str, PlannedAction] = Field(default_factory=dict)
|
|
310
|
+
|
|
311
|
+
def action_counts(self) -> dict[str, int]:
|
|
312
|
+
counts = {"download": 0, "skip": 0, "block": 0, "untrack": 0}
|
|
313
|
+
for action in self.actions.values():
|
|
314
|
+
counts[action.action] += 1
|
|
315
|
+
return counts
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class ExecutionResultItem(SyncModel):
|
|
319
|
+
"""One target's result: what happened and why."""
|
|
320
|
+
|
|
321
|
+
target_key: str
|
|
322
|
+
resource_id: str
|
|
323
|
+
outcome: ResultOutcome
|
|
324
|
+
reason: PlanReason
|
|
325
|
+
written_version: int | None = None
|
|
326
|
+
error_detail: str | None = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class ExecutionResult(SyncModel):
|
|
330
|
+
"""Collected results handed to the recorder after execution."""
|
|
331
|
+
|
|
332
|
+
items: dict[str, ExecutionResultItem] = Field(default_factory=dict)
|
|
333
|
+
was_cancelled: bool = False
|
|
334
|
+
|
|
335
|
+
def has_errors(self) -> bool:
|
|
336
|
+
return self.was_cancelled or any(
|
|
337
|
+
item.outcome == "error" for item in self.items.values()
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(frozen=True, slots=True)
|
|
342
|
+
class PreparedSync:
|
|
343
|
+
"""The product of preparing a sync run, before any execution or DB writes."""
|
|
344
|
+
|
|
345
|
+
desired_set: DesiredSet
|
|
346
|
+
remote_snapshot: RemoteSnapshot
|
|
347
|
+
local_snapshot: LocalSnapshot
|
|
348
|
+
execution_plan: ExecutionPlan
|