Skip to content

Commit a4c7e86

Browse files
authored
CM-27209 - Add latest CLI version check (#276)
1 parent 1c4d549 commit a4c7e86

File tree

7 files changed

+448
-4
lines changed

7 files changed

+448
-4
lines changed

.github/workflows/tests_full.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ jobs:
6565
run: poetry install
6666

6767
- name: Run executable test
68-
if: matrix.python-version != '3.13' # we will migrate pyinstaller to 3.13 later
68+
# we care about the one Python version that will be used to build the executable
69+
# TODO(MarshalX): upgrade to Python 3.13
70+
if: matrix.python-version == '3.12'
6971
run: |
7072
poetry run pyinstaller pyinstaller.spec
7173
./dist/cycode-cli version

cycode/cli/commands/main_cli.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33

44
import click
55

6+
from cycode import __version__
67
from cycode.cli.commands.ai_remediation.ai_remediation_command import ai_remediation_command
78
from cycode.cli.commands.auth.auth_command import auth_command
89
from cycode.cli.commands.configure.configure_command import configure_command
910
from cycode.cli.commands.ignore.ignore_command import ignore_command
1011
from cycode.cli.commands.report.report_command import report_command
1112
from cycode.cli.commands.scan.scan_command import scan_command
1213
from cycode.cli.commands.status.status_command import status_command
14+
from cycode.cli.commands.version.version_checker import version_checker
1315
from cycode.cli.commands.version.version_command import version_command
1416
from cycode.cli.consts import (
1517
CLI_CONTEXT_SETTINGS,
@@ -48,6 +50,12 @@
4850
default=False,
4951
help='Do not show the progress meter.',
5052
)
53+
@click.option(
54+
'--no-update-notifier',
55+
is_flag=True,
56+
default=False,
57+
help='Do not check CLI for updates.',
58+
)
5159
@click.option(
5260
'--output',
5361
'-o',
@@ -63,7 +71,12 @@
6371
)
6472
@click.pass_context
6573
def main_cli(
66-
context: click.Context, verbose: bool, no_progress_meter: bool, output: str, user_agent: Optional[str]
74+
context: click.Context,
75+
verbose: bool,
76+
no_progress_meter: bool,
77+
no_update_notifier: bool,
78+
output: str,
79+
user_agent: Optional[str],
6780
) -> None:
6881
init_sentry()
6982
add_breadcrumb('cycode')
@@ -85,3 +98,20 @@ def main_cli(
8598
if user_agent:
8699
user_agent_option = UserAgentOptionScheme().loads(user_agent)
87100
CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix)
101+
102+
if not no_update_notifier:
103+
context.call_on_close(lambda: check_latest_version_on_close())
104+
105+
106+
@click.pass_context
107+
def check_latest_version_on_close(context: click.Context) -> None:
108+
output = context.obj.get('output')
109+
# don't print anything if the output is JSON
110+
if output == 'json':
111+
return
112+
113+
# we always want to check the latest version for "version" and "status" commands
114+
should_use_cache = context.invoked_subcommand not in {'version', 'status'}
115+
version_checker.check_and_notify_update(
116+
current_version=__version__, use_color=context.color, use_cache=should_use_cache
117+
)
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import os
2+
import re
3+
import time
4+
from pathlib import Path
5+
from typing import List, Optional, Tuple
6+
7+
import click
8+
9+
from cycode.cli.user_settings.configuration_manager import ConfigurationManager
10+
from cycode.cli.utils.path_utils import get_file_content
11+
from cycode.cyclient.cycode_client_base import CycodeClientBase
12+
13+
14+
def _compare_versions(
15+
current_parts: List[int],
16+
latest_parts: List[int],
17+
current_is_pre: bool,
18+
latest_is_pre: bool,
19+
latest_version: str,
20+
) -> Optional[str]:
21+
"""Compare version numbers and determine if an update is needed.
22+
23+
Implements version comparison logic with special handling for pre-release versions:
24+
- Won't suggest downgrading from stable to pre-release
25+
- Will suggest upgrading from pre-release to stable of the same version
26+
27+
Args:
28+
current_parts: List of numeric version components for the current version
29+
latest_parts: List of numeric version components for the latest version
30+
current_is_pre: Whether the current version is pre-release
31+
latest_is_pre: Whether the latest version is pre-release
32+
latest_version: The full latest version string
33+
34+
Returns:
35+
str | None: The latest version string if an update is recommended,
36+
None if no update is needed
37+
"""
38+
# If current is stable and latest is pre-release, don't suggest update
39+
if not current_is_pre and latest_is_pre:
40+
return None
41+
42+
# Compare version numbers
43+
for current, latest in zip(current_parts, latest_parts):
44+
if latest > current:
45+
return latest_version
46+
if current > latest:
47+
return None
48+
49+
# If all numbers are equal, suggest update if current is pre-release and latest is stable
50+
if current_is_pre and not latest_is_pre:
51+
return latest_version
52+
53+
return None
54+
55+
56+
class VersionChecker(CycodeClientBase):
57+
PYPI_API_URL = 'https://pypi.org/pypi'
58+
PYPI_PACKAGE_NAME = 'cycode'
59+
60+
GIT_CHANGELOG_URL_PREFIX = 'https://github.com/cycodehq/cycode-cli/releases/tag/v'
61+
62+
DAILY = 24 * 60 * 60 # 24 hours in seconds
63+
WEEKLY = DAILY * 7
64+
65+
def __init__(self) -> None:
66+
"""Initialize the VersionChecker.
67+
68+
Sets up the version checker with PyPI API URL and configure the cache file location
69+
using the global configuration directory.
70+
"""
71+
super().__init__(self.PYPI_API_URL)
72+
73+
configuration_manager = ConfigurationManager()
74+
config_dir = configuration_manager.global_config_file_manager.get_config_directory_path()
75+
self.cache_file = Path(config_dir) / '.version_check'
76+
77+
def get_latest_version(self) -> Optional[str]:
78+
"""Fetch the latest version of the package from PyPI.
79+
80+
Makes an HTTP request to PyPI's JSON API to get the latest version information.
81+
82+
Returns:
83+
str | None: The latest version string if successful, None if the request fails
84+
or the version information is not available.
85+
"""
86+
try:
87+
response = self.get(f'{self.PYPI_PACKAGE_NAME}/json')
88+
data = response.json()
89+
return data.get('info', {}).get('version')
90+
except Exception:
91+
return None
92+
93+
@staticmethod
94+
def _parse_version(version: str) -> Tuple[List[int], bool]:
95+
"""Parse version string into components and identify if it's a pre-release.
96+
97+
Extracts numeric version components and determines if the version is a pre-release
98+
by checking for 'dev' in the version string.
99+
100+
Args:
101+
version: The version string to parse (e.g., '1.2.3' or '1.2.3dev4')
102+
103+
Returns:
104+
tuple: A tuple containing:
105+
- List[int]: List of numeric version components
106+
- bool: True if this is a pre-release version, False otherwise
107+
"""
108+
version_parts = [int(x) for x in re.findall(r'\d+', version)]
109+
is_prerelease = 'dev' in version
110+
111+
return version_parts, is_prerelease
112+
113+
def _should_check_update(self, is_prerelease: bool) -> bool:
114+
"""Determine if an update check should be performed based on the last check time.
115+
116+
Implements a time-based caching mechanism where update checks are performed:
117+
- Daily for pre-release versions
118+
- Weekly for stable versions
119+
120+
Args:
121+
is_prerelease: Whether the current version is a pre-release
122+
123+
Returns:
124+
bool: True if an update check should be performed, False otherwise
125+
"""
126+
if not os.path.exists(self.cache_file):
127+
return True
128+
129+
file_content = get_file_content(self.cache_file)
130+
if file_content is None:
131+
return True
132+
133+
try:
134+
last_check = float(file_content.strip())
135+
except ValueError:
136+
return True
137+
138+
duration = self.DAILY if is_prerelease else self.WEEKLY
139+
return time.time() - last_check >= duration
140+
141+
def _update_last_check(self) -> None:
142+
"""Update the timestamp of the last update check.
143+
144+
Creates the cache directory if it doesn't exist and write the current timestamp
145+
to the cache file. Silently handle any IO errors that might occur during the process.
146+
"""
147+
try:
148+
os.makedirs(os.path.dirname(self.cache_file), exist_ok=True)
149+
with open(self.cache_file, 'w', encoding='UTF-8') as f:
150+
f.write(str(time.time()))
151+
except IOError:
152+
pass
153+
154+
def check_for_update(self, current_version: str, use_cache: bool = True) -> Optional[str]:
155+
"""Check if an update is available for the current version.
156+
157+
Respects the update check frequency (daily/weekly) based on the version type
158+
159+
Args:
160+
current_version: The current version string of the CLI
161+
use_cache: If True, use the cached timestamp to determine if an update check is needed
162+
163+
Returns:
164+
str | None: The latest version string if an update is recommended,
165+
None if no update is needed or if check should be skipped
166+
"""
167+
current_parts, current_is_pre = self._parse_version(current_version)
168+
169+
# Check if we should perform the update check based on frequency
170+
if use_cache and not self._should_check_update(current_is_pre):
171+
return None
172+
173+
latest_version = self.get_latest_version()
174+
if not latest_version:
175+
return None
176+
177+
# Update the last check timestamp
178+
use_cache and self._update_last_check()
179+
180+
latest_parts, latest_is_pre = self._parse_version(latest_version)
181+
return _compare_versions(current_parts, latest_parts, current_is_pre, latest_is_pre, latest_version)
182+
183+
def check_and_notify_update(self, current_version: str, use_color: bool = True, use_cache: bool = True) -> None:
184+
"""Check for updates and display a notification if a new version is available.
185+
186+
Performs the version check and displays a formatted message with update instructions
187+
if a newer version is available. The message includes:
188+
- Current and new version numbers
189+
- Link to the changelog
190+
- Command to perform the update
191+
192+
Args:
193+
current_version: Current version of the CLI
194+
use_color: If True, use colored output in the terminal
195+
use_cache: If True, use the cached timestamp to determine if an update check is needed
196+
"""
197+
latest_version = self.check_for_update(current_version, use_cache)
198+
should_update = bool(latest_version)
199+
if should_update:
200+
update_message = (
201+
'\nNew version of cycode available! '
202+
f"{click.style(current_version, fg='yellow')}{click.style(latest_version, fg='bright_blue')}\n"
203+
f"Changelog: {click.style(f'{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}', fg='bright_blue')}\n"
204+
f"Run {click.style('pip install --upgrade cycode', fg='green')} to update\n"
205+
)
206+
click.echo(update_message, color=use_color)
207+
208+
209+
version_checker = VersionChecker()

cycode/cli/utils/path_utils.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import json
22
import os
33
from functools import lru_cache
4-
from typing import AnyStr, List, Optional
4+
from typing import TYPE_CHECKING, AnyStr, List, Optional, Union
55

66
import click
77
from binaryornot.helpers import is_binary_string
88

99
from cycode.cyclient import logger
1010

11+
if TYPE_CHECKING:
12+
from os import PathLike
13+
1114

1215
@lru_cache(maxsize=None)
1316
def is_sub_path(path: str, sub_path: str) -> bool:
@@ -73,7 +76,7 @@ def join_paths(path: str, filename: str) -> str:
7376
return os.path.join(path, filename)
7477

7578

76-
def get_file_content(file_path: str) -> Optional[AnyStr]:
79+
def get_file_content(file_path: Union[str, 'PathLike']) -> Optional[AnyStr]:
7780
try:
7881
with open(file_path, 'r', encoding='UTF-8') as f:
7982
return f.read()
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
from click.testing import CliRunner
5+
6+
from cycode import __version__
7+
from cycode.cli.commands.main_cli import main_cli
8+
from cycode.cli.commands.version.version_checker import VersionChecker
9+
from tests.conftest import CLI_ENV_VARS
10+
11+
_NEW_LATEST_VERSION = '999.0.0' # Simulate a newer version available
12+
_UPDATE_MESSAGE_PART = 'new version of cycode available'
13+
14+
15+
@patch.object(VersionChecker, 'check_for_update')
16+
def test_version_check_with_json_output(mock_check_update: patch) -> None:
17+
# When output is JSON, version check should be skipped
18+
mock_check_update.return_value = _NEW_LATEST_VERSION
19+
20+
args = ['--output', 'json', 'version']
21+
result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS)
22+
23+
# Version check message should not be present in JSON output
24+
assert _UPDATE_MESSAGE_PART not in result.output.lower()
25+
mock_check_update.assert_not_called()
26+
27+
28+
@pytest.fixture
29+
def mock_auth_info() -> 'patch':
30+
# Mock the authorization info to avoid API calls
31+
with patch('cycode.cli.commands.status.status_command.get_authorization_info', return_value=None) as mock:
32+
yield mock
33+
34+
35+
@pytest.mark.parametrize('command', ['version', 'status'])
36+
@patch.object(VersionChecker, 'check_for_update')
37+
def test_version_check_for_special_commands(mock_check_update: patch, mock_auth_info: patch, command: str) -> None:
38+
# Version and status commands should always check the version without cache
39+
mock_check_update.return_value = _NEW_LATEST_VERSION
40+
41+
result = CliRunner().invoke(main_cli, [command], env=CLI_ENV_VARS)
42+
43+
# Version information should be present in output
44+
assert _UPDATE_MESSAGE_PART in result.output.lower()
45+
# Version check must be called without a cache
46+
mock_check_update.assert_called_once_with(__version__, False)
47+
48+
49+
@patch.object(VersionChecker, 'check_for_update')
50+
def test_version_check_with_text_output(mock_check_update: patch) -> None:
51+
# Regular commands with text output should check the version using cache
52+
mock_check_update.return_value = _NEW_LATEST_VERSION
53+
54+
args = ['version']
55+
result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS)
56+
57+
# Version check message should be present in JSON output
58+
assert _UPDATE_MESSAGE_PART in result.output.lower()
59+
60+
61+
@patch.object(VersionChecker, 'check_for_update')
62+
def test_version_check_disabled(mock_check_update: patch) -> None:
63+
# When --no-update-notifier is used, version check should be skipped
64+
mock_check_update.return_value = _NEW_LATEST_VERSION
65+
66+
args = ['--no-update-notifier', 'version']
67+
result = CliRunner().invoke(main_cli, args, env=CLI_ENV_VARS)
68+
69+
# Version check message should not be present
70+
assert _UPDATE_MESSAGE_PART not in result.output.lower()
71+
mock_check_update.assert_not_called()

tests/cli/commands/version/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)