Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions common/config_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,10 @@ class LoggingConfig(BaseModel):
verbose: bool
format: LoggingFormatConfig
levels: LoggingLevelsConfig


class CliConfig(BaseModel):
"""CLI configuration settings."""

package_name: str
local_package_name: str
4 changes: 3 additions & 1 deletion common/global_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
DefaultLlm,
LlmConfig,
LoggingConfig,
CliConfig,
)

# Get the path to the root directory (one level up from common)
Expand Down Expand Up @@ -139,6 +140,7 @@ class Config(BaseSettings):
default_llm: DefaultLlm
llm_config: LlmConfig
logging: LoggingConfig
cli: CliConfig

# Environment variables (required)
DEV_ENV: str
Expand Down Expand Up @@ -251,4 +253,4 @@ def api_base(self, model_name: str) -> str:
warnings.warn(f"{env_file_to_check} file not found or empty", UserWarning)

# Create a singleton instance
global_config = Config()
global_config = Config() # type: ignore
4 changes: 4 additions & 0 deletions common/global_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ logging:
warning: true # Show warning logs
error: true # Show error logs
critical: true # Show critical logs

cli:
package_name: eito-cli
local_package_name: python-template
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies = [
"pydantic-settings>=2.12.0",
"requests>=2.31.0",
"typer[all]>=0.12.3",
"packaging>=23.0",
]
readme = "README.md"
requires-python = ">= 3.12"
Expand All @@ -43,10 +44,10 @@ packages = ["src"]
eito = "src.cli.main:app"

[tool.vulture]
ignore_decorators = ["@app.command"]
exclude = [
".venv/",
"tests/**/test_*.py",
"tests/test_template.py",
"tests/",
"utils/llm/",
"common/",
"src/utils/logging_config.py",
Expand Down
40 changes: 39 additions & 1 deletion src/cli/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,46 @@
import typer
import requests
from src.utils.logging_config import setup_logging
from src.utils.version import update_package, is_update_available

# Initialize logging
setup_logging()

app = typer.Typer()

@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
"""
Eito CLI
"""
# Check for updates, but skip if we are running the update command
if ctx.invoked_subcommand == "update":
return

try:
available, current, latest = is_update_available()
if available:
typer.secho(
f"\nUpdate available: {current} -> {latest}\nRun 'eito update' to upgrade.\n",
fg=typer.colors.YELLOW,
err=True
)
except Exception:
# Don't let update check crash the app
pass

# If no subcommand was invoked (e.g. just `eito`), show help
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
raise typer.Exit()

@app.command()
def update():
"""
Update the CLI to the latest version.
"""
update_package()

@app.command()
def run():
"""
Expand All @@ -12,7 +50,7 @@ def run():
response = requests.get("https://app.eito.me/ping")
response.raise_for_status() # Raise an exception for bad status codes
print(response.text)
except requests.exceptions.RequestException as e:
except requests.RequestException as e:
print(f"Error: {e}")

if __name__ == "__main__":
Expand Down
Empty file added src/utils/__init__.py
Empty file.
122 changes: 122 additions & 0 deletions src/utils/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import sys
import subprocess
import importlib.metadata
import requests
from packaging import version
from loguru import logger as log
import typer
import json
import time
from pathlib import Path
from common import global_config

CACHE_DIR = Path.home() / ".eito-cli"
CACHE_FILE = CACHE_DIR / "update_check.json"
CACHE_TTL = 86400 # 24 hours

def get_current_version() -> str | None:
"""
Get the currently installed version of the package.
"""
package_name = global_config.cli.package_name
local_package_name = global_config.cli.local_package_name

try:
return importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
try:
# Fallback for local development
return importlib.metadata.version(local_package_name)
except importlib.metadata.PackageNotFoundError:
log.debug(f"Package {package_name} (or {local_package_name}) not found.")
return None

def get_latest_version(package_name: str | None = None) -> str | None:
"""
Get the latest version of the package from PyPI.
"""
if package_name is None:
package_name = global_config.cli.package_name

try:
response = requests.get(f"https://pypi.org/pypi/{package_name}/json", timeout=2)
if response.status_code == 200:
return response.json()["info"]["version"]
else:
log.debug(f"Failed to fetch package info from PyPI. Status code: {response.status_code}")
return None
except requests.RequestException as e:
log.debug(f"Error checking for updates: {e}")
return None

def get_cached_latest_version() -> str | None:
"""
Get the latest version from cache if valid.
"""
if not CACHE_FILE.exists():
return None
try:
data = json.loads(CACHE_FILE.read_text())
timestamp = data.get("timestamp", 0)
if time.time() - timestamp < CACHE_TTL:
return data.get("version")
except Exception:
pass
return None

def save_cached_latest_version(version_str: str):
"""
Save the latest version to cache.
"""
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
CACHE_FILE.write_text(json.dumps({
"timestamp": time.time(),
"version": version_str
}))
except Exception:
# Silently fail if we can't write cache
pass

def is_update_available() -> tuple[bool, str | None, str | None]:
"""
Check if an update is available.
Returns: (update_available, current_version, latest_version)
"""
current_v = get_current_version()

# Try cache first
latest_v = get_cached_latest_version()

# If not in cache or expired, fetch from PyPI
if not latest_v:
latest_v = get_latest_version()
if latest_v:
save_cached_latest_version(latest_v)

if current_v and latest_v:
try:
if version.parse(latest_v) > version.parse(current_v):
return True, current_v, latest_v
except version.InvalidVersion:
log.warning(f"Invalid version string encountered: current={current_v}, latest={latest_v}")

return False, current_v, latest_v

def update_package():
"""
Update the package using pip.
"""
package = global_config.cli.package_name
# If we are in local dev (detected by checking if LOCAL_PACKAGE_NAME is installed but PACKAGE_NAME is not),
# we might strictly want to update PACKAGE_NAME, but users might be confused.
# However, the requirement is to update the CLI.
# We will always try to install the main package name.

log.info(f"Updating {package}...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "--upgrade", package])
typer.secho(f"Successfully updated {package}!", fg=typer.colors.GREEN)
except subprocess.CalledProcessError as e:
typer.secho(f"Failed to update {package}. Error: {e}", fg=typer.colors.RED)
raise typer.Exit(code=1)
2 changes: 1 addition & 1 deletion tests/healthcheck/test_pydantic_type_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def test_pydantic_type_coercion(monkeypatch):

# Reload the config module to pick up the new environment variables
importlib.reload(common_module)
config = common_module.global_config
config = common_module.global_config # type: ignore

# Verify integer coercion
assert isinstance(
Expand Down
11 changes: 9 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import pytest
from src.cli.main import run
from src.cli.main import run, update
from unittest.mock import patch, MagicMock

@patch('requests.get')
Expand All @@ -16,3 +15,11 @@ def test_cli_run(mock_get, capsys):

captured = capsys.readouterr()
assert "pong" in captured.out

@patch('src.cli.main.update_package')
def test_cli_update(mock_update):
"""
Tests that the eito update command calls update_package.
"""
update()
mock_update.assert_called_once()
80 changes: 80 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import pytest
from unittest.mock import MagicMock
from src.utils import version
import importlib.metadata

@pytest.fixture(autouse=True)
def mock_global_config(monkeypatch):
mock_config = MagicMock()
mock_config.cli.package_name = "eito-cli"
mock_config.cli.local_package_name = "python-template"
monkeypatch.setattr("src.utils.version.global_config", mock_config)

class TestVersionCheck:
def test_get_current_version_success(self, monkeypatch):
monkeypatch.setattr(importlib.metadata, "version", lambda x: "0.1.0")
assert version.get_current_version() == "0.1.0"

def test_get_current_version_fallback(self, monkeypatch):
def mock_version(name):
if name == "eito-cli":
raise importlib.metadata.PackageNotFoundError
if name == "python-template":
return "0.1.0"
raise importlib.metadata.PackageNotFoundError

monkeypatch.setattr(importlib.metadata, "version", mock_version)
assert version.get_current_version() == "0.1.0"

def test_get_latest_version_success(self, monkeypatch):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"info": {"version": "0.2.0"}}

mock_requests = MagicMock()
mock_requests.get.return_value = mock_response

monkeypatch.setattr("src.utils.version.requests", mock_requests)

assert version.get_latest_version() == "0.2.0"

def test_is_update_available_true(self, monkeypatch):
monkeypatch.setattr("src.utils.version.get_current_version", lambda: "0.1.0")
monkeypatch.setattr("src.utils.version.get_latest_version", lambda: "0.2.0")
monkeypatch.setattr("src.utils.version.get_cached_latest_version", lambda: None)
monkeypatch.setattr("src.utils.version.save_cached_latest_version", lambda x: None)

available, current, latest = version.is_update_available()
assert available is True
assert current == "0.1.0"
assert latest == "0.2.0"

def test_is_update_available_false(self, monkeypatch):
monkeypatch.setattr("src.utils.version.get_current_version", lambda: "0.2.0")
monkeypatch.setattr("src.utils.version.get_latest_version", lambda: "0.2.0")
monkeypatch.setattr("src.utils.version.get_cached_latest_version", lambda: None)
monkeypatch.setattr("src.utils.version.save_cached_latest_version", lambda x: None)

available, current, latest = version.is_update_available()
assert available is False

def test_is_update_available_fail(self, monkeypatch):
monkeypatch.setattr("src.utils.version.get_current_version", lambda: "0.1.0")
monkeypatch.setattr("src.utils.version.get_latest_version", lambda: None)
monkeypatch.setattr("src.utils.version.get_cached_latest_version", lambda: None)
monkeypatch.setattr("src.utils.version.save_cached_latest_version", lambda x: None)

available, current, latest = version.is_update_available()
assert available is False

def test_is_update_available_cached(self, monkeypatch):
monkeypatch.setattr("src.utils.version.get_current_version", lambda: "0.1.0")
monkeypatch.setattr("src.utils.version.get_cached_latest_version", lambda: "0.3.0")
# get_latest_version should NOT be called
mock_get_latest = MagicMock()
monkeypatch.setattr("src.utils.version.get_latest_version", mock_get_latest)

available, current, latest = version.is_update_available()
assert available is True
assert latest == "0.3.0"
mock_get_latest.assert_not_called()
Loading