Skip to content

Commit 4c0398f

Browse files
committed
Add update check mechanism
1 parent 993e89f commit 4c0398f

7 files changed

Lines changed: 508 additions & 3 deletions

File tree

deepfabric/cli.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import os
23
import sys
34

@@ -20,6 +21,7 @@
2021
from .topic_manager import load_or_build_topic_model, save_topic_model
2122
from .topic_model import TopicModel
2223
from .tui import get_tui
24+
from .update_checker import check_for_updates
2325
from .validation import show_validation_success, validate_path_requirements
2426

2527
OverrideValue = str | int | float | bool | None
@@ -45,7 +47,9 @@ def handle_error(ctx: click.Context, error: Exception) -> NoReturn:
4547
@click.version_option()
4648
def cli():
4749
"""DeepFabric CLI - Generate synthetic training data for language models."""
48-
pass
50+
# Check for updates on CLI startup (silently fail if any issues occur)
51+
with contextlib.suppress(Exception):
52+
check_for_updates()
4953

5054

5155
class GenerateOptions(BaseModel):

deepfabric/format_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def format_command(
5252
tui.info(f"Loading dataset from Hugging Face repo '{repo}' (split: {hf_split})...")
5353
try:
5454
# Bandit nosec, as no digest is set.
55-
hf_ds = load_dataset(str(repo), split=hf_split) # nosec
55+
hf_ds = load_dataset(str(repo), split=hf_split) # nosec
5656
except (DatasetNotFoundError, UnexpectedSplitsError) as e:
5757
msg = (
5858
"Failed to load dataset from Hugging Face repo "

deepfabric/update_checker.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import importlib.metadata
2+
import json
3+
import logging
4+
import os
5+
import urllib.error
6+
import urllib.request
7+
8+
from typing import Any
9+
10+
from packaging.version import Version, parse
11+
12+
from .metrics import trace
13+
from .tui import get_tui
14+
15+
logger = logging.getLogger(__name__)
16+
17+
# PyPI API endpoint for deepfabric package
18+
PYPI_API_URL = "https://pypi.org/pypi/deepfabric/json"
19+
20+
# Timeout for PyPI API request (2 seconds)
21+
REQUEST_TIMEOUT = 2.0
22+
23+
24+
def _get_current_version() -> str | None:
25+
"""
26+
Get the current installed version of deepfabric.
27+
28+
Returns:
29+
str | None: Version string or None if unable to determine
30+
"""
31+
try:
32+
return importlib.metadata.version("deepfabric")
33+
except (ImportError, importlib.metadata.PackageNotFoundError):
34+
logger.debug("Unable to determine current version")
35+
return None
36+
37+
38+
def _is_update_check_disabled() -> bool:
39+
"""
40+
Check if update checking is disabled via environment variable.
41+
42+
Returns:
43+
bool: True if DEEPFABRIC_NO_UPDATE_CHECK is set to any truthy value
44+
"""
45+
env_value = os.environ.get("DEEPFABRIC_NO_UPDATE_CHECK", "").lower()
46+
return env_value in ("1", "true", "yes", "on")
47+
48+
49+
def _fetch_latest_version_from_pypi() -> str | None:
50+
"""
51+
Fetch the latest version from PyPI API.
52+
53+
Returns:
54+
str | None: Latest version string or None if fetch fails
55+
"""
56+
try:
57+
with urllib.request.urlopen( # noqa: S310
58+
PYPI_API_URL, timeout=REQUEST_TIMEOUT
59+
) as response:
60+
data: dict[str, Any] = json.loads(response.read().decode("utf-8"))
61+
latest_version = data.get("info", {}).get("version")
62+
if latest_version:
63+
logger.debug("Fetched latest version from PyPI: %s", latest_version)
64+
return latest_version
65+
logger.debug("No version found in PyPI response")
66+
return None
67+
except TimeoutError:
68+
logger.debug("PyPI request timed out after %s seconds", REQUEST_TIMEOUT)
69+
return None
70+
except urllib.error.URLError as e:
71+
logger.debug("Failed to fetch from PyPI: %s", e)
72+
return None
73+
except (KeyError, ValueError, json.JSONDecodeError) as e:
74+
logger.debug("Failed to parse PyPI response: %s", e)
75+
return None
76+
77+
78+
def _compare_versions(current: str, latest: str) -> bool:
79+
"""
80+
Compare version strings to determine if an update is available.
81+
82+
Args:
83+
current: Current version string
84+
latest: Latest version string
85+
86+
Returns:
87+
bool: True if latest > current, False otherwise
88+
"""
89+
try:
90+
current_version: Version = parse(current)
91+
latest_version: Version = parse(latest)
92+
except Exception as e:
93+
logger.debug("Failed to compare versions: %s", e)
94+
return False
95+
else:
96+
return latest_version > current_version
97+
98+
99+
def check_for_updates() -> None:
100+
"""
101+
Check for available updates and notify user if a newer version exists.
102+
103+
This function:
104+
1. Checks if update checking is disabled via environment variable
105+
2. Gets the current installed version
106+
3. Fetches the latest version from PyPI
107+
4. Compares versions and displays a warning if update is available
108+
5. Tracks metrics about the update check
109+
110+
The function is designed to fail silently and never block CLI execution.
111+
All errors are logged at DEBUG level and do not interrupt the user.
112+
"""
113+
# Check if update checking is disabled
114+
if _is_update_check_disabled():
115+
logger.debug("Update check disabled via DEEPFABRIC_NO_UPDATE_CHECK")
116+
return
117+
118+
# Get current version
119+
current_version = _get_current_version()
120+
if not current_version or current_version == "development":
121+
logger.debug("Skipping update check for development version")
122+
return
123+
124+
# Fetch latest version from PyPI
125+
latest_version = _fetch_latest_version_from_pypi()
126+
if not latest_version:
127+
logger.debug("Could not fetch latest version from PyPI")
128+
return
129+
130+
# Track metrics about the check
131+
try:
132+
trace(
133+
"update_check_performed",
134+
{
135+
"current_version": current_version,
136+
"latest_version": latest_version,
137+
"update_available": _compare_versions(current_version, latest_version),
138+
},
139+
)
140+
except Exception as e:
141+
logger.debug("Failed to track update check metrics: %s", e)
142+
143+
# Compare versions and notify user if update is available
144+
if _compare_versions(current_version, latest_version):
145+
try:
146+
tui = get_tui()
147+
tui.warning(
148+
f"Update available: deepfabric {latest_version} "
149+
f"(you have {current_version})\n"
150+
f" Run: pip install --upgrade deepfabric"
151+
)
152+
except Exception as e:
153+
logger.debug("Failed to display update notification: %s", e)

misc/test_update_manual.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env python3
2+
"""Manual test script for update checker."""
3+
4+
import json
5+
6+
from unittest.mock import Mock, patch
7+
8+
9+
# Mock the PyPI response with a newer version
10+
def test_with_newer_version():
11+
print("Testing update checker with simulated newer version...")
12+
13+
mock_response = Mock()
14+
mock_response.read.return_value = json.dumps({
15+
"info": {"version": "99.99.99"} # Simulate a much newer version
16+
}).encode("utf-8")
17+
mock_response.__enter__ = Mock(return_value=mock_response)
18+
mock_response.__exit__ = Mock(return_value=False)
19+
20+
with patch("urllib.request.urlopen", return_value=mock_response):
21+
from deepfabric.update_checker import check_for_updates # noqa: PLC0415
22+
print("Running check_for_updates()...")
23+
check_for_updates()
24+
print("Check complete! You should see a warning above if TUI is working.")
25+
26+
if __name__ == "__main__":
27+
test_with_newer_version()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"rich>=13.0.0",
2020
"google-genai>=1.36.0",
2121
"posthog>=3.0.0",
22+
"packaging>=25.0",
2223
]
2324

2425
[project.optional-dependencies]

0 commit comments

Comments
 (0)