Skip to content

Commit 5f46fea

Browse files
committed
non-interactive update check feature
1 parent 083a44e commit 5f46fea

5 files changed

Lines changed: 233 additions & 1 deletion

File tree

src/redfetch/config_firstrun.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ def is_first_run(default_config_dir):
147147
return not os.path.exists(first_run_flag)
148148

149149

150+
def is_configured(default_config_dir: str | None = None) -> bool:
151+
"""Can initialize_config() proceed without interactive prompts?"""
152+
default_config_dir = default_config_dir or user_config_dir("redfetch", "RedGuides")
153+
try:
154+
with open(os.path.join(default_config_dir, "first_run_complete")) as f:
155+
config_dir = f.read().strip()
156+
except OSError:
157+
return False
158+
return os.path.exists(os.path.join(config_dir, ".env"))
159+
160+
150161
def get_or_create_table(doc: TOMLDocument, table_path: str):
151162
"""Navigate or create nested tables in the TOML document."""
152163
current_section = doc

src/redfetch/main.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
from enum import Enum
55
from pathlib import Path
6-
from typing import Optional
6+
from typing import NoReturn, Optional
77
import asyncio
88

99
# third-party imports
@@ -39,6 +39,12 @@ class Env(str, Enum):
3939
EMU = "EMU"
4040

4141

42+
EXIT_CHECK_ERROR = 1
43+
EXIT_CALLER_UPDATE = 2
44+
EXIT_AUTH_REQUIRED = 3
45+
EXIT_NOT_CONFIGURED = 4
46+
47+
4248
def parse_resource_id_or_fail(value: str) -> str:
4349
"""Accept either an integer ID or a URL that includes a recognizable ID."""
4450
value_stripped = value.strip()
@@ -259,6 +265,83 @@ def download(
259265
asyncio.run(download_command_async(db_name=db_name, db_path=db_path, id_or_url=id_or_url, force=force))
260266

261267

268+
def _check_exit(real_stdout, exit_code: int, stdout_line: str = "") -> NoReturn:
269+
"""Write one line to real stdout and exit (never returns)."""
270+
if stdout_line:
271+
real_stdout.write(stdout_line + "\n")
272+
real_stdout.flush()
273+
raise typer.Exit(exit_code)
274+
275+
276+
def _has_auth_credentials() -> bool:
277+
"""Peek at env / keyring for stored credentials (no network, no init)."""
278+
if os.environ.get("REDGUIDES_API_KEY"):
279+
return True
280+
try:
281+
import keyring
282+
token = keyring.get_password(auth.KEYRING_SERVICE_NAME, "access_token")
283+
return token is not None
284+
except Exception:
285+
return False
286+
287+
288+
@app.command(
289+
"check",
290+
help="Machine-readable check for available updates. Stdout: update count. Exit code: 0=ok, 2=caller needs update, 3=auth required, 4=not configured.",
291+
rich_help_panel="📦 Resource Management",
292+
)
293+
def check_command(
294+
caller_resource_id: str | None = typer.Option(
295+
None, "--caller-resource-id",
296+
help="Resource ID of the calling program (e.g. 1974 for Very Vanilla MQ Live).",
297+
),
298+
):
299+
real_stdout = sys.stdout
300+
sys.stdout = sys.stderr
301+
302+
try:
303+
# Phase 1 — pre-flight (no init, instant)
304+
from redfetch.config_firstrun import is_configured
305+
306+
if not is_configured():
307+
_check_exit(real_stdout, EXIT_NOT_CONFIGURED)
308+
309+
if not _has_auth_credentials():
310+
_check_exit(real_stdout, EXIT_AUTH_REQUIRED)
311+
312+
# Phase 2 — init + check
313+
config.initialize_config()
314+
auth.initialize_keyring()
315+
db_name = f"{config.settings.ENV}_resources.db"
316+
store.initialize_db(db_name)
317+
db_path = store.get_db_path(db_name)
318+
319+
result = asyncio.run(_check_command_async(db_path, caller_resource_id))
320+
321+
sys.stdout = real_stdout
322+
323+
if result is None:
324+
_check_exit(real_stdout, EXIT_AUTH_REQUIRED)
325+
326+
exit_code = EXIT_CALLER_UPDATE if result.caller_update_available is True else 0
327+
_check_exit(real_stdout, exit_code, str(result.updates_available))
328+
329+
except typer.Exit:
330+
raise
331+
except Exception:
332+
sys.stdout = real_stdout
333+
_check_exit(real_stdout, EXIT_CHECK_ERROR)
334+
335+
336+
async def _check_command_async(db_path: str, caller_resource_id: str | None):
337+
try:
338+
headers = await auth.get_api_headers()
339+
except RuntimeError:
340+
return None
341+
from redfetch.update_check import check_for_updates
342+
return await check_for_updates(db_path, headers, caller_resource_id)
343+
344+
262345
@app.command(
263346
"ui",
264347
help="Launch the [italic]Terminal User Interface[/italic].",

src/redfetch/sync_types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,11 @@ def has_errors(self) -> bool:
336336
return self.was_cancelled or any(
337337
item.outcome == "error" for item in self.items.values()
338338
)
339+
340+
341+
class UpdateCheckResult(SyncModel):
342+
"""Result of a lightweight update check (no downloads)."""
343+
344+
updates_available: int = 0
345+
caller_update_available: bool | None = None
346+
caller_resource_id: str | None = None

src/redfetch/update_check.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Lightweight non-interactive update check, no downloads"""
2+
3+
from __future__ import annotations
4+
5+
import httpx
6+
7+
from redfetch import net
8+
from redfetch import store
9+
from redfetch.sync_discovery import payload_version_id
10+
from redfetch.sync_types import LocalSnapshot, UpdateCheckResult
11+
12+
13+
def _count_outdated(
14+
local_snapshot: LocalSnapshot,
15+
manifest: dict,
16+
caller_resource_id: str | None = None,
17+
) -> UpdateCheckResult:
18+
manifest_resources = manifest.get("resources") or {}
19+
20+
updates_available = 0
21+
caller_found = False
22+
caller_outdated = False
23+
24+
for local_state in local_snapshot.install_targets.values():
25+
manifest_entry = manifest_resources.get(local_state.resource_id)
26+
remote_version = payload_version_id(manifest_entry)
27+
if local_state.version_local is None or remote_version is None:
28+
continue
29+
is_caller_root = (
30+
caller_resource_id
31+
and local_state.resource_id == caller_resource_id
32+
and local_state.target_kind == "root"
33+
)
34+
if local_state.version_local != remote_version:
35+
updates_available += 1
36+
if is_caller_root:
37+
caller_outdated = True
38+
if is_caller_root:
39+
caller_found = True
40+
41+
if caller_resource_id and caller_found:
42+
caller_update_available = caller_outdated
43+
else:
44+
caller_update_available = None
45+
46+
return UpdateCheckResult(
47+
updates_available=updates_available,
48+
caller_update_available=caller_update_available,
49+
caller_resource_id=caller_resource_id,
50+
)
51+
52+
53+
async def check_for_updates(
54+
db_path: str,
55+
headers: dict,
56+
caller_resource_id: str | None = None,
57+
) -> UpdateCheckResult:
58+
"""Compare local DB versions against the manifest"""
59+
local_snapshot = await store.load_local_snapshot(db_path)
60+
61+
async with httpx.AsyncClient(
62+
headers=headers,
63+
http2=True,
64+
timeout=30.0,
65+
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
66+
) as client:
67+
manifest = await net.fetch_manifest_cached(client)
68+
69+
return _count_outdated(local_snapshot, manifest, caller_resource_id)

tests/test_check.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Tests the non-interactive update check logic."""
2+
import pytest
3+
4+
from redfetch.config_firstrun import is_configured
5+
from redfetch.sync_types import LocalInstallState, LocalSnapshot
6+
from redfetch.update_check import _count_outdated
7+
8+
9+
@pytest.fixture
10+
def make_state():
11+
def _make(
12+
resource_id,
13+
*,
14+
version_local=10,
15+
target_kind="root",
16+
parent_id=None,
17+
root_resource_id=None,
18+
):
19+
if target_kind == "root":
20+
target_key = f"/{resource_id}/"
21+
else:
22+
target_key = f"/{parent_id}/{resource_id}/"
23+
return LocalInstallState(
24+
target_key=target_key,
25+
resource_id=resource_id,
26+
parent_id=parent_id,
27+
parent_target_key=f"/{parent_id}/" if parent_id else None,
28+
root_resource_id=root_resource_id or resource_id,
29+
target_kind=target_kind,
30+
version_local=version_local,
31+
)
32+
return _make
33+
34+
35+
def test_counts_outdated_and_skips_none_version(make_state):
36+
states = [
37+
make_state("100", version_local=10),
38+
make_state("200", version_local=20),
39+
make_state("300", version_local=None),
40+
]
41+
snapshot = LocalSnapshot(install_targets={s.target_key: s for s in states})
42+
manifest = {"resources": {"100": {"version_id": 11}, "200": {"version_id": 20}, "300": {"version_id": 99}}}
43+
result = _count_outdated(snapshot, manifest)
44+
assert result.updates_available == 1
45+
46+
47+
def test_caller_as_dependency_not_tracked(make_state):
48+
dep = make_state("1974", version_local=5, target_kind="dependency",
49+
parent_id="100", root_resource_id="100")
50+
snapshot = LocalSnapshot(install_targets={dep.target_key: dep})
51+
manifest = {"resources": {"1974": {"version_id": 10}}}
52+
result = _count_outdated(snapshot, manifest, caller_resource_id="1974")
53+
assert result.updates_available == 1
54+
assert result.caller_update_available is None
55+
56+
57+
def test_is_configured_false_when_flag_but_no_env(tmp_path):
58+
config_dir = tmp_path / "config"
59+
config_dir.mkdir()
60+
(tmp_path / "first_run_complete").write_text(str(config_dir))
61+
assert is_configured(str(tmp_path)) is False

0 commit comments

Comments
 (0)