Skip to content

Commit 652f4dc

Browse files
committed
user-facing error messages
1 parent ee3cdf7 commit 652f4dc

File tree

9 files changed

+291
-75
lines changed

9 files changed

+291
-75
lines changed

src/redfetch/api.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ async def fetch_resource_record(client: httpx.AsyncClient, resource_id: str) ->
5454
current_files = resource.get('current_files') or []
5555
if not resource.get('can_download', False):
5656
status: RemoteStatus = 'access_denied'
57-
elif len(current_files) != 1:
58-
status = 'missing_files'
57+
elif len(current_files) == 0:
58+
status = 'no_files'
59+
elif len(current_files) > 1:
60+
status = 'multiple_files'
5961
else:
6062
status = 'downloadable'
6163
return ResourceRecord(resource_id=resource_id, status=status, resource=resource)

src/redfetch/listener.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async def handle_health(request: web.Request) -> web.Response:
6262

6363
if version_local is None:
6464
return web.json_response({"action": "install"})
65-
elif version_local != remote_version:
65+
elif version_local < remote_version:
6666
return web.json_response({"action": "update"})
6767
else:
6868
return web.json_response({"action": "re-install"})

src/redfetch/sync.py

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,74 @@
1010
from redfetch import sync_executor
1111
from redfetch import sync_planner
1212
from redfetch import sync_remote
13-
from redfetch.sync_types import ExecutionPlan, ExecutionResult, SyncEventCallback
13+
from redfetch.sync_types import (
14+
ExecutionPlan,
15+
ExecutionResult,
16+
PLAN_REASON_META,
17+
ReasonInfo,
18+
SyncEventCallback,
19+
reason_message,
20+
)
1421

1522

1623
_sync_lock: asyncio.Lock | None = None
24+
_DEFAULT_REASON = ReasonInfo(message="")
1725

1826

19-
def _print_plan_summary(execution_plan: ExecutionPlan) -> None:
27+
def _print_plan_summary(
28+
execution_plan: ExecutionPlan,
29+
resource_ids: list[str] | None = None,
30+
) -> None:
31+
is_full_sync = resource_ids is None
32+
all_blocked = [
33+
action for action in execution_plan.actions.values()
34+
if action.action == "block"
35+
]
36+
if is_full_sync:
37+
quiet = [a for a in all_blocked if PLAN_REASON_META.get(a.reason, _DEFAULT_REASON).quiet]
38+
blocked = [a for a in all_blocked if not PLAN_REASON_META.get(a.reason, _DEFAULT_REASON).quiet]
39+
else:
40+
quiet = []
41+
blocked = all_blocked
42+
2043
counts = execution_plan.action_counts()
2144
print(f"Resources in scope: >>> {len(execution_plan.actions)} <<<")
2245
print(f"Resources to download: >>> {counts.get('download', 0)} <<<")
23-
if counts.get("block", 0):
24-
print(f"Resources blocked: >>> {counts.get('block', 0)} <<<")
46+
if blocked:
47+
print(f"Resources blocked: >>> {len(blocked)} <<<")
48+
for action in blocked:
49+
label = action.title or action.resource_id
50+
print(f" - {label} (id={action.resource_id}): {reason_message(action.reason)}")
51+
52+
summary_buckets: dict[str, int] = {}
53+
for action in quiet:
54+
label = PLAN_REASON_META[action.reason].summary_label
55+
if label:
56+
summary_buckets[label] = summary_buckets.get(label, 0) + 1
57+
for label, count in summary_buckets.items():
58+
print(f"{label}: >>> {count} <<<")
59+
2560
if counts.get("untrack", 0):
2661
print(f"Resources to untrack: >>> {counts.get('untrack', 0)} <<<")
2762

2863

64+
def _print_failure_detail(
65+
execution_plan: ExecutionPlan,
66+
resource_ids: list[str],
67+
) -> None:
68+
"""Print a hint when a targeted sync fails with zero scoped actions (resource not in scope at all)."""
69+
requested = {str(rid) for rid in resource_ids}
70+
has_scoped_actions = any(
71+
action.root_resource_id in requested
72+
for action in execution_plan.actions.values()
73+
)
74+
if not has_scoped_actions:
75+
print(
76+
f"No valid resources found for IDs: {resource_ids}. "
77+
"Are you in the right server env? Did you opt_in in your settings.local.toml?"
78+
)
79+
80+
2981
def _run_succeeded(
3082
*,
3183
execution_plan: ExecutionPlan,
@@ -92,7 +144,7 @@ async def sync(
92144
settings_env=settings_env,
93145
)
94146

95-
_print_plan_summary(execution_plan)
147+
_print_plan_summary(execution_plan, resource_ids=resource_ids)
96148
if on_event:
97149
on_event(("total", len(execution_plan.actions), None))
98150

@@ -129,19 +181,18 @@ async def sync(
129181
)
130182

131183
if execution_result.has_errors():
132-
errored_resources = [
133-
item.resource_id
184+
errored_items = [
185+
item
134186
for item in execution_result.items.values()
135187
if item.outcome == "error"
136188
]
137-
if errored_resources:
189+
if errored_items:
138190
print("One or more resources failed to download.")
139-
print(f"Failed resources: {errored_resources}")
191+
for item in errored_items:
192+
detail = f": {item.error_detail}" if item.error_detail else ""
193+
print(f" - {item.resource_id}{detail}")
140194
elif resource_ids is not None and not success:
141-
print(
142-
f"No valid resources found for IDs: {resource_ids}. "
143-
"Are you in the right server env? Did you opt_in in your settings.local.toml?"
144-
)
195+
_print_failure_detail(execution_plan, resource_ids)
145196
elif any(item.outcome == "downloaded" for item in execution_result.items.values()):
146197
print("All resources downloaded successfully.")
147198
else:

src/redfetch/sync_planner.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from redfetch import config
34
from redfetch.sync_discovery import (
45
is_special_resource,
56
resolve_dependency_path,
@@ -21,7 +22,7 @@
2122
)
2223

2324

24-
BLOCKING_STATUSES = {"access_denied", "missing_files", "not_found", "fetch_error"}
25+
BLOCKING_STATUSES = {"access_denied", "no_files", "multiple_files", "not_found", "fetch_error"}
2526

2627

2728
def _desired_targets_in_order(desired_set: DesiredSet) -> list[DesiredInstallTarget]:
@@ -64,8 +65,9 @@ def _resolve_target_path(
6465
category_id = target.category_id
6566
if category_id is None and remote_state is not None:
6667
category_id = remote_state.category_id
67-
if category_id is None and not is_special_resource(target.resource_id, settings_env):
68-
return None, target.subfolder
68+
if not is_special_resource(target.resource_id, settings_env):
69+
if category_id is None or category_id not in config.CATEGORY_MAP:
70+
return None, target.subfolder
6971
return resolve_root_path(target.resource_id, category_id, settings_env), None
7072

7173
if parent_action is None or not parent_action.resolved_path or target.parent_id is None:
@@ -118,6 +120,8 @@ def _decide_action(
118120
return "block", "fetch_error"
119121
if remote_state.status in BLOCKING_STATUSES:
120122
return "block", remote_state.status # type: ignore[return-value]
123+
if resolved_path is None and target.target_kind == "root":
124+
return "block", "unknown_category"
121125
if _install_context_changed(
122126
local_state, resolved_path=resolved_path, subfolder=subfolder, target=target,
123127
):

src/redfetch/sync_remote.py

Lines changed: 20 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,6 @@ def _payload_details(payload: dict | None) -> _PayloadDetails:
6363
)
6464

6565

66-
def _payload_state(resource_id: str, payload: dict | None, *, status: str, source_note: str) -> RemoteResourceState:
67-
title, category_id, version_id, artifact = _payload_details(payload)
68-
return RemoteResourceState(
69-
resource_id=resource_id,
70-
title=title,
71-
category_id=category_id,
72-
version_id=version_id,
73-
status=status,
74-
artifact=artifact if status == "downloadable" else None,
75-
source_note=source_note,
76-
)
77-
78-
7966
def _needs_live_check(
8067
*,
8168
desired_targets: list[DesiredInstallTarget],
@@ -109,25 +96,18 @@ def _blocked_state(
10996
*,
11097
status: str,
11198
manifest_details: _PayloadDetails | None,
112-
payload: dict | None,
99+
live_title: str | None = None,
100+
live_category_id: int | None = None,
113101
) -> RemoteResourceState:
114102
"""Build a RemoteResourceState for a resource that is blocked."""
115-
if payload:
116-
return _payload_state(
117-
resource_id,
118-
payload,
119-
status=status,
120-
source_note="api_fallback",
121-
)
122-
123103
return RemoteResourceState(
124104
resource_id=resource_id,
125-
title=manifest_details.title if manifest_details else None,
126-
category_id=manifest_details.category_id if manifest_details else None,
105+
title=(manifest_details.title if manifest_details else None) or live_title,
106+
category_id=(manifest_details.category_id if manifest_details and manifest_details.category_id is not None else live_category_id),
127107
version_id=manifest_details.version_id if manifest_details else None,
128108
status=status,
129109
artifact=None,
130-
source_note="manifest_only" if manifest_details else "api_fallback",
110+
source_note="manifest" if manifest_details else "live_access_only",
131111
)
132112

133113

@@ -173,35 +153,27 @@ async def fetch_remote_snapshot(
173153
for record in live_records:
174154
resource_id = record.resource_id
175155
manifest_details = manifest_cache.get(resource_id)
176-
payload = record.resource
177-
status = record.status
156+
live_title = payload_title(record.resource)
157+
live_category_id = payload_category_id(record.resource)
178158

179-
if status != "downloadable":
159+
if record.status != "downloadable":
180160
remote_resources[resource_id] = _blocked_state(
181161
resource_id,
182-
status=status,
162+
status=record.status,
183163
manifest_details=manifest_details,
184-
payload=payload,
164+
live_title=live_title,
165+
live_category_id=live_category_id,
185166
)
186167
continue
187168

188-
if manifest_details is not None and manifest_details.version_id is not None and manifest_details.artifact is not None:
189-
live_details = _payload_details(payload)
190-
remote_resources[resource_id] = RemoteResourceState(
191-
resource_id=resource_id,
192-
title=manifest_details.title or live_details.title,
193-
category_id=manifest_details.category_id if manifest_details.category_id is not None else live_details.category_id,
194-
version_id=manifest_details.version_id,
195-
status="downloadable",
196-
artifact=manifest_details.artifact,
197-
source_note="manifest_plus_access_check",
198-
)
199-
else:
200-
remote_resources[resource_id] = _payload_state(
201-
resource_id,
202-
payload,
203-
status="downloadable",
204-
source_note="api_fallback",
205-
)
169+
remote_resources[resource_id] = RemoteResourceState(
170+
resource_id=resource_id,
171+
title=(manifest_details.title if manifest_details else None) or live_title,
172+
category_id=(manifest_details.category_id if manifest_details and manifest_details.category_id is not None else live_category_id),
173+
version_id=manifest_details.version_id if manifest_details else None,
174+
status="downloadable",
175+
artifact=manifest_details.artifact if manifest_details else None,
176+
source_note="manifest_plus_access_check" if manifest_details else "live_access_only",
177+
)
206178

207179
return RemoteSnapshot(resources=remote_resources)

src/redfetch/sync_types.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable
4+
from dataclasses import dataclass
45
from typing import Literal, Union
56

67
from pydantic import BaseModel, ConfigDict, Field
@@ -13,7 +14,8 @@
1314
"manifest_current",
1415
"downloadable",
1516
"access_denied",
16-
"missing_files",
17+
"no_files",
18+
"multiple_files",
1719
"not_found",
1820
"fetch_error",
1921
]
@@ -25,15 +27,48 @@
2527
"install_context_changed",
2628
"not_desired",
2729
"access_denied",
28-
"missing_files",
30+
"no_files",
31+
"multiple_files",
2932
"not_found",
3033
"fetch_error",
3134
"parent_blocked",
3235
"parent_failed",
3336
"dependency_cycle",
37+
"unknown_category",
3438
]
3539
ResultOutcome = Literal["downloaded", "skipped", "blocked", "untracked", "error"]
3640

41+
@dataclass(frozen=True, slots=True)
42+
class ReasonInfo:
43+
"""Display metadata for a single PlanReason value."""
44+
45+
message: str
46+
quiet: bool = False
47+
summary_label: str | None = None
48+
49+
50+
PLAN_REASON_META: dict[PlanReason, ReasonInfo] = {
51+
"access_denied": ReasonInfo("You don't have permission to download this resource."),
52+
"no_files": ReasonInfo("This resource has no downloadable files.", quiet=True, summary_label="Resources with no files"),
53+
"multiple_files": ReasonInfo("This resource has multiple files and cannot be auto-synced. Ask the author to release it as a .zip file."),
54+
"not_found": ReasonInfo("This resource was not found."),
55+
"fetch_error": ReasonInfo("Failed to retrieve this resource from the server."),
56+
"unknown_category": ReasonInfo("This resource's category is not mapped to an install location, but you can specify one manually in settings.local.toml"),
57+
"parent_blocked": ReasonInfo("Skipped because its parent resource is blocked.", quiet=True),
58+
"parent_failed": ReasonInfo("Skipped because its parent resource failed to download."),
59+
"dependency_cycle": ReasonInfo("Skipped due to a circular dependency."),
60+
"not_desired": ReasonInfo("No longer watched or licensed; untracking."),
61+
"outdated": ReasonInfo("A newer version is available."),
62+
"not_installed": ReasonInfo("Not yet installed locally."),
63+
"already_current": ReasonInfo("Already up to date."),
64+
"install_context_changed": ReasonInfo("Install location or settings changed; re-downloading."),
65+
}
66+
67+
68+
def reason_message(reason: PlanReason) -> str:
69+
meta = PLAN_REASON_META.get(reason)
70+
return meta.message if meta else reason
71+
3772
SyncEvent = Union[
3873
tuple[Literal["total"], int, None],
3974
tuple[Literal["add_total"], int, None],

tests/test_special_resources_e2e.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -334,12 +334,26 @@ async def mock_fetch_licenses(client):
334334
return []
335335

336336
async def mock_fetch_manifest(client):
337-
# Return a manifest with our special resources
338337
return {
339338
'resources': {
340-
'151': {'last_update': 9999999999},
341-
'153': {'last_update': 9999999999},
342-
'1865': {'last_update': 9999999999}
339+
'151': {
340+
'version_id': 100, 'last_update': 9999999999, 'title': 'MySEQ Open',
341+
'parent_category_id': 11,
342+
'current_files': [{'id': 1001, 'filename': 'myseqserver.ini',
343+
'download_url': 'https://example.com/myseqserver.ini', 'hash': 'abc123'}],
344+
},
345+
'153': {
346+
'version_id': 200, 'last_update': 9999999999, 'title': "Brewall's Maps",
347+
'parent_category_id': 11,
348+
'current_files': [{'id': 1002, 'filename': 'brewall-maps_20241203.zip',
349+
'download_url': 'https://example.com/brewall-maps.zip', 'hash': 'def456'}],
350+
},
351+
'1865': {
352+
'version_id': 300, 'last_update': 9999999999, 'title': 'MySEQ',
353+
'parent_category_id': 11,
354+
'current_files': [{'id': 1003, 'filename': 'myseq.zip',
355+
'download_url': 'https://example.com/myseq.zip', 'hash': 'ghi789'}],
356+
},
343357
}
344358
}
345359

0 commit comments

Comments
 (0)