main
py 185 lines 6.22 KB
Raw
1 # Copyright 2026 Google LLC
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 import logging
16 import os
17 import signal
18 import sys
19 import time
20 from typing import Optional
21
22 import typer
23
24 from colab_cli.auth import AuthProvider, get_credentials
25 from colab_cli.client import Client, Prod
26 from colab_cli.history import HistoryLogger
27 from colab_cli.state import StateStore, SettingsStore
28
29
30 class State:
31 def __init__(self):
32 self.client_oauth_config = os.path.expanduser("~/.colab-cli-oauth-config.json")
33 self.config_path = None
34 self.logtostderr = False
35 self.auth_provider = AuthProvider.OAUTH2
36 self._client = None
37 self._store = None
38 self._settings_store = None
39 self._history = None
40 self._sessions = None
41
42 @property
43 def store(self):
44 if self._store is None:
45 self._store = StateStore(self.config_path)
46 return self._store
47
48 @property
49 def settings_store(self):
50 if self._settings_store is None:
51 # We don't currently allow overriding settings path via CLI,
52 # but we could if needed. For now, use default.
53 self._settings_store = SettingsStore()
54 return self._settings_store
55
56 @property
57 def history(self):
58 if self._history is None:
59 self._history = HistoryLogger()
60 return self._history
61
62 @property
63 def client(self):
64 if self._client is None:
65 creds = get_credentials(
66 self.client_oauth_config, provider=self.auth_provider
67 )
68 self._client = Client(Prod(), creds)
69 return self._client
70
71 def prune_session(self, name: str):
72 """Removes a session from local state and kills its keep-alive process."""
73 s = self.store.get(name)
74 if s and s.keep_alive_pid:
75 kill_process(s.keep_alive_pid)
76 self.store.remove(name)
77 if self._sessions and name in self._sessions:
78 del self._sessions[name]
79 self.history.log_event(name, "session_terminated", {"reason": "pruned"})
80
81 def sync_sessions(self):
82 if self._sessions is not None:
83 return self._sessions, self.client.list_assignments()
84
85 # Check local store first. If it's empty, we don't necessarily need to hit the backend
86 # unless we are specifically looking for server-side assignments (e.g. 'colab sessions').
87 local_sessions = self.store.list()
88 if not local_sessions:
89 self._sessions = {}
90 # We still need to return assignments for 'colab sessions' to work
91 # But we only trigger client creation (and thus auth) if we have to.
92 try:
93 assignments = self.client.list_assignments()
94 except SystemExit:
95 # If auth fails, we just return empty assignments
96 assignments = []
97 return self._sessions, assignments
98
99 assignments = self.client.list_assignments()
100 active_endpoints = {a.endpoint for a in assignments}
101
102 self._sessions = local_sessions
103 pruned = 0
104 for name, s in list(self._sessions.items()):
105 if s.endpoint not in active_endpoints:
106 self.prune_session(name)
107 pruned += 1
108
109 if pruned > 0:
110 typer.echo(f"[colab] Pruned {pruned} stale local session(s).")
111
112 return self._sessions, assignments
113
114 def resolve_session(self, session_name: Optional[str]) -> str:
115 if session_name:
116 return session_name
117
118 # Check local store first to avoid hitting the backend (and triggering auth) if we don't have to
119 local_sessions = self.store.list()
120 if not local_sessions:
121 typer.echo(
122 "[colab] Error: No active sessions found. Create one with 'colab new'."
123 )
124 raise typer.Exit(1)
125
126 # If we have local sessions, we need to sync to make sure they are still valid.
127 # This will trigger auth if valid credentials are not present.
128 sessions, _ = self.sync_sessions()
129 active_names = list(sessions.keys())
130
131 if len(active_names) == 1:
132 name = active_names[0]
133 typer.echo(f"[colab] Using unique session '{name}'.")
134 return name
135 elif len(active_names) > 1:
136 typer.echo(
137 f"[colab] Error: Multiple active sessions found. Specify one with -s: {', '.join(active_names)}"
138 )
139 raise typer.Exit(1)
140 else:
141 typer.echo(
142 "[colab] Error: No active sessions found. Create one with 'colab new'."
143 )
144 raise typer.Exit(1)
145
146
147 state = State()
148
149
150 def kill_process(pid: int):
151 """Safely terminates a process by PID."""
152 if not pid:
153 return
154 try:
155 os.kill(pid, signal.SIGTERM)
156 # Give it a moment to exit
157 for _ in range(5):
158 time.sleep(0.1)
159 os.kill(pid, 0)
160 except OSError:
161 # Already dead
162 pass
163 except Exception:
164 logging.debug(f"Failed to kill process {pid}")
165
166
167 def setup_logging(log_to_stderr: bool):
168 log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
169 logger = logging.getLogger()
170 logger.setLevel(logging.DEBUG)
171
172 requests_log = logging.getLogger("urllib3")
173 requests_log.setLevel(logging.DEBUG)
174 requests_log.propagate = True
175
176 log_dir = os.path.expanduser("~/.config/colab-cli")
177 os.makedirs(log_dir, exist_ok=True)
178 file_handler = logging.FileHandler(os.path.join(log_dir, "colab.log"))
179 file_handler.setFormatter(logging.Formatter(log_format))
180 logger.addHandler(file_handler)
181
182 if log_to_stderr:
183 stream_handler = logging.StreamHandler(sys.stderr)
184 stream_handler.setFormatter(logging.Formatter(log_format))
185 logger.addHandler(stream_handler)