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/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