Skip to content

Commit 2eb067a

Browse files
committed
discovery block for expired licenses
1 parent 652f4dc commit 2eb067a

4 files changed

Lines changed: 143 additions & 2 deletions

File tree

src/redfetch/sync_discovery.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import os
5+
import time
56
from dataclasses import dataclass, field
67
from typing import Any
78

@@ -91,6 +92,7 @@ class _RootSpec:
9192
"""Tracks why a resource was selected for sync and its raw API payload."""
9293
sources: set[str] = field(default_factory=set)
9394
payload: dict | None = None
95+
discovery_block: str | None = None
9496

9597

9698
def payload_title(payload: dict | None) -> str | None:
@@ -149,13 +151,17 @@ def _root_sources_for_full_sync(
149151
for license_info in licenses:
150152
if not license_info.get("active", False):
151153
continue
154+
end_date = license_info.get("end_date", 0)
155+
is_expired = end_date != 0 and end_date < time.time()
152156
payload = license_info.get("resource") or {}
153157
category_id = payload_category_id(payload)
154158
if not _category_allowed_in_env(category_id, settings_env):
155159
continue
156160
resource_id = str(payload["resource_id"])
157161
spec = specs.setdefault(resource_id, _RootSpec())
158162
spec.sources.add("licensed")
163+
if is_expired:
164+
spec.discovery_block = "license_expired"
159165
spec.payload = payload
160166

161167
settings_for_env = config.settings.from_env(settings_env)
@@ -313,6 +319,8 @@ async def discover_desired_set(
313319
payload=spec.payload,
314320
settings_env=settings_env,
315321
)
322+
if spec.discovery_block:
323+
root_target.discovery_block = spec.discovery_block
316324
_expand_dependencies(
317325
desired_set,
318326
parent_target=root_target,

src/redfetch/sync_planner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ def _decide_action(
116116
parent_action is None or parent_action.action in {"block", "untrack"}
117117
):
118118
return "block", "parent_blocked"
119+
if target.discovery_block:
120+
return "block", target.discovery_block
119121
if remote_state is None:
120122
return "block", "fetch_error"
121123
if remote_state.status in BLOCKING_STATUSES:

src/redfetch/sync_types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"parent_failed",
3636
"dependency_cycle",
3737
"unknown_category",
38+
"license_expired",
3839
]
3940
ResultOutcome = Literal["downloaded", "skipped", "blocked", "untracked", "error"]
4041

@@ -62,6 +63,7 @@ class ReasonInfo:
6263
"not_installed": ReasonInfo("Not yet installed locally."),
6364
"already_current": ReasonInfo("Already up to date."),
6465
"install_context_changed": ReasonInfo("Install location or settings changed; re-downloading."),
66+
"license_expired": ReasonInfo("Your license for this resource has expired.", quiet=True, summary_label="Licenses expired"),
6567
}
6668

6769

@@ -128,6 +130,7 @@ class DesiredInstallTarget(TargetIdentity):
128130
flatten: bool = False
129131
protected_files: list[str] = Field(default_factory=list)
130132
explicit_root: bool = False
133+
discovery_block: PlanReason | None = None
131134

132135

133136
class DesiredSet(SyncModel):
@@ -154,6 +157,7 @@ def add_target(self, target: DesiredInstallTarget) -> DesiredInstallTarget:
154157
existing.flatten = existing.flatten or target.flatten
155158
existing.protected_files = existing.protected_files or target.protected_files
156159
existing.explicit_root = existing.explicit_root or target.explicit_root
160+
existing.discovery_block = existing.discovery_block or target.discovery_block
157161
return existing
158162

159163
def resource_targets(self, resource_id: str) -> list[DesiredInstallTarget]:

tests/test_licensed_resources_filtering.py

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
"""Discovery-stage tests for licensed resource filtering."""
22

33
import asyncio
4+
import time
45
from types import SimpleNamespace
56
from unittest.mock import AsyncMock, MagicMock, patch
67

78
import httpx
89
import pytest
910

1011
from redfetch import sync_discovery as discovery
12+
from redfetch import sync_planner as planner
13+
from redfetch.sync_types import (
14+
DesiredInstallTarget,
15+
DesiredSet,
16+
LocalSnapshot,
17+
RemoteArtifact,
18+
RemoteResourceState,
19+
RemoteSnapshot,
20+
)
21+
22+
FAR_FUTURE = int(time.time()) + 86400 * 365 * 10
23+
PAST = int(time.time()) - 86400 * 30
1124

1225

1326
def make_license(
@@ -17,11 +30,12 @@ def make_license(
1730
*,
1831
version_id: int = 101,
1932
file_id: int = 1001,
33+
end_date: int = FAR_FUTURE,
2034
) -> dict:
2135
return {
2236
"active": True,
23-
"start_date": "2024-01-01",
24-
"end_date": "2025-01-01",
37+
"start_date": 1711170000,
38+
"end_date": end_date,
2539
"license_id": 12345,
2640
"resource": {
2741
"resource_id": resource_id,
@@ -109,3 +123,116 @@ def test_cross_compatible_licensed_resources_remain_in_scope(env, category_id, r
109123
target_key = f"/{resource_id}/"
110124
assert target_key in desired_set.install_targets
111125
assert desired_set.install_targets[target_key].sources == {"licensed"}
126+
127+
128+
# --- expired license discovery tests ---
129+
130+
131+
def test_expired_license_gets_discovery_block():
132+
desired_set = asyncio.run(
133+
_discover_from_licenses([make_license(9998, 8, end_date=PAST)], "LIVE")
134+
)
135+
target = desired_set.install_targets["/9998/"]
136+
assert target.sources == {"licensed"}
137+
assert target.discovery_block == "license_expired"
138+
139+
140+
def test_valid_license_has_no_discovery_block():
141+
desired_set = asyncio.run(
142+
_discover_from_licenses([make_license(9998, 8, end_date=FAR_FUTURE)], "LIVE")
143+
)
144+
target = desired_set.install_targets["/9998/"]
145+
assert target.sources == {"licensed"}
146+
assert target.discovery_block is None
147+
148+
149+
def test_unlimited_license_has_no_discovery_block():
150+
desired_set = asyncio.run(
151+
_discover_from_licenses([make_license(9998, 8, end_date=0)], "LIVE")
152+
)
153+
target = desired_set.install_targets["/9998/"]
154+
assert target.sources == {"licensed"}
155+
assert target.discovery_block is None
156+
157+
158+
# --- expired license planner tests ---
159+
160+
161+
def _downloadable_state(resource_id: str, *, category_id: int = 8) -> RemoteResourceState:
162+
return RemoteResourceState(
163+
resource_id=resource_id,
164+
title=f"Resource {resource_id}",
165+
category_id=category_id,
166+
version_id=1234,
167+
status="downloadable",
168+
artifact=RemoteArtifact(
169+
file_id=9876,
170+
filename=f"{resource_id}.zip",
171+
download_url=f"https://example.com/{resource_id}.zip",
172+
file_hash="d41d8cd98f00b204e9800998ecf8427e",
173+
),
174+
source_note="manifest_plus_access_check",
175+
)
176+
177+
178+
def _build_plan_for_target(target, remote_state):
179+
mock_settings = MagicMock()
180+
mock_settings.ENV = "LIVE"
181+
mock_settings.from_env.return_value = SimpleNamespace(
182+
DOWNLOAD_FOLDER="C:/downloads",
183+
SPECIAL_RESOURCES={},
184+
PROTECTED_FILES_BY_RESOURCE={},
185+
)
186+
desired_set = DesiredSet(
187+
mode="full",
188+
resource_ids={target.resource_id},
189+
install_targets={target.target_key: target},
190+
)
191+
with patch("redfetch.sync_planner.config.settings", mock_settings), \
192+
patch("redfetch.sync_planner.config.CATEGORY_MAP", {8: "macros", 11: "plugins", 25: "lua"}):
193+
return planner.build_execution_plan(
194+
desired_set=desired_set,
195+
remote_snapshot=RemoteSnapshot(resources={target.resource_id: remote_state}),
196+
local_snapshot=LocalSnapshot(),
197+
settings_env="LIVE",
198+
)
199+
200+
201+
def test_planner_blocks_on_discovery_block():
202+
target = DesiredInstallTarget(
203+
target_key="/9998/",
204+
resource_id="9998",
205+
parent_id=None,
206+
parent_target_key=None,
207+
root_resource_id="9998",
208+
target_kind="root",
209+
sources={"licensed"},
210+
title="Expired Resource",
211+
category_id=8,
212+
resolved_path="C:/downloads/9998",
213+
discovery_block="license_expired",
214+
)
215+
plan = _build_plan_for_target(target, _downloadable_state("9998"))
216+
action = plan.actions["/9998/"]
217+
assert action.action == "block"
218+
assert action.reason == "license_expired"
219+
220+
221+
def test_planner_blocks_discovery_block_even_when_watching():
222+
target = DesiredInstallTarget(
223+
target_key="/9998/",
224+
resource_id="9998",
225+
parent_id=None,
226+
parent_target_key=None,
227+
root_resource_id="9998",
228+
target_kind="root",
229+
sources={"watching", "licensed"},
230+
title="Watched + Expired Resource",
231+
category_id=8,
232+
resolved_path="C:/downloads/9998",
233+
discovery_block="license_expired",
234+
)
235+
plan = _build_plan_for_target(target, _downloadable_state("9998"))
236+
action = plan.actions["/9998/"]
237+
assert action.action == "block"
238+
assert action.reason == "license_expired"

0 commit comments

Comments
 (0)