main
py 157 lines 5.21 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 contextlib
16 import json
17 import os
18 from datetime import datetime
19 from typing import Dict, Optional, Tuple, Iterator, IO
20
21 import filelock
22 from pydantic import BaseModel
23
24
25 class SessionState(BaseModel):
26 name: str
27 token: str
28 url: str
29 endpoint: str
30 variant: str = "DEFAULT"
31 accelerator: str = "NONE"
32 machine_shape: str = "STANDARD"
33 kernel_id: Optional[str] = None
34 session_id: Optional[str] = None
35 last_execution: Optional[Tuple[str, Optional[str], str]] = None
36 running: Optional[str] = None
37 keep_alive_pid: Optional[int] = None
38
39
40 class Settings(BaseModel):
41 update_url: str = "https://pypi.org/pypi/google-colab-cli/json"
42 last_check: Optional[datetime] = None
43 enable_update_check: bool = True
44 # Highest version seen on the update source; cached for the banner.
45 latest_version: Optional[str] = None
46
47
48 class _LockedFileStore:
49 def __init__(self, path: str):
50 self.path = path
51 self.lock_path = "%s.lock" % self.path
52 # ReadWriteLock gives us shared (concurrent) readers and exclusive
53 # writers -- the cross-platform equivalent of fcntl LOCK_SH/LOCK_EX.
54 # is_singleton=False keeps each store's lock independent: with the
55 # default (True), two StateStore instances for the same path in one
56 # process are merged into a single reentrant lock, whose reentrancy
57 # guard then raises RuntimeError when two threads contend for the write
58 # lock. We want them to actually serialize via the underlying file lock.
59 self._rwlock = filelock.ReadWriteLock(self.lock_path, is_singleton=False)
60 self._ensure_dir()
61
62 def _ensure_dir(self):
63 os.makedirs(os.path.dirname(self.path), exist_ok=True)
64
65 def _write_data(self, f: IO, data: str):
66 f.seek(0)
67 f.truncate()
68 f.write(data)
69 f.flush()
70 os.fsync(f.fileno())
71
72 @contextlib.contextmanager
73 def _lock_shared(self) -> Iterator[Optional[IO]]:
74 if not os.path.exists(self.path):
75 yield None
76 return
77 with self._rwlock.read_lock():
78 with open(self.path, "r") as f:
79 yield f
80
81 @contextlib.contextmanager
82 def _lock_exclusive(self) -> Iterator[IO]:
83 with self._rwlock.write_lock():
84 with open(self.path, "a+") as f:
85 yield f
86
87
88 class SettingsStore(_LockedFileStore):
89 def __init__(self, path: Optional[str] = None):
90 if not path:
91 path = os.path.expanduser("~/.config/colab-cli/settings.json")
92 super().__init__(path)
93
94 def load(self) -> Settings:
95 with self._lock_shared() as f:
96 if f is None:
97 return Settings()
98 try:
99 content = f.read()
100 if not content or content.isspace():
101 return Settings()
102 data = json.loads(content)
103 return Settings.model_validate(data)
104 except Exception:
105 return Settings()
106
107 def save(self, settings: Settings):
108 with self._lock_exclusive() as f:
109 self._write_data(f, settings.model_dump_json(indent=2))
110
111
112 class StateStore(_LockedFileStore):
113 def __init__(self, path: Optional[str] = None):
114 if not path:
115 path = os.path.expanduser("~/.config/colab-cli/sessions.json")
116 super().__init__(path)
117
118 def _load_raw(self, f) -> Dict[str, SessionState]:
119 try:
120 f.seek(0)
121 content = f.read()
122 if not content or content.isspace():
123 return {}
124 data = json.loads(content)
125 return {k: SessionState(**v) for k, v in data.items()}
126 except Exception:
127 return {}
128
129 def _save_raw(self, f, sessions: Dict[str, SessionState]):
130 content = json.dumps({k: v.model_dump() for k, v in sessions.items()}, indent=2)
131 self._write_data(f, content)
132
133 def add(self, state: SessionState):
134 with self._lock_exclusive() as f:
135 sessions = self._load_raw(f)
136 sessions[state.name] = state
137 self._save_raw(f, sessions)
138
139 def get(self, name: str) -> Optional[SessionState]:
140 with self._lock_shared() as f:
141 if f is None:
142 return None
143 sessions = self._load_raw(f)
144 return sessions.get(name)
145
146 def remove(self, name: str):
147 with self._lock_exclusive() as f:
148 sessions = self._load_raw(f)
149 if name in sessions:
150 del sessions[name]
151 self._save_raw(f, sessions)
152
153 def list(self) -> Dict[str, SessionState]:
154 with self._lock_shared() as f:
155 if f is None:
156 return {}
157 return self._load_raw(f)