Skip to content

Commit 6d205de

Browse files
authored
Merge pull request #125 from WecoAI/dev
Create WecoEnv class for managing the weco environment, log observe events
2 parents 8eb002b + da3bdbd commit 6d205de

6 files changed

Lines changed: 228 additions & 55 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ name = "weco"
88
authors = [{ name = "Weco AI Team", email = "contact@weco.ai" }]
99
description = "Documentation for `weco`, a CLI for using Weco AI's code optimizer."
1010
readme = "README.md"
11-
version = "0.3.21"
11+
version = "0.3.22"
1212
license = { file = "LICENSE" }
1313
requires-python = ">=3.9"
1414
dependencies = [

weco/cli.py

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,12 @@
55
from rich.traceback import install
66

77
from .auth import perform_login
8-
from .config import clear_api_key, load_weco_api_key
9-
from .observe.cli import configure_observe_parser, execute_observe_command
8+
from .config import clear_api_key
109
from .constants import DEFAULT_MODELS
11-
from .events import (
12-
send_event,
13-
create_event_context,
14-
get_event_context,
15-
set_event_context,
16-
CLIInvokedEvent,
17-
RunStartAttemptedEvent,
18-
)
19-
from .utils import check_for_cli_updates, get_default_model, UnrecognizedAPIKeysError, DefaultModelNotFoundError
10+
from .env import WecoEnv
11+
from .events import send_event, get_event_context, CLIInvokedEvent, RunStartAttemptedEvent
12+
from .observe.cli import configure_observe_parser, execute_observe_command
13+
from .utils import get_default_model, UnrecognizedAPIKeysError, DefaultModelNotFoundError
2014
from .validation import validate_sources, validate_log_directory, ValidationError, print_validation_error
2115

2216

@@ -407,8 +401,6 @@ def main() -> None:
407401

408402
def _main() -> None:
409403
"""Internal main function containing the CLI logic."""
410-
check_for_cli_updates()
411-
412404
parser = argparse.ArgumentParser(
413405
description="[bold cyan]Weco CLI[/]\nEnhance your code with AI-driven optimization.",
414406
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -479,18 +471,22 @@ def _main() -> None:
479471

480472
args = parser.parse_args()
481473

482-
# Create event context with via_skill flag
483-
via_skill = getattr(args, "via_skill", False)
484-
ctx = create_event_context(via_skill=via_skill)
485-
set_event_context(ctx)
474+
# Initialize environment
475+
env = WecoEnv(via_skill=getattr(args, "via_skill", False))
476+
if args.command != "setup":
477+
env.check_for_updates()
486478

487479
# Send CLI invocation event
488-
send_event(CLIInvokedEvent(command=args.command or "help"), ctx)
480+
send_event(
481+
CLIInvokedEvent(
482+
command=args.command or "help",
483+
installed_skills=[{"tool": s.tool, "version": s.version} for s in env.installed_skills],
484+
),
485+
env.event_context,
486+
)
489487

490488
if args.command == "login":
491-
# Check if already logged in
492-
existing_key = load_weco_api_key()
493-
if existing_key:
489+
if env.is_authenticated:
494490
console.print("[bold green]You are already logged in.[/]")
495491
console.print("[dim]Use 'weco logout' to log out first if you want to switch accounts.[/]")
496492
sys.exit(0)

weco/env.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# weco/env.py
2+
"""High-level interface to the weco CLI environment.
3+
4+
Provides a single object that encapsulates version info, authentication
5+
state, installed skills, event context, and update checking.
6+
7+
Usage::
8+
9+
env = WecoEnv(via_skill=args.via_skill)
10+
env.check_for_updates()
11+
12+
if env.is_authenticated:
13+
...
14+
15+
send_event(SomeEvent(), env.event_context)
16+
"""
17+
18+
import pathlib
19+
import time
20+
from dataclasses import dataclass
21+
22+
import requests
23+
from packaging.version import parse as parse_version
24+
25+
from . import __pkg_version__, __base_url__, __dashboard_url__
26+
from .config import load_weco_api_key
27+
from .events import EventContext, create_event_context, set_event_context
28+
29+
30+
_UNSET = object()
31+
32+
# Update checking
33+
_CLI_UPDATE_PYPI_URL = "https://pypi.org/pypi/weco/json"
34+
35+
36+
@dataclass(frozen=True)
37+
class InstalledSkill:
38+
"""A locally installed weco skill."""
39+
40+
tool: str # "claude-code" or "cursor"
41+
path: pathlib.Path
42+
version: str # Installed skill version ("" if unknown)
43+
44+
45+
class WecoEnv:
46+
"""High-level interface to the weco CLI environment.
47+
48+
Encapsulates version info, authentication state, installed skills,
49+
event context, and update checking behind a single object that CLI
50+
commands can depend on.
51+
"""
52+
53+
# Read once at class-load time — these never change within a process.
54+
version: str = __pkg_version__
55+
base_url: str = __base_url__
56+
dashboard_url: str = __dashboard_url__
57+
58+
def __init__(self, via_skill: bool = False):
59+
self._via_skill = via_skill
60+
# _UNSET distinguishes "not yet loaded" from None ("no key configured").
61+
self._api_key = _UNSET
62+
self._event_ctx: EventContext | None = None
63+
64+
# ── Authentication ──────────────────────────────────────────
65+
66+
@property
67+
def api_key(self) -> str | None:
68+
"""Weco API key (loaded lazily from env var or credentials file)."""
69+
if self._api_key is _UNSET:
70+
self._api_key = load_weco_api_key()
71+
return self._api_key
72+
73+
@property
74+
def is_authenticated(self) -> bool:
75+
"""Whether a valid API key is available."""
76+
return self.api_key is not None
77+
78+
@property
79+
def auth_headers(self) -> dict[str, str]:
80+
"""Authorization headers for API requests (empty if not authenticated)."""
81+
if self.api_key:
82+
return {"Authorization": f"Bearer {self.api_key}"}
83+
return {}
84+
85+
def clear_cached_api_key(self) -> None:
86+
"""Reset the cached API key so it will be re-loaded on next access."""
87+
self._api_key = _UNSET
88+
89+
# ── Installed Skills ────────────────────────────────────────
90+
91+
@property
92+
def installed_skills(self) -> list[InstalledSkill]:
93+
"""Discover locally installed weco skills."""
94+
from .setup import WECO_SKILL_DIR, CURSOR_WECO_SKILL_DIR
95+
96+
skills = []
97+
for tool, path in [("claude-code", WECO_SKILL_DIR), ("cursor", CURSOR_WECO_SKILL_DIR)]:
98+
if path.exists():
99+
version = ""
100+
try:
101+
version = (path / "VERSION").read_text().strip()
102+
except (OSError, FileNotFoundError):
103+
pass
104+
skills.append(InstalledSkill(tool=tool, path=path, version=version))
105+
return skills
106+
107+
# ── Event Context ───────────────────────────────────────────
108+
109+
@property
110+
def event_context(self) -> EventContext:
111+
"""Event context for this CLI invocation (created lazily).
112+
113+
Also sets the module-level global so that code using
114+
``get_event_context()`` continues to work.
115+
"""
116+
if self._event_ctx is None:
117+
self._event_ctx = create_event_context(via_skill=self._via_skill)
118+
set_event_context(self._event_ctx)
119+
return self._event_ctx
120+
121+
# ── Update Checking ─────────────────────────────────────────
122+
123+
def check_for_updates(self) -> None:
124+
"""Check for CLI package and skill updates.
125+
126+
Prints a yellow warning and pauses briefly for each available
127+
update. Fails silently — never disrupts the user.
128+
"""
129+
self._check_cli_updates()
130+
self._check_skill_updates()
131+
132+
def _check_cli_updates(self) -> None:
133+
"""Check PyPI for a newer CLI version."""
134+
try:
135+
response = requests.get(_CLI_UPDATE_PYPI_URL, timeout=5)
136+
response.raise_for_status()
137+
latest_version_str = response.json()["info"]["version"]
138+
139+
if parse_version(latest_version_str) > parse_version(self.version):
140+
_print_update_warning(
141+
f"New Weco CLI version ({latest_version_str}) available (you have {self.version}). Run: pipx upgrade weco"
142+
)
143+
except Exception:
144+
pass
145+
146+
def _check_skill_updates(self) -> None:
147+
"""Check the Weco API for a newer skill version."""
148+
try:
149+
if not self.installed_skills:
150+
return
151+
152+
skill = self.installed_skills[0]
153+
154+
response = requests.get(f"{self.base_url}/version", timeout=5)
155+
if response.status_code != 200:
156+
return
157+
158+
latest_version = response.json().get("latest_skill_version", "")
159+
if not latest_version:
160+
return
161+
162+
# If installed skill has no version (pre-VERSION file install), always prompt to update
163+
if not skill.version:
164+
commands = ", ".join(f"weco setup {s.tool}" for s in self.installed_skills)
165+
_print_update_warning(f"New weco skill version ({latest_version}) available. Run: {commands}")
166+
return
167+
168+
if parse_version(latest_version) > parse_version(skill.version):
169+
commands = ", ".join(f"weco setup {s.tool}" for s in self.installed_skills)
170+
_print_update_warning(
171+
f"New weco skill version ({latest_version}) available (you have {skill.version}). Run: {commands}"
172+
)
173+
except Exception:
174+
pass
175+
176+
177+
def _print_update_warning(message: str) -> None:
178+
"""Print a yellow warning and pause briefly so the user sees it."""
179+
print(f"\033[93m{message}\033[0m")
180+
time.sleep(2)

weco/events.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class CLIInvokedEvent(BaseEvent):
5757
"""Tracked when the CLI is invoked."""
5858

5959
command: str # The command being run (run, login, setup, etc.)
60+
installed_skills: list[dict[str, str]] = Field(default_factory=list) # [{"tool": ..., "version": ...}]
6061

6162
@property
6263
def event_name(self) -> str:
@@ -113,6 +114,28 @@ def event_name(self) -> str:
113114
return "run.start.attempted"
114115

115116

117+
class ObserveInitEvent(BaseEvent):
118+
"""Tracked when an external run is initialized via observe."""
119+
120+
metric: str
121+
goal: str # "maximize" or "minimize"
122+
source_count: int
123+
124+
@property
125+
def event_name(self) -> str:
126+
return "observe.init"
127+
128+
129+
class ObserveLogEvent(BaseEvent):
130+
"""Tracked when a step is logged to an external run via observe."""
131+
132+
status: str # "completed" or "failed"
133+
134+
@property
135+
def event_name(self) -> str:
136+
return "observe.log"
137+
138+
116139
class AuthStartedEvent(BaseEvent):
117140
"""Tracked when authentication flow begins."""
118141

weco/observe/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from weco.auth import handle_authentication
1313
from weco.browser import open_browser
14+
from weco.events import send_event, ObserveInitEvent, ObserveLogEvent
1415
from weco.observe import api
1516
from weco import __dashboard_url__
1617

@@ -109,6 +110,10 @@ def _handle_init(args: argparse.Namespace, auth_headers: dict) -> None:
109110

110111
maximize = args.goal in ("maximize", "max")
111112

113+
send_event(
114+
ObserveInitEvent(metric=args.metric, goal="maximize" if maximize else "minimize", source_count=len(source_code))
115+
)
116+
112117
result = api.create_run(
113118
source_code=source_code,
114119
metric_name=args.metric,
@@ -146,6 +151,8 @@ def _handle_log(args: argparse.Namespace, auth_headers: dict) -> None:
146151
if source_arg:
147152
code = _read_code_files(source_arg)
148153

154+
send_event(ObserveLogEvent(status=args.status))
155+
149156
api.log_step(
150157
run_id=args.run_id,
151158
step=args.step,

weco/utils.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
from rich.live import Live
99
from rich.panel import Panel
1010
import pathlib
11-
import requests
12-
from packaging.version import parse as parse_version
1311
from .constants import TRUNCATION_THRESHOLD, TRUNCATION_KEEP_LENGTH, SUPPORTED_FILE_EXTENSIONS, DEFAULT_MODELS
1412

1513

@@ -302,37 +300,6 @@ def run_evaluation(eval_command: str, timeout: int | None = None) -> str:
302300
return f"Evaluation timed out after {'an unspecified duration' if timeout is None else f'{timeout} seconds'}."
303301

304302

305-
def check_for_cli_updates():
306-
"""Checks PyPI for a newer version of the weco package and notifies the user."""
307-
try:
308-
from . import __pkg_version__
309-
310-
pypi_url = "https://pypi.org/pypi/weco/json"
311-
response = requests.get(pypi_url, timeout=5) # Short timeout for non-critical check
312-
response.raise_for_status()
313-
latest_version_str = response.json()["info"]["version"]
314-
315-
current_version = parse_version(__pkg_version__)
316-
latest_version = parse_version(latest_version_str)
317-
318-
if latest_version > current_version:
319-
yellow_start = "\033[93m"
320-
reset_color = "\033[0m"
321-
message = f"WARNING: New weco version ({latest_version_str}) available (you have {__pkg_version__}). Run: pip install --upgrade weco"
322-
print(f"{yellow_start}{message}{reset_color}")
323-
time.sleep(2) # Wait for 2 second
324-
325-
except requests.exceptions.RequestException:
326-
# Silently fail on network errors, etc. Don't disrupt user.
327-
pass
328-
except (KeyError, ValueError):
329-
# Handle cases where the PyPI response format might be unexpected
330-
pass
331-
except Exception:
332-
# Catch any other unexpected error during the check
333-
pass
334-
335-
336303
def get_default_model(api_keys: dict[str, str] | None = None) -> str:
337304
"""Determine the default model to use based on the API keys."""
338305
providers = {provider for provider, _ in DEFAULT_MODELS}

0 commit comments

Comments
 (0)