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.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from redfetch import config
|
|
8
|
+
from redfetch import store
|
|
9
|
+
from redfetch import sync_discovery
|
|
10
|
+
from redfetch import sync_executor
|
|
11
|
+
from redfetch import sync_planner
|
|
12
|
+
from redfetch import sync_remote
|
|
13
|
+
from redfetch.sync_types import (
|
|
14
|
+
ExecutionPlan,
|
|
15
|
+
ExecutionResult,
|
|
16
|
+
PLAN_REASON_META,
|
|
17
|
+
PreparedSync,
|
|
18
|
+
ReasonInfo,
|
|
19
|
+
SyncEventCallback,
|
|
20
|
+
reason_message,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_sync_lock: asyncio.Lock | None = None
|
|
25
|
+
_DEFAULT_REASON = ReasonInfo(message="")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _print_plan_summary(
|
|
29
|
+
execution_plan: ExecutionPlan,
|
|
30
|
+
resource_ids: list[str] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
is_full_sync = resource_ids is None
|
|
33
|
+
all_blocked = [
|
|
34
|
+
action for action in execution_plan.actions.values()
|
|
35
|
+
if action.action == "block"
|
|
36
|
+
]
|
|
37
|
+
if is_full_sync:
|
|
38
|
+
quiet = [a for a in all_blocked if PLAN_REASON_META.get(a.reason, _DEFAULT_REASON).quiet]
|
|
39
|
+
blocked = [a for a in all_blocked if not PLAN_REASON_META.get(a.reason, _DEFAULT_REASON).quiet]
|
|
40
|
+
else:
|
|
41
|
+
quiet = []
|
|
42
|
+
blocked = all_blocked
|
|
43
|
+
|
|
44
|
+
counts = execution_plan.action_counts()
|
|
45
|
+
print(f"Resources in scope: >>> {len(execution_plan.actions)} <<<")
|
|
46
|
+
print(f"Resources to download: >>> {counts.get('download', 0)} <<<")
|
|
47
|
+
if blocked:
|
|
48
|
+
print(f"Resources blocked: >>> {len(blocked)} <<<")
|
|
49
|
+
for action in blocked:
|
|
50
|
+
label = action.title or action.resource_id
|
|
51
|
+
print(f" - {label} (id={action.resource_id}): {reason_message(action.reason)}")
|
|
52
|
+
|
|
53
|
+
summary_buckets: dict[str, int] = {}
|
|
54
|
+
for action in quiet:
|
|
55
|
+
label = PLAN_REASON_META[action.reason].summary_label
|
|
56
|
+
if label:
|
|
57
|
+
summary_buckets[label] = summary_buckets.get(label, 0) + 1
|
|
58
|
+
for label, count in summary_buckets.items():
|
|
59
|
+
print(f"{label}: >>> {count} <<<")
|
|
60
|
+
|
|
61
|
+
if counts.get("untrack", 0):
|
|
62
|
+
print(f"Resources to untrack: >>> {counts.get('untrack', 0)} <<<")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _print_failure_detail(
|
|
66
|
+
execution_plan: ExecutionPlan,
|
|
67
|
+
resource_ids: list[str],
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Print a hint when a targeted sync fails with zero scoped actions (resource not in scope at all)."""
|
|
70
|
+
requested = {str(rid) for rid in resource_ids}
|
|
71
|
+
has_scoped_actions = any(
|
|
72
|
+
action.root_resource_id in requested
|
|
73
|
+
for action in execution_plan.actions.values()
|
|
74
|
+
)
|
|
75
|
+
if not has_scoped_actions:
|
|
76
|
+
print(
|
|
77
|
+
f"No valid resources found for IDs: {resource_ids}. "
|
|
78
|
+
"Are you in the right server env? Did you opt_in in your settings.local.toml?"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _run_succeeded(
|
|
83
|
+
*,
|
|
84
|
+
execution_plan: ExecutionPlan,
|
|
85
|
+
execution_result: ExecutionResult,
|
|
86
|
+
resource_ids: list[str] | None,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Targeted sync succeeds when there are no errors, at least one explicit root exists, and no target in the requested closure is blocked."""
|
|
89
|
+
if execution_result.has_errors():
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
if resource_ids is None:
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
requested_root_ids = {str(resource_id) for resource_id in resource_ids}
|
|
96
|
+
scoped_actions = [
|
|
97
|
+
action
|
|
98
|
+
for action in execution_plan.actions.values()
|
|
99
|
+
if action.root_resource_id in requested_root_ids
|
|
100
|
+
]
|
|
101
|
+
if not scoped_actions:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
if not any(action.explicit_root for action in scoped_actions):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
for action in scoped_actions:
|
|
108
|
+
item = execution_result.items.get(action.target_key)
|
|
109
|
+
if item is None or item.outcome == "blocked":
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def prepare_sync(
|
|
115
|
+
db_path: str,
|
|
116
|
+
headers: dict,
|
|
117
|
+
resource_ids: list[str] | None = None,
|
|
118
|
+
) -> PreparedSync:
|
|
119
|
+
"""Run discovery -> remote snapshot -> planning, returning the plan without executing it."""
|
|
120
|
+
settings_env = config.settings.ENV
|
|
121
|
+
local_snapshot = await store.load_local_snapshot(db_path)
|
|
122
|
+
|
|
123
|
+
async with httpx.AsyncClient(
|
|
124
|
+
headers=headers,
|
|
125
|
+
http2=True,
|
|
126
|
+
timeout=30.0,
|
|
127
|
+
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
|
128
|
+
) as client:
|
|
129
|
+
desired_set = await sync_discovery.discover_desired_set(
|
|
130
|
+
client=client,
|
|
131
|
+
resource_ids=resource_ids,
|
|
132
|
+
settings_env=settings_env,
|
|
133
|
+
)
|
|
134
|
+
remote_snapshot = await sync_remote.fetch_remote_snapshot(
|
|
135
|
+
client=client,
|
|
136
|
+
desired_set=desired_set,
|
|
137
|
+
local_snapshot=local_snapshot,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
execution_plan = sync_planner.build_execution_plan(
|
|
141
|
+
desired_set=desired_set,
|
|
142
|
+
remote_snapshot=remote_snapshot,
|
|
143
|
+
local_snapshot=local_snapshot,
|
|
144
|
+
settings_env=settings_env,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return PreparedSync(
|
|
148
|
+
desired_set=desired_set,
|
|
149
|
+
remote_snapshot=remote_snapshot,
|
|
150
|
+
local_snapshot=local_snapshot,
|
|
151
|
+
execution_plan=execution_plan,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def sync(
|
|
156
|
+
db_path: str,
|
|
157
|
+
headers: dict,
|
|
158
|
+
resource_ids: list[str] | None = None,
|
|
159
|
+
on_event: SyncEventCallback | None = None,
|
|
160
|
+
) -> bool:
|
|
161
|
+
"""Discover, plan, and execute a sync run against the API."""
|
|
162
|
+
prepared = await prepare_sync(db_path, headers, resource_ids=resource_ids)
|
|
163
|
+
desired_set = prepared.desired_set
|
|
164
|
+
remote_snapshot = prepared.remote_snapshot
|
|
165
|
+
local_snapshot = prepared.local_snapshot
|
|
166
|
+
execution_plan = prepared.execution_plan
|
|
167
|
+
|
|
168
|
+
_print_plan_summary(execution_plan, resource_ids=resource_ids)
|
|
169
|
+
if on_event:
|
|
170
|
+
on_event(("total", len(execution_plan.actions), None))
|
|
171
|
+
|
|
172
|
+
execution_result = await sync_executor.execute_plan(
|
|
173
|
+
headers=headers,
|
|
174
|
+
desired_set=desired_set,
|
|
175
|
+
remote_snapshot=remote_snapshot,
|
|
176
|
+
execution_plan=execution_plan,
|
|
177
|
+
on_event=on_event,
|
|
178
|
+
on_download_success=lambda target, action, remote: store.record_download_success(
|
|
179
|
+
db_path,
|
|
180
|
+
target=target,
|
|
181
|
+
action=action,
|
|
182
|
+
remote_state=remote,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
await store.record_installed_state(
|
|
188
|
+
db_path,
|
|
189
|
+
desired_set=desired_set,
|
|
190
|
+
remote_snapshot=remote_snapshot,
|
|
191
|
+
local_snapshot=local_snapshot,
|
|
192
|
+
execution_plan=execution_plan,
|
|
193
|
+
execution_result=execution_result,
|
|
194
|
+
)
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
print(f"Warning: failed to record sync state: {exc}")
|
|
197
|
+
|
|
198
|
+
success = _run_succeeded(
|
|
199
|
+
execution_plan=execution_plan,
|
|
200
|
+
execution_result=execution_result,
|
|
201
|
+
resource_ids=resource_ids,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if execution_result.has_errors():
|
|
205
|
+
errored_items = [
|
|
206
|
+
item
|
|
207
|
+
for item in execution_result.items.values()
|
|
208
|
+
if item.outcome == "error"
|
|
209
|
+
]
|
|
210
|
+
if errored_items:
|
|
211
|
+
print("One or more resources failed to download.")
|
|
212
|
+
for item in errored_items:
|
|
213
|
+
detail = f": {item.error_detail}" if item.error_detail else ""
|
|
214
|
+
print(f" - {item.resource_id}{detail}")
|
|
215
|
+
elif resource_ids is not None and not success:
|
|
216
|
+
_print_failure_detail(execution_plan, resource_ids)
|
|
217
|
+
elif any(item.outcome == "downloaded" for item in execution_result.items.values()):
|
|
218
|
+
print("All resources downloaded successfully.")
|
|
219
|
+
else:
|
|
220
|
+
print("All resources are up-to-date; no downloads were necessary.")
|
|
221
|
+
|
|
222
|
+
return success
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def run_sync(
|
|
226
|
+
db_path: str,
|
|
227
|
+
headers: dict,
|
|
228
|
+
resource_ids: list[str] | None = None,
|
|
229
|
+
on_event: SyncEventCallback | None = None,
|
|
230
|
+
navmesh_override: bool | None = None,
|
|
231
|
+
) -> bool:
|
|
232
|
+
"""Top-level entry point: run the sync pipeline under a global lock, then navmesh if applicable."""
|
|
233
|
+
global _sync_lock
|
|
234
|
+
if _sync_lock is None:
|
|
235
|
+
_sync_lock = asyncio.Lock()
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
async with _sync_lock:
|
|
239
|
+
result = await sync(
|
|
240
|
+
db_path,
|
|
241
|
+
headers,
|
|
242
|
+
resource_ids=resource_ids,
|
|
243
|
+
on_event=on_event,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if resource_ids is None:
|
|
247
|
+
from redfetch import navmesh
|
|
248
|
+
|
|
249
|
+
navmesh_ok = await navmesh.sync_navmeshes(
|
|
250
|
+
db_path,
|
|
251
|
+
headers,
|
|
252
|
+
on_event=on_event,
|
|
253
|
+
override=navmesh_override,
|
|
254
|
+
)
|
|
255
|
+
if not navmesh_ok:
|
|
256
|
+
print("navmesh sync encountered errors")
|
|
257
|
+
|
|
258
|
+
return result
|
|
259
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
260
|
+
print("Download cancelled by user.")
|
|
261
|
+
return False
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Builds the desired install-target set from resources that are watched, licensed, special, etc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from redfetch import api
|
|
14
|
+
from redfetch import config
|
|
15
|
+
from redfetch import utils
|
|
16
|
+
from redfetch.sync_types import (
|
|
17
|
+
DesiredInstallTarget,
|
|
18
|
+
DesiredSet,
|
|
19
|
+
make_child_target_key,
|
|
20
|
+
make_root_target_key,
|
|
21
|
+
parse_target_key,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Path resolution helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def is_special_resource(resource_id: str, settings_env: str) -> bool:
|
|
30
|
+
"""Check if a resource is configured in SPECIAL_RESOURCES for the given environment."""
|
|
31
|
+
return str(resource_id) in config.settings.from_env(settings_env).SPECIAL_RESOURCES
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_dependency_details(parent_id: str, resource_id: str, settings_env: str) -> dict[str, Any]:
|
|
35
|
+
settings_for_env = config.settings.from_env(settings_env)
|
|
36
|
+
parent_settings = settings_for_env.SPECIAL_RESOURCES.get(str(parent_id), {})
|
|
37
|
+
dependencies = parent_settings.get("dependencies", {})
|
|
38
|
+
return dependencies.get(str(resource_id), {})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_dependency_path(parent_target_path: str, parent_id: str, resource_id: str, settings_env: str) -> tuple[str, str | None]:
|
|
42
|
+
dependency_details = _get_dependency_details(parent_id, resource_id, settings_env)
|
|
43
|
+
subfolder = dependency_details.get("subfolder", "") or ""
|
|
44
|
+
resolved_path = os.path.join(parent_target_path, subfolder) if subfolder else parent_target_path
|
|
45
|
+
return os.path.normpath(resolved_path), (subfolder or None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_protected_files(resource_id: str, settings_env: str) -> list[str]:
|
|
49
|
+
settings_for_env = config.settings.from_env(settings_env)
|
|
50
|
+
protected = getattr(settings_for_env, "PROTECTED_FILES_BY_RESOURCE", {})
|
|
51
|
+
return list(protected.get(str(resource_id), []))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_root_path(resource_id: str, category_id: int | None, settings_env: str) -> str:
|
|
55
|
+
settings_for_env = config.settings.from_env(settings_env)
|
|
56
|
+
download_folder = os.path.normpath(settings_for_env.DOWNLOAD_FOLDER) if settings_for_env.DOWNLOAD_FOLDER else ""
|
|
57
|
+
|
|
58
|
+
special_resource = settings_for_env.SPECIAL_RESOURCES.get(str(resource_id))
|
|
59
|
+
special_destination = utils.resolve_special_destination(special_resource, download_folder)
|
|
60
|
+
if special_destination:
|
|
61
|
+
return special_destination
|
|
62
|
+
|
|
63
|
+
category_name = config.CATEGORY_MAP.get(category_id or -1, "")
|
|
64
|
+
|
|
65
|
+
if category_name:
|
|
66
|
+
category_paths = getattr(settings_for_env, "CATEGORY_PATHS", None) or {}
|
|
67
|
+
override = category_paths.get(category_name)
|
|
68
|
+
if override:
|
|
69
|
+
if os.path.isabs(override):
|
|
70
|
+
return os.path.normpath(override)
|
|
71
|
+
return os.path.normpath(os.path.join(download_folder, override))
|
|
72
|
+
|
|
73
|
+
base_path = download_folder
|
|
74
|
+
vvmq_id = utils.get_current_vvmq_id(settings_env)
|
|
75
|
+
if vvmq_id:
|
|
76
|
+
vvmq_resource = settings_for_env.SPECIAL_RESOURCES.get(vvmq_id)
|
|
77
|
+
vvmq_destination = utils.resolve_special_destination(vvmq_resource, download_folder)
|
|
78
|
+
if vvmq_destination:
|
|
79
|
+
base_path = vvmq_destination
|
|
80
|
+
|
|
81
|
+
if category_name:
|
|
82
|
+
return os.path.normpath(os.path.join(base_path, category_name))
|
|
83
|
+
return base_path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_flatten(resource_id: str, parent_id: str | None, settings_env: str) -> bool:
|
|
87
|
+
if parent_id:
|
|
88
|
+
dependency_details = _get_dependency_details(parent_id, resource_id, settings_env)
|
|
89
|
+
if "flatten" in dependency_details:
|
|
90
|
+
return bool(dependency_details["flatten"])
|
|
91
|
+
|
|
92
|
+
settings_for_env = config.settings.from_env(settings_env)
|
|
93
|
+
special_resource = settings_for_env.SPECIAL_RESOURCES.get(str(resource_id), {})
|
|
94
|
+
return bool(special_resource.get("flatten", False))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Discovery logic
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class _RootSpec:
|
|
103
|
+
"""Tracks why a resource was selected for sync and its raw API payload."""
|
|
104
|
+
sources: set[str] = field(default_factory=set)
|
|
105
|
+
payload: dict | None = None
|
|
106
|
+
discovery_block: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def payload_title(payload: dict | None) -> str | None:
|
|
110
|
+
if not payload:
|
|
111
|
+
return None
|
|
112
|
+
title = payload.get("title")
|
|
113
|
+
return str(title) if title is not None else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def payload_category_id(payload: dict | None) -> int | None:
|
|
117
|
+
if not payload:
|
|
118
|
+
return None
|
|
119
|
+
category = payload.get("Category") or payload.get("category") or {}
|
|
120
|
+
raw = category.get("parent_category_id") or payload.get("parent_category_id")
|
|
121
|
+
if raw is None:
|
|
122
|
+
return None
|
|
123
|
+
return int(raw)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def payload_version_id(payload: dict | None) -> int | None:
|
|
127
|
+
"""Only the manifest endpoint provides this; the live XenForo API does not."""
|
|
128
|
+
if not payload:
|
|
129
|
+
return None
|
|
130
|
+
raw = payload.get("version_id")
|
|
131
|
+
return int(raw) if raw is not None else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _category_allowed_in_env(category_id: int | None, settings_env: str) -> bool:
|
|
135
|
+
"""Category 11 (plugins) is excluded from TEST and EMU for now."""
|
|
136
|
+
if category_id is None:
|
|
137
|
+
return False
|
|
138
|
+
if category_id not in config.CATEGORY_MAP:
|
|
139
|
+
return False
|
|
140
|
+
if category_id == 11 and settings_env.upper() in {"TEST", "EMU"}:
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _root_sources_for_full_sync(
|
|
146
|
+
watched_resources: list[dict],
|
|
147
|
+
licenses: list[dict],
|
|
148
|
+
settings_env: str,
|
|
149
|
+
) -> dict[str, _RootSpec]:
|
|
150
|
+
"""Collect every resource that qualifies for a full sync and record how each one qualified."""
|
|
151
|
+
specs: dict[str, _RootSpec] = {}
|
|
152
|
+
|
|
153
|
+
for payload in watched_resources:
|
|
154
|
+
category_id = payload_category_id(payload)
|
|
155
|
+
if not _category_allowed_in_env(category_id, settings_env):
|
|
156
|
+
continue
|
|
157
|
+
resource_id = str(payload["resource_id"])
|
|
158
|
+
spec = specs.setdefault(resource_id, _RootSpec())
|
|
159
|
+
spec.sources.add("watching")
|
|
160
|
+
spec.payload = payload
|
|
161
|
+
|
|
162
|
+
licenses_by_resource: dict[str, list[tuple[dict, bool]]] = {}
|
|
163
|
+
for license_info in licenses:
|
|
164
|
+
if not license_info.get("active", False):
|
|
165
|
+
continue
|
|
166
|
+
end_date = license_info.get("end_date", 0)
|
|
167
|
+
is_expired = end_date != 0 and end_date < time.time()
|
|
168
|
+
payload = license_info.get("resource") or {}
|
|
169
|
+
category_id = payload_category_id(payload)
|
|
170
|
+
if not _category_allowed_in_env(category_id, settings_env):
|
|
171
|
+
continue
|
|
172
|
+
resource_id = str(payload["resource_id"])
|
|
173
|
+
licenses_by_resource.setdefault(resource_id, []).append((payload, is_expired))
|
|
174
|
+
|
|
175
|
+
for resource_id, resource_licenses in licenses_by_resource.items():
|
|
176
|
+
spec = specs.setdefault(resource_id, _RootSpec())
|
|
177
|
+
spec.sources.add("licensed")
|
|
178
|
+
valid_license = next(
|
|
179
|
+
(lic_payload for lic_payload, is_expired in resource_licenses if not is_expired),
|
|
180
|
+
None,
|
|
181
|
+
)
|
|
182
|
+
if valid_license is None:
|
|
183
|
+
spec.discovery_block = "license_expired"
|
|
184
|
+
spec.payload = resource_licenses[0][0]
|
|
185
|
+
else:
|
|
186
|
+
spec.discovery_block = None
|
|
187
|
+
spec.payload = valid_license
|
|
188
|
+
|
|
189
|
+
settings_for_env = config.settings.from_env(settings_env)
|
|
190
|
+
for resource_id, resource_info in settings_for_env.SPECIAL_RESOURCES.items():
|
|
191
|
+
if resource_info.get("opt_in", False):
|
|
192
|
+
specs.setdefault(str(resource_id), _RootSpec()).sources.add("special")
|
|
193
|
+
|
|
194
|
+
return specs
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _root_sources_for_targeted(resource_ids: list[str], settings_env: str) -> dict[str, _RootSpec]:
|
|
198
|
+
"""builds the resource list for a single resource sync, since they can have dependencies."""
|
|
199
|
+
settings_for_env = config.settings.from_env(settings_env)
|
|
200
|
+
specs: dict[str, _RootSpec] = {}
|
|
201
|
+
for resource_id in resource_ids:
|
|
202
|
+
rid = str(resource_id)
|
|
203
|
+
spec = _RootSpec(sources={"explicit"})
|
|
204
|
+
resource_info = settings_for_env.SPECIAL_RESOURCES.get(rid, {})
|
|
205
|
+
if resource_info.get("opt_in", False):
|
|
206
|
+
spec.sources.add("special")
|
|
207
|
+
specs[rid] = spec
|
|
208
|
+
return specs
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _add_root_target(
|
|
212
|
+
desired_set: DesiredSet,
|
|
213
|
+
*,
|
|
214
|
+
resource_id: str,
|
|
215
|
+
sources: set[str],
|
|
216
|
+
payload: dict | None,
|
|
217
|
+
settings_env: str,
|
|
218
|
+
) -> DesiredInstallTarget:
|
|
219
|
+
"""Resolve path, category, flatten, and protected files for a root resource and add it to the plan."""
|
|
220
|
+
category_id = payload_category_id(payload)
|
|
221
|
+
resolved_path = None
|
|
222
|
+
settings_for_env = config.settings.from_env(settings_env)
|
|
223
|
+
special_resource = settings_for_env.SPECIAL_RESOURCES.get(str(resource_id))
|
|
224
|
+
has_own_destination = bool(
|
|
225
|
+
special_resource and (special_resource.get("custom_path") or special_resource.get("default_path"))
|
|
226
|
+
)
|
|
227
|
+
if has_own_destination or category_id is not None:
|
|
228
|
+
resolved_path = resolve_root_path(resource_id, category_id, settings_env)
|
|
229
|
+
target = DesiredInstallTarget(
|
|
230
|
+
target_key=make_root_target_key(resource_id),
|
|
231
|
+
resource_id=resource_id,
|
|
232
|
+
parent_id=None,
|
|
233
|
+
parent_target_key=None,
|
|
234
|
+
root_resource_id=resource_id,
|
|
235
|
+
target_kind="root",
|
|
236
|
+
sources=set(sources),
|
|
237
|
+
title=payload_title(payload),
|
|
238
|
+
category_id=category_id,
|
|
239
|
+
resolved_path=resolved_path,
|
|
240
|
+
subfolder=None,
|
|
241
|
+
flatten=_get_flatten(resource_id, None, settings_env),
|
|
242
|
+
protected_files=_get_protected_files(resource_id, settings_env),
|
|
243
|
+
explicit_root="explicit" in sources,
|
|
244
|
+
)
|
|
245
|
+
return desired_set.add_target(target)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _add_dependency_target(
|
|
249
|
+
desired_set: DesiredSet,
|
|
250
|
+
*,
|
|
251
|
+
parent_target: DesiredInstallTarget,
|
|
252
|
+
resource_id: str,
|
|
253
|
+
settings_env: str,
|
|
254
|
+
) -> DesiredInstallTarget:
|
|
255
|
+
"""Build an install target for a dependency, deriving its path from the parent target."""
|
|
256
|
+
if parent_target.resolved_path:
|
|
257
|
+
resolved_path, subfolder = resolve_dependency_path(
|
|
258
|
+
parent_target.resolved_path, parent_target.resource_id, resource_id, settings_env,
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
resolved_path, subfolder = None, None
|
|
262
|
+
|
|
263
|
+
target = DesiredInstallTarget(
|
|
264
|
+
target_key=make_child_target_key(parent_target.target_key, resource_id),
|
|
265
|
+
resource_id=resource_id,
|
|
266
|
+
parent_id=parent_target.resource_id,
|
|
267
|
+
parent_target_key=parent_target.target_key,
|
|
268
|
+
root_resource_id=parent_target.root_resource_id,
|
|
269
|
+
target_kind="dependency",
|
|
270
|
+
sources={"dependency"},
|
|
271
|
+
title=None,
|
|
272
|
+
category_id=None,
|
|
273
|
+
resolved_path=resolved_path,
|
|
274
|
+
subfolder=subfolder,
|
|
275
|
+
flatten=_get_flatten(resource_id, parent_target.resource_id, settings_env),
|
|
276
|
+
protected_files=_get_protected_files(resource_id, settings_env),
|
|
277
|
+
explicit_root=False,
|
|
278
|
+
)
|
|
279
|
+
return desired_set.add_target(target)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _expand_dependencies(
|
|
283
|
+
desired_set: DesiredSet,
|
|
284
|
+
*,
|
|
285
|
+
parent_target: DesiredInstallTarget,
|
|
286
|
+
settings_env: str,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Recursively add all opt-in dependencies of a parent target to the desired set."""
|
|
289
|
+
settings_for_env = config.settings.from_env(settings_env)
|
|
290
|
+
parent_settings = settings_for_env.SPECIAL_RESOURCES.get(parent_target.resource_id, {})
|
|
291
|
+
dependencies = parent_settings.get("dependencies", {})
|
|
292
|
+
|
|
293
|
+
for dependency_id, dependency_info in dependencies.items():
|
|
294
|
+
info = dependency_info or {}
|
|
295
|
+
if not info.get("opt_in", False):
|
|
296
|
+
continue
|
|
297
|
+
dependency_id_str = str(dependency_id)
|
|
298
|
+
child_target = _add_dependency_target(
|
|
299
|
+
desired_set,
|
|
300
|
+
parent_target=parent_target,
|
|
301
|
+
resource_id=dependency_id_str,
|
|
302
|
+
settings_env=settings_env,
|
|
303
|
+
)
|
|
304
|
+
if dependency_id_str in parse_target_key(parent_target.target_key):
|
|
305
|
+
# Keep the repeated edge once so planning can block the cycle explicitly.
|
|
306
|
+
continue
|
|
307
|
+
_expand_dependencies(
|
|
308
|
+
desired_set,
|
|
309
|
+
parent_target=child_target,
|
|
310
|
+
settings_env=settings_env,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def discover_desired_set(
|
|
315
|
+
*,
|
|
316
|
+
client: httpx.AsyncClient,
|
|
317
|
+
resource_ids: list[str] | None,
|
|
318
|
+
settings_env: str,
|
|
319
|
+
) -> DesiredSet:
|
|
320
|
+
"""Main entry point for discovery: figure out everything that needs to be installed and where it goes."""
|
|
321
|
+
if resource_ids is None:
|
|
322
|
+
watched_resources, licenses = await asyncio.gather(
|
|
323
|
+
api.fetch_watched_resources(client),
|
|
324
|
+
api.fetch_licenses(client),
|
|
325
|
+
)
|
|
326
|
+
root_specs = _root_sources_for_full_sync(watched_resources, licenses, settings_env)
|
|
327
|
+
desired_set = DesiredSet(mode="full")
|
|
328
|
+
else:
|
|
329
|
+
normalized_ids = [str(resource_id) for resource_id in resource_ids]
|
|
330
|
+
root_specs = _root_sources_for_targeted(normalized_ids, settings_env)
|
|
331
|
+
desired_set = DesiredSet(
|
|
332
|
+
mode="targeted",
|
|
333
|
+
requested_root_ids=set(normalized_ids),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
for resource_id, spec in root_specs.items():
|
|
337
|
+
root_target = _add_root_target(
|
|
338
|
+
desired_set,
|
|
339
|
+
resource_id=resource_id,
|
|
340
|
+
sources=spec.sources,
|
|
341
|
+
payload=spec.payload,
|
|
342
|
+
settings_env=settings_env,
|
|
343
|
+
)
|
|
344
|
+
if spec.discovery_block:
|
|
345
|
+
root_target.discovery_block = spec.discovery_block
|
|
346
|
+
_expand_dependencies(
|
|
347
|
+
desired_set,
|
|
348
|
+
parent_target=root_target,
|
|
349
|
+
settings_env=settings_env,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return desired_set
|