v1.1.3
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
This commit is contained in:
parent
a340a721ea
commit
661588de0c
48 changed files with 1679 additions and 467 deletions
|
|
@ -1,8 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
import pyperclip
|
||||
from textual.app import App
|
||||
|
||||
_PREVIEW_MAX_LENGTH = 40
|
||||
|
||||
|
||||
def _copy_osc52(text: str) -> None:
|
||||
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
osc52_seq = f"\033]52;c;{encoded}\a"
|
||||
if os.environ.get("TMUX"):
|
||||
osc52_seq = f"\033Ptmux;\033{osc52_seq}\033\\"
|
||||
|
||||
with open("/dev/tty", "w") as tty:
|
||||
tty.write(osc52_seq)
|
||||
tty.flush()
|
||||
|
||||
|
||||
def _shorten_preview(texts: list[str]) -> str:
|
||||
dense_text = "⏎".join(texts).replace("\n", "⏎")
|
||||
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
||||
return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}…"
|
||||
return dense_text
|
||||
|
||||
|
||||
def copy_selection_to_clipboard(app: App) -> None:
|
||||
selected_texts = []
|
||||
|
|
@ -30,10 +53,21 @@ def copy_selection_to_clipboard(app: App) -> None:
|
|||
|
||||
combined_text = "\n".join(selected_texts)
|
||||
|
||||
try:
|
||||
pyperclip.copy(combined_text)
|
||||
app.notify("Selection added to clipboard", severity="information", timeout=2)
|
||||
except Exception:
|
||||
for copy_fn in [_copy_osc52, pyperclip.copy, app.copy_to_clipboard]:
|
||||
try:
|
||||
copy_fn(combined_text)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
app.notify(
|
||||
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
)
|
||||
break
|
||||
else:
|
||||
app.notify(
|
||||
"Use Ctrl+c to copy selections in Vibe", severity="warning", timeout=3
|
||||
"Failed to copy - no clipboard method available",
|
||||
severity="warning",
|
||||
timeout=3,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@ from rich import print as rprint
|
|||
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.core.config import (
|
||||
CONFIG_FILE,
|
||||
HISTORY_FILE,
|
||||
INSTRUCTIONS_FILE,
|
||||
MissingAPIKeyError,
|
||||
MissingPromptFileError,
|
||||
VibeConfig,
|
||||
load_api_keys_from_env,
|
||||
)
|
||||
from vibe.core.config_path import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
|
||||
from vibe.core.interaction_logger import InteractionLogger
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.types import OutputFormat, ResumeSessionInfo
|
||||
|
|
@ -138,23 +136,23 @@ def main() -> None: # noqa: PLR0912, PLR0915
|
|||
run_onboarding()
|
||||
sys.exit(0)
|
||||
try:
|
||||
if not CONFIG_FILE.exists():
|
||||
if not CONFIG_FILE.path.exists():
|
||||
try:
|
||||
VibeConfig.save_updates(VibeConfig.create_default())
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create default config file: {e}[/]")
|
||||
|
||||
if not INSTRUCTIONS_FILE.exists():
|
||||
if not INSTRUCTIONS_FILE.path.exists():
|
||||
try:
|
||||
INSTRUCTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
INSTRUCTIONS_FILE.touch()
|
||||
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
INSTRUCTIONS_FILE.path.touch()
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create instructions file: {e}[/]")
|
||||
|
||||
if not HISTORY_FILE.exists():
|
||||
if not HISTORY_FILE.path.exists():
|
||||
try:
|
||||
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
HISTORY_FILE.write_text("Hello Vibe!\n", "utf-8")
|
||||
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create history file: {e}[/]")
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from enum import StrEnum, auto
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any, ClassVar, assert_never
|
||||
|
||||
|
|
@ -35,20 +34,22 @@ from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
|||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner
|
||||
from vibe.cli.update_notifier import (
|
||||
GitHubVersionUpdateGateway,
|
||||
VersionUpdate,
|
||||
FileSystemUpdateCacheRepository,
|
||||
PyPIVersionUpdateGateway,
|
||||
UpdateCacheRepository,
|
||||
VersionUpdateAvailability,
|
||||
VersionUpdateError,
|
||||
is_version_update_available,
|
||||
VersionUpdateGateway,
|
||||
get_update_if_available,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update_gateway import VersionUpdateGateway
|
||||
from vibe.core import __version__ as CORE_VERSION
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import HISTORY_FILE, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config_path import HISTORY_FILE
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import LLMMessage, ResumeSessionInfo, Role
|
||||
from vibe.core.types import ApprovalResponse, LLMMessage, ResumeSessionInfo, Role
|
||||
from vibe.core.utils import (
|
||||
ApprovalResponse,
|
||||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
is_dangerous_directory,
|
||||
|
|
@ -72,6 +73,10 @@ class VibeApp(App):
|
|||
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
|
||||
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
|
||||
Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True),
|
||||
Binding("shift+up", "scroll_chat_up", "Scroll Up", show=False, priority=True),
|
||||
Binding(
|
||||
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
|
|
@ -83,6 +88,7 @@ class VibeApp(App):
|
|||
loaded_messages: list[LLMMessage] | None = None,
|
||||
session_info: ResumeSessionInfo | None = None,
|
||||
version_update_notifier: VersionUpdateGateway | None = None,
|
||||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
|
@ -108,12 +114,13 @@ class VibeApp(App):
|
|||
self._current_bottom_app: BottomApp = BottomApp.Input
|
||||
self.theme = config.textual_theme
|
||||
|
||||
self.history_file = HISTORY_FILE
|
||||
self.history_file = HISTORY_FILE.path
|
||||
|
||||
self._tools_collapsed = True
|
||||
self._todos_collapsed = False
|
||||
self._current_streaming_message: AssistantMessage | None = None
|
||||
self._version_update_notifier = version_update_notifier
|
||||
self._update_cache_repository = update_cache_repository
|
||||
self._is_update_check_enabled = config.enable_update_checks
|
||||
self._current_version = current_version
|
||||
self._update_notification_task: asyncio.Task | None = None
|
||||
|
|
@ -126,6 +133,7 @@ class VibeApp(App):
|
|||
# prevent a race condition where the agent initialization
|
||||
# completes exactly at the moment the user interrupts
|
||||
self._agent_init_interrupted = False
|
||||
self._auto_scroll = True
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="chat"):
|
||||
|
|
@ -460,7 +468,7 @@ class VibeApp(App):
|
|||
|
||||
async def _approval_callback(
|
||||
self, tool: str, args: dict, tool_call_id: str
|
||||
) -> tuple[str, str | None]:
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
self._pending_approval = asyncio.Future()
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
|
|
@ -955,6 +963,23 @@ class VibeApp(App):
|
|||
|
||||
self.exit()
|
||||
|
||||
def action_scroll_chat_up(self) -> None:
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
chat.scroll_relative(y=-5, animate=False)
|
||||
self._auto_scroll = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def action_scroll_chat_down(self) -> None:
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
chat.scroll_relative(y=5, animate=False)
|
||||
if self._is_scrolled_to_bottom(chat):
|
||||
self._auto_scroll = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _show_dangerous_directory_warning(self) -> None:
|
||||
is_dangerous, reason = is_dangerous_directory()
|
||||
if is_dangerous:
|
||||
|
|
@ -975,6 +1000,9 @@ class VibeApp(App):
|
|||
chat = self.query_one("#chat", VerticalScroll)
|
||||
was_at_bottom = self._is_scrolled_to_bottom(chat)
|
||||
|
||||
if was_at_bottom:
|
||||
self._auto_scroll = True
|
||||
|
||||
if isinstance(widget, AssistantMessage):
|
||||
if self._current_streaming_message is not None:
|
||||
content = widget._content or ""
|
||||
|
|
@ -1014,6 +1042,8 @@ class VibeApp(App):
|
|||
self.call_after_refresh(self._scroll_to_bottom)
|
||||
|
||||
def _anchor_if_scrollable(self) -> None:
|
||||
if not self._auto_scroll:
|
||||
return
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
if chat.max_scroll_y == 0:
|
||||
|
|
@ -1036,11 +1066,16 @@ class VibeApp(App):
|
|||
|
||||
async def _check_version_update(self) -> None:
|
||||
try:
|
||||
if self._version_update_notifier is None:
|
||||
if (
|
||||
self._version_update_notifier is None
|
||||
or self._update_cache_repository is None
|
||||
):
|
||||
return
|
||||
|
||||
update = await is_version_update_available(
|
||||
self._version_update_notifier, current_version=self._current_version
|
||||
update = await get_update_if_available(
|
||||
version_update_notifier=self._version_update_notifier,
|
||||
current_version=self._current_version,
|
||||
update_cache_repository=self._update_cache_repository,
|
||||
)
|
||||
except VersionUpdateError as error:
|
||||
self.notify(
|
||||
|
|
@ -1056,12 +1091,12 @@ class VibeApp(App):
|
|||
finally:
|
||||
self._update_notification_task = None
|
||||
|
||||
if update is None:
|
||||
if update is None or not update.should_notify:
|
||||
return
|
||||
|
||||
self._display_update_notification(update)
|
||||
|
||||
def _display_update_notification(self, update: VersionUpdate) -> None:
|
||||
def _display_update_notification(self, update: VersionUpdateAvailability) -> None:
|
||||
if self._update_notification_shown:
|
||||
return
|
||||
|
||||
|
|
@ -1084,9 +1119,8 @@ def run_textual_ui(
|
|||
loaded_messages: list[LLMMessage] | None = None,
|
||||
session_info: ResumeSessionInfo | None = None,
|
||||
) -> None:
|
||||
update_notifier = GitHubVersionUpdateGateway(
|
||||
owner="mistralai", repository="mistral-vibe", token=os.getenv("GITHUB_TOKEN")
|
||||
)
|
||||
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
app = VibeApp(
|
||||
config=config,
|
||||
auto_approve=auto_approve,
|
||||
|
|
@ -1095,5 +1129,6 @@ def run_textual_ui(
|
|||
loaded_messages=loaded_messages,
|
||||
session_info=session_info,
|
||||
version_update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
)
|
||||
app.run()
|
||||
|
|
|
|||
|
|
@ -1,31 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.update_notifier.fake_version_update_gateway import (
|
||||
FakeVersionUpdateGateway,
|
||||
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
|
||||
FileSystemUpdateCacheRepository,
|
||||
)
|
||||
from vibe.cli.update_notifier.github_version_update_gateway import (
|
||||
from vibe.cli.update_notifier.adapters.github_version_update_gateway import (
|
||||
GitHubVersionUpdateGateway,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update import (
|
||||
VersionUpdateError,
|
||||
is_version_update_available,
|
||||
from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
|
||||
PyPIVersionUpdateGateway,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
from vibe.cli.update_notifier.ports.update_cache_repository import (
|
||||
UpdateCache,
|
||||
UpdateCacheRepository,
|
||||
)
|
||||
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||
DEFAULT_GATEWAY_MESSAGES,
|
||||
VersionUpdate,
|
||||
VersionUpdateGateway,
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
from vibe.cli.update_notifier.version_update import (
|
||||
VersionUpdateAvailability,
|
||||
VersionUpdateError,
|
||||
get_update_if_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_GATEWAY_MESSAGES",
|
||||
"FakeVersionUpdateGateway",
|
||||
"FileSystemUpdateCacheRepository",
|
||||
"GitHubVersionUpdateGateway",
|
||||
"PyPIVersionUpdateGateway",
|
||||
"UpdateCache",
|
||||
"UpdateCacheRepository",
|
||||
"VersionUpdate",
|
||||
"VersionUpdateAvailability",
|
||||
"VersionUpdateError",
|
||||
"VersionUpdateGateway",
|
||||
"VersionUpdateGatewayCause",
|
||||
"VersionUpdateGatewayError",
|
||||
"is_version_update_available",
|
||||
"get_update_if_available",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.cli.update_notifier.ports.update_cache_repository import (
|
||||
UpdateCache,
|
||||
UpdateCacheRepository,
|
||||
)
|
||||
from vibe.core.config_path import VIBE_HOME
|
||||
|
||||
|
||||
class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
||||
def __init__(self, base_path: Path | str | None = None) -> None:
|
||||
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
|
||||
self._cache_file = self._base_path / "update_cache.json"
|
||||
|
||||
async def get(self) -> UpdateCache | None:
|
||||
try:
|
||||
content = await asyncio.to_thread(self._cache_file.read_text)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(content)
|
||||
latest_version = data.get("latest_version")
|
||||
stored_at_timestamp = data.get("stored_at_timestamp")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
if not isinstance(latest_version, str) or not isinstance(
|
||||
stored_at_timestamp, int
|
||||
):
|
||||
return None
|
||||
|
||||
return UpdateCache(
|
||||
latest_version=latest_version, stored_at_timestamp=stored_at_timestamp
|
||||
)
|
||||
|
||||
async def set(self, update_cache: UpdateCache) -> None:
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"latest_version": update_cache.latest_version,
|
||||
"stored_at_timestamp": update_cache.stored_at_timestamp,
|
||||
})
|
||||
await asyncio.to_thread(self._cache_file.write_text, payload)
|
||||
except OSError:
|
||||
return None
|
||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import httpx
|
||||
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||
VersionUpdate,
|
||||
VersionUpdateGateway,
|
||||
VersionUpdateGatewayCause,
|
||||
113
vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py
Normal file
113
vibe/cli/update_notifier/adapters/pypi_version_update_gateway.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from packaging.utils import parse_sdist_filename, parse_wheel_filename
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from vibe.cli.update_notifier.ports.version_update_gateway import (
|
||||
VersionUpdate,
|
||||
VersionUpdateGateway,
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
|
||||
_STATUS_CAUSES: dict[int, VersionUpdateGatewayCause] = {
|
||||
httpx.codes.NOT_FOUND: VersionUpdateGatewayCause.NOT_FOUND,
|
||||
httpx.codes.FORBIDDEN: VersionUpdateGatewayCause.FORBIDDEN,
|
||||
httpx.codes.TOO_MANY_REQUESTS: VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
|
||||
}
|
||||
|
||||
|
||||
class PyPIVersionUpdateGateway(VersionUpdateGateway):
|
||||
def __init__(
|
||||
self,
|
||||
project_name: str,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
timeout: float = 5.0,
|
||||
base_url: str = "https://pypi.org",
|
||||
) -> None:
|
||||
self._project_name = project_name
|
||||
self._client = client
|
||||
self._timeout = timeout
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
async def fetch_update(self) -> VersionUpdate | None:
|
||||
response = await self._fetch()
|
||||
self._raise_gateway_error_if_any(response)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise VersionUpdateGatewayError(
|
||||
cause=VersionUpdateGatewayCause.INVALID_RESPONSE
|
||||
) from exc
|
||||
|
||||
versions = data.get("versions") or []
|
||||
files = data.get("files") or []
|
||||
|
||||
non_yanked_versions: set[Version] = set()
|
||||
for file in files:
|
||||
if not isinstance(file, dict) or file.get("yanked") is True:
|
||||
continue
|
||||
filename = file.get("filename")
|
||||
if not isinstance(filename, str):
|
||||
continue
|
||||
parsed_version = _parse_filename_version(filename)
|
||||
if parsed_version is not None:
|
||||
non_yanked_versions.add(parsed_version)
|
||||
|
||||
valid_versions: list[Version] = []
|
||||
for raw_version in versions:
|
||||
try:
|
||||
valid_versions.append(Version(str(raw_version)))
|
||||
except InvalidVersion:
|
||||
continue
|
||||
|
||||
for version in sorted(valid_versions, reverse=True):
|
||||
if version in non_yanked_versions:
|
||||
return VersionUpdate(latest_version=str(version))
|
||||
|
||||
return None
|
||||
|
||||
async def _fetch(self) -> httpx.Response:
|
||||
headers = {"Accept": "application/vnd.pypi.simple.v1+json"}
|
||||
request_path = f"/simple/{self._project_name}/"
|
||||
|
||||
try:
|
||||
if self._client is not None:
|
||||
return await self._client.get(
|
||||
f"{self._base_url}{request_path}",
|
||||
headers=headers,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self._base_url, timeout=self._timeout
|
||||
) as client:
|
||||
return await client.get(request_path, headers=headers)
|
||||
except httpx.RequestError as exc:
|
||||
raise VersionUpdateGatewayError(
|
||||
cause=VersionUpdateGatewayCause.REQUEST_FAILED
|
||||
) from exc
|
||||
|
||||
def _raise_gateway_error_if_any(self, response: httpx.Response) -> None:
|
||||
if response.status_code in _STATUS_CAUSES:
|
||||
raise VersionUpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
|
||||
|
||||
if response.is_error:
|
||||
raise VersionUpdateGatewayError(
|
||||
cause=VersionUpdateGatewayCause.ERROR_RESPONSE
|
||||
)
|
||||
|
||||
|
||||
def _parse_filename_version(filename: str) -> Version | None:
|
||||
try:
|
||||
_, version, *_ = parse_wheel_filename(filename)
|
||||
return Version(str(version))
|
||||
except Exception:
|
||||
try:
|
||||
_, sdist_version = parse_sdist_filename(filename)
|
||||
return Version(str(sdist_version))
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
VersionUpdate,
|
||||
VersionUpdateGateway,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
|
||||
|
||||
class FakeVersionUpdateGateway(VersionUpdateGateway):
|
||||
def __init__(
|
||||
self,
|
||||
update: VersionUpdate | None = None,
|
||||
error: VersionUpdateGatewayError | None = None,
|
||||
) -> None:
|
||||
self._update: VersionUpdate | None = update
|
||||
self._error = error
|
||||
self.fetch_update_calls = 0
|
||||
|
||||
async def fetch_update(self) -> VersionUpdate | None:
|
||||
self.fetch_update_calls += 1
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._update
|
||||
15
vibe/cli/update_notifier/ports/update_cache_repository.py
Normal file
15
vibe/cli/update_notifier/ports/update_cache_repository.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpdateCache:
|
||||
latest_version: str
|
||||
stored_at_timestamp: int
|
||||
|
||||
|
||||
class UpdateCacheRepository(Protocol):
|
||||
async def get(self) -> UpdateCache | None: ...
|
||||
async def set(self, update_cache: UpdateCache) -> None: ...
|
||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
from typing import Protocol, runtime_checkable
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -49,6 +49,5 @@ class VersionUpdateGatewayError(Exception):
|
|||
super().__init__(detail)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VersionUpdateGateway(Protocol):
|
||||
async def fetch_update(self) -> VersionUpdate | None: ...
|
||||
|
|
@ -1,15 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from vibe.cli.update_notifier.version_update_gateway import (
|
||||
from vibe.cli.update_notifier import (
|
||||
DEFAULT_GATEWAY_MESSAGES,
|
||||
VersionUpdate,
|
||||
UpdateCache,
|
||||
UpdateCacheRepository,
|
||||
VersionUpdateGateway,
|
||||
VersionUpdateGatewayCause,
|
||||
VersionUpdateGatewayError,
|
||||
)
|
||||
|
||||
UPDATE_CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VersionUpdateAvailability:
|
||||
latest_version: str
|
||||
should_notify: bool
|
||||
|
||||
|
||||
class VersionUpdateError(Exception):
|
||||
def __init__(self, message: str) -> None:
|
||||
|
|
@ -37,21 +50,76 @@ def _describe_gateway_error(error: VersionUpdateGatewayError) -> str:
|
|||
return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
|
||||
|
||||
|
||||
async def is_version_update_available(
|
||||
version_update_notifier: VersionUpdateGateway, current_version: str
|
||||
) -> VersionUpdate | None:
|
||||
def _is_cache_fresh(
|
||||
cache: UpdateCache, get_current_timestamp: Callable[[], int]
|
||||
) -> bool:
|
||||
return (
|
||||
cache.stored_at_timestamp > get_current_timestamp() - UPDATE_CACHE_TTL_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def _get_cached_update_if_any(
|
||||
cache: UpdateCache, current: Version
|
||||
) -> VersionUpdateAvailability | None:
|
||||
latest_version_in_cache = _parse_version(cache.latest_version)
|
||||
if latest_version_in_cache is None or latest_version_in_cache <= current:
|
||||
return None
|
||||
|
||||
return VersionUpdateAvailability(
|
||||
latest_version=cache.latest_version, should_notify=False
|
||||
)
|
||||
|
||||
|
||||
async def _write_update_cache(
|
||||
repository: UpdateCacheRepository,
|
||||
version: str,
|
||||
get_current_timestamp: Callable[[], int],
|
||||
) -> None:
|
||||
await repository.set(
|
||||
UpdateCache(latest_version=version, stored_at_timestamp=get_current_timestamp())
|
||||
)
|
||||
|
||||
|
||||
async def get_update_if_available(
|
||||
version_update_notifier: VersionUpdateGateway,
|
||||
current_version: str,
|
||||
update_cache_repository: UpdateCacheRepository,
|
||||
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
|
||||
) -> VersionUpdateAvailability | None:
|
||||
if not (current := _parse_version(current_version)):
|
||||
return None
|
||||
|
||||
if update_cache := await update_cache_repository.get():
|
||||
if _is_cache_fresh(update_cache, get_current_timestamp):
|
||||
return _get_cached_update_if_any(update_cache, current)
|
||||
|
||||
try:
|
||||
update = await version_update_notifier.fetch_update()
|
||||
except VersionUpdateGatewayError as error:
|
||||
await _write_update_cache(
|
||||
update_cache_repository, current_version, get_current_timestamp
|
||||
)
|
||||
raise VersionUpdateError(_describe_gateway_error(error)) from error
|
||||
|
||||
if not update:
|
||||
await _write_update_cache(
|
||||
update_cache_repository, current_version, get_current_timestamp
|
||||
)
|
||||
return None
|
||||
|
||||
latest_version = _parse_version(update.latest_version)
|
||||
current = _parse_version(current_version)
|
||||
|
||||
if latest_version is None or current is None:
|
||||
if not (latest_version := _parse_version(update.latest_version)):
|
||||
return None
|
||||
|
||||
return update if latest_version > current else None
|
||||
if latest_version <= current:
|
||||
await _write_update_cache(
|
||||
update_cache_repository, current_version, get_current_timestamp
|
||||
)
|
||||
return None
|
||||
|
||||
await _write_update_cache(
|
||||
update_cache_repository, update.latest_version, get_current_timestamp
|
||||
)
|
||||
|
||||
return VersionUpdateAvailability(
|
||||
latest_version=update.latest_version, should_notify=True
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue