cdx-manager 0.5.7 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli_render.py CHANGED
@@ -98,6 +98,19 @@ def _dim(text, use_color=False):
98
98
  return _style(text, "2", use_color)
99
99
 
100
100
 
101
+ def _format_launch_text(launch):
102
+ if not launch:
103
+ return "default"
104
+ parts = []
105
+ if launch.get("power"):
106
+ parts.append(launch["power"])
107
+ if launch.get("permission"):
108
+ parts.append(launch["permission"])
109
+ if launch.get("fast") is True:
110
+ parts.append("fast-on")
111
+ return "/".join(parts) or "default"
112
+
113
+
101
114
  def format_error(error, env=None, stderr=None):
102
115
  return _style(str(error), "31", _should_use_color(env or os.environ, stderr or sys.stderr))
103
116
 
@@ -116,40 +129,26 @@ def _format_sessions(service, use_color=False):
116
129
  headers = [_style(header, "1", use_color) for header in headers]
117
130
  table_rows = []
118
131
  for r in rows:
119
- parts = [r["name"]]
132
+ name = f"{r['name']}*" if r.get("active") else r["name"]
133
+ parts = [name]
120
134
  if has_provider:
121
135
  parts.append(r.get("provider") or "n/a")
122
136
  status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
123
137
  parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
124
138
  if has_launch:
125
139
  launch = r.get("launch") or {}
126
- if launch:
127
- launch_text = "/".join([
128
- launch.get("power") or "-",
129
- launch.get("permission") or "-",
130
- "fast-on" if launch.get("fast") is True else "fast-off" if launch.get("fast") is False else "-",
131
- ])
132
- else:
133
- launch_text = "default"
134
- parts.append(_dim(launch_text, use_color))
140
+ parts.append(_dim(_format_launch_text(launch), use_color))
135
141
  parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
136
142
  table_rows.append(parts)
137
143
  lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
138
144
  lines += [
139
145
  _style("Next actions:", "1", use_color),
140
- f" {_style('cdx add <name>', '36', use_color)}",
141
- f" {_style('cdx <name>', '36', use_color)}",
142
- f" {_style('cdx login <name>', '36', use_color)}",
143
- f" {_style('cdx logout <name>', '36', use_color)}",
144
- f" {_style('cdx config <name>', '36', use_color)}",
146
+ f" {_style('cdx status', '36', use_color)}",
147
+ f" {_style('cdx ready', '36', use_color)}",
145
148
  f" {_style('cdx set <name> --power medium --permission default --fast off', '36', use_color)}",
146
- f" {_style('cdx context show', '36', use_color)}",
147
- f" {_style('cdx handoff <name>', '36', use_color)}",
148
149
  f" {_style('cdx handoff <source> <target>', '36', use_color)}",
149
- f" {_style('cdx disable <name>', '36', use_color)}",
150
- f" {_style('cdx enable <name>', '36', use_color)}",
151
- f" {_style('cdx ren <source> <dest>', '36', use_color)}",
152
- f" {_style('cdx rmv <name>', '36', use_color)}",
153
- f" {_style('cdx status', '36', use_color)}",
150
+ f" {_style('cdx history', '36', use_color)}",
151
+ f" {_style('cdx help', '36', use_color)}",
152
+ f" {_style('cdx update', '36', use_color)}",
154
153
  ]
155
154
  return "\n".join(lines)
@@ -131,7 +131,7 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
131
131
  args = args + [transcript_path]
132
132
  args = args + [spec["command"]] + spec["args"]
133
133
  else:
134
- args = ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
134
+ args = _default_script_args(transcript_path, spec)
135
135
  return {
136
136
  "command": script_bin,
137
137
  "args": args,
@@ -142,6 +142,13 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
142
142
  }
143
143
 
144
144
 
145
+ def _default_script_args(transcript_path, spec):
146
+ if sys.platform.startswith("linux"):
147
+ command = shlex.join([spec["command"]] + spec["args"])
148
+ return ["-q", "-F", "-c", command, transcript_path]
149
+ return ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
150
+
151
+
145
152
  def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
146
153
  if initial_prompt is not None:
147
154
  if not isinstance(initial_prompt, str):
@@ -277,7 +284,7 @@ def _signal_name(sig):
277
284
 
278
285
  def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
279
286
  env_override=None, signal_emitter=None,
280
- initial_prompt=None):
287
+ initial_prompt=None, lifecycle_callback=None):
281
288
  spawn = spawn or subprocess.Popen
282
289
  spec = (
283
290
  _build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
@@ -293,11 +300,32 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
293
300
  **{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
294
301
  )
295
302
 
303
+ start_time = datetime.now(timezone.utc)
304
+
305
+ child_pid = None
306
+
307
+ def run_info(current_spec, returncode=None):
308
+ end_time = datetime.now(timezone.utc)
309
+ return {
310
+ "started_at": start_time.isoformat().replace("+00:00", "Z"),
311
+ "ended_at": end_time.isoformat().replace("+00:00", "Z"),
312
+ "duration_ms": int((end_time - start_time).total_seconds() * 1000),
313
+ "command": current_spec.get("command"),
314
+ "args": list(current_spec.get("args") or []),
315
+ "label": current_spec.get("label"),
316
+ "transcript_path": current_spec.get("transcript_path"),
317
+ "pid": child_pid,
318
+ "returncode": returncode,
319
+ }
320
+
296
321
  try:
297
322
  child = start_child(spec)
298
323
  except FileNotFoundError as error:
299
324
  spec = _fallback_launch_spec_or_raise(spec, error)
300
325
  child = start_child(spec)
326
+ child_pid = getattr(child, "pid", None) or os.getpid()
327
+ if lifecycle_callback:
328
+ lifecycle_callback("started", run_info(spec))
301
329
 
302
330
  forwarded_signal = None
303
331
  handlers = []
@@ -336,6 +364,9 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
336
364
  if forwarded_signal is None and child.returncode != 0 and _should_retry_without_transcript(spec):
337
365
  spec = _fallback_launch_spec_or_raise(spec)
338
366
  child = start_child(spec)
367
+ child_pid = getattr(child, "pid", None) or os.getpid()
368
+ if lifecycle_callback:
369
+ lifecycle_callback("started", run_info(spec))
339
370
  child.wait()
340
371
  finally:
341
372
  if use_emitter:
@@ -352,14 +383,26 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
352
383
  pass
353
384
 
354
385
  if forwarded_signal is not None:
355
- raise CdxError(
386
+ error = CdxError(
356
387
  f"{spec['label']} interrupted by {_signal_name(forwarded_signal)} for session {session['name']}",
357
388
  _signal_exit_code(forwarded_signal),
358
389
  )
390
+ error.run_info = run_info(spec, returncode=error.exit_code)
391
+ if lifecycle_callback:
392
+ lifecycle_callback("finished", error.run_info)
393
+ raise error
359
394
  if child.returncode != 0:
360
- raise CdxError(
395
+ error = CdxError(
361
396
  f"{spec['label']} exited with code {child.returncode} for session {session['name']}"
362
397
  )
398
+ error.run_info = run_info(spec, returncode=child.returncode)
399
+ if lifecycle_callback:
400
+ lifecycle_callback("finished", error.run_info)
401
+ raise error
402
+ info = run_info(spec, returncode=child.returncode)
403
+ if lifecycle_callback:
404
+ lifecycle_callback("finished", info)
405
+ return info
363
406
 
364
407
 
365
408
  def _fallback_launch_spec_or_raise(spec, original_error=None):
@@ -4,6 +4,7 @@ import json
4
4
  import base64
5
5
  import sys
6
6
  import tempfile
7
+ import uuid
7
8
  from datetime import datetime, timezone
8
9
  from urllib.parse import quote
9
10
 
@@ -28,11 +29,13 @@ RESERVED_SESSION_NAMES = {
28
29
  "export",
29
30
  "help",
30
31
  "handoff",
32
+ "history",
31
33
  "import",
32
34
  "login",
33
35
  "logout",
34
36
  "mv",
35
37
  "notify",
38
+ "ready",
36
39
  "repair",
37
40
  "ren",
38
41
  "rename",
@@ -116,6 +119,24 @@ def _local_now_iso():
116
119
  return datetime.now().astimezone().isoformat()
117
120
 
118
121
 
122
+ def _process_is_running(pid):
123
+ try:
124
+ pid = int(pid)
125
+ except (TypeError, ValueError):
126
+ return False
127
+ if pid <= 0:
128
+ return False
129
+ try:
130
+ os.kill(pid, 0)
131
+ except ProcessLookupError:
132
+ return False
133
+ except PermissionError:
134
+ return True
135
+ except OSError:
136
+ return False
137
+ return True
138
+
139
+
119
140
  def _safe_relpath(path):
120
141
  normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
121
142
  if (
@@ -324,6 +345,32 @@ def create_session_service(options=None):
324
345
  raise CdxError(f"Unsupported provider: {value}")
325
346
  return value
326
347
 
348
+ def _runtime_is_active(runtime):
349
+ return (
350
+ isinstance(runtime, dict)
351
+ and runtime.get("status") == "running"
352
+ and _process_is_running(runtime.get("pid"))
353
+ )
354
+
355
+ def _session_runtime(name):
356
+ state = store["read_session_state"](name)
357
+ if not state:
358
+ return None
359
+ runtime = state.get("runtime")
360
+ if _runtime_is_active(runtime):
361
+ return runtime
362
+ if isinstance(runtime, dict) and runtime.get("status") == "running":
363
+ store["write_session_state"](name, {
364
+ **state,
365
+ "status": "ready",
366
+ "runtime": {
367
+ **runtime,
368
+ "status": "stale",
369
+ "endedAt": _local_now_iso(),
370
+ },
371
+ })
372
+ return None
373
+
327
374
  def _validate_new_session_name(name):
328
375
  if not name:
329
376
  raise CdxError("Session name is required")
@@ -585,6 +632,56 @@ def create_session_service(options=None):
585
632
  **s, "updatedAt": now, "lastLaunchedAt": now
586
633
  })
587
634
 
635
+ def start_session_runtime(name, payload=None):
636
+ session = store["get_session"](name)
637
+ if not session:
638
+ raise CdxError(f"Unknown session: {name}")
639
+ state = store["read_session_state"](name)
640
+ if not state:
641
+ raise CdxError(f"Session state missing for {name}. Reconnect required.")
642
+ payload = dict(payload or {})
643
+ now = _local_now_iso()
644
+ runtime = {
645
+ "status": "running",
646
+ "runId": payload.get("runId") or uuid.uuid4().hex,
647
+ "startedAt": payload.get("startedAt") or now,
648
+ "pid": payload.get("pid") or os.getpid(),
649
+ "command": payload.get("command"),
650
+ "label": payload.get("label"),
651
+ "transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
652
+ }
653
+ store["write_session_state"](name, {
654
+ **state,
655
+ "status": "running",
656
+ "runtime": runtime,
657
+ })
658
+ return runtime
659
+
660
+ def finish_session_runtime(name, run_id=None, payload=None):
661
+ session = store["get_session"](name)
662
+ if not session:
663
+ raise CdxError(f"Unknown session: {name}")
664
+ state = store["read_session_state"](name)
665
+ if not state:
666
+ return None
667
+ runtime = state.get("runtime") or {}
668
+ if run_id and runtime.get("runId") != run_id:
669
+ return runtime
670
+ payload = dict(payload or {})
671
+ updated_runtime = {
672
+ **runtime,
673
+ "status": payload.get("status") or "stopped",
674
+ "endedAt": payload.get("endedAt") or _local_now_iso(),
675
+ }
676
+ if "returncode" in payload:
677
+ updated_runtime["returncode"] = payload["returncode"]
678
+ store["write_session_state"](name, {
679
+ **state,
680
+ "status": "ready",
681
+ "runtime": updated_runtime,
682
+ })
683
+ return updated_runtime
684
+
588
685
  def ensure_session_state(name):
589
686
  session = store["get_session"](name)
590
687
  if not session:
@@ -669,6 +766,25 @@ def create_session_service(options=None):
669
766
  raise CdxError(f"Unknown session: {name}")
670
767
  return updated
671
768
 
769
+ def record_launch_history(name, payload):
770
+ session = store["get_session"](name)
771
+ if not session:
772
+ raise CdxError(f"Unknown session: {name}")
773
+ entry = {
774
+ "schema_version": 1,
775
+ "session_name": session["name"],
776
+ "provider": session["provider"],
777
+ "launch": session.get("launch") or {},
778
+ **payload,
779
+ }
780
+ store["append_launch_history"](entry)
781
+ return entry
782
+
783
+ def get_launch_history(name=None, limit=20):
784
+ if name and not store["get_session"](name):
785
+ raise CdxError(f"Unknown session: {name}")
786
+ return store["list_launch_history"](session_name=name, limit=limit)
787
+
672
788
  def _resolve_session_status(session, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS):
673
789
  current_status = session.get("lastStatus")
674
790
  if session.get("enabled", True) is False:
@@ -806,6 +922,7 @@ def create_session_service(options=None):
806
922
  "session_name": s["name"],
807
923
  "provider": s["provider"],
808
924
  "enabled": enabled,
925
+ "active": bool(_session_runtime(s["name"])) if enabled else False,
809
926
  "status": "enabled" if enabled else "disabled",
810
927
  "remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
811
928
  "remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
@@ -839,6 +956,7 @@ def create_session_service(options=None):
839
956
  "name": s["name"],
840
957
  "provider": s["provider"] if has_multiple else None,
841
958
  "enabled": s.get("enabled", True) is not False,
959
+ "active": bool(_session_runtime(s["name"])) if s.get("enabled", True) is not False else False,
842
960
  "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
843
961
  "status": s.get("lastStatus"),
844
962
  "launch": s.get("launch") or {},
@@ -992,6 +1110,8 @@ def create_session_service(options=None):
992
1110
  "copy_session": copy_session,
993
1111
  "rename_session": rename_session,
994
1112
  "launch_session": launch_session,
1113
+ "start_session_runtime": start_session_runtime,
1114
+ "finish_session_runtime": finish_session_runtime,
995
1115
  "ensure_session_state": ensure_session_state,
996
1116
  "list_sessions": list_sessions,
997
1117
  "get_session": get_session,
@@ -999,6 +1119,8 @@ def create_session_service(options=None):
999
1119
  "set_launch_settings": set_launch_settings,
1000
1120
  "unset_launch_settings": unset_launch_settings,
1001
1121
  "record_status": record_status,
1122
+ "record_launch_history": record_launch_history,
1123
+ "get_launch_history": get_launch_history,
1002
1124
  "update_auth_state": update_auth_state,
1003
1125
  "get_status_rows": get_status_rows,
1004
1126
  "format_list_rows": format_list_rows,
@@ -105,6 +105,7 @@ def create_session_store(base_dir):
105
105
  store_file = os.path.join(base_dir, "sessions.json")
106
106
  lock_file = os.path.join(base_dir, ".sessions.lock")
107
107
  state_dir = os.path.join(base_dir, "state")
108
+ launch_history_file = os.path.join(state_dir, "launch_history.jsonl")
108
109
 
109
110
  def _state_file_path(name):
110
111
  return os.path.join(state_dir, f"{_encode(name)}.json")
@@ -259,6 +260,39 @@ def create_session_store(base_dir):
259
260
  with _file_lock(lock_file):
260
261
  _write_session_state_unlocked(name, state)
261
262
 
263
+ def append_launch_history(entry):
264
+ with _file_lock(lock_file):
265
+ _ensure_dir(state_dir)
266
+ with open(launch_history_file, "a", encoding="utf-8") as handle:
267
+ handle.write(json.dumps(entry, separators=(",", ":")))
268
+ handle.write("\n")
269
+ handle.flush()
270
+ os.fsync(handle.fileno())
271
+ _fsync_directory(state_dir)
272
+
273
+ def list_launch_history(session_name=None, limit=20):
274
+ with _file_lock(lock_file):
275
+ try:
276
+ with open(launch_history_file, "r", encoding="utf-8") as handle:
277
+ lines = handle.readlines()
278
+ except FileNotFoundError:
279
+ return []
280
+ entries = []
281
+ for line in reversed(lines):
282
+ line = line.strip()
283
+ if not line:
284
+ continue
285
+ try:
286
+ entry = json.loads(line)
287
+ except json.JSONDecodeError as error:
288
+ raise CdxError(f"Corrupt JSONL file: {launch_history_file}") from error
289
+ if session_name and entry.get("session_name") != session_name:
290
+ continue
291
+ entries.append(entry)
292
+ if limit and len(entries) >= limit:
293
+ break
294
+ return entries
295
+
262
296
  return {
263
297
  "list_sessions": list_sessions,
264
298
  "get_session": get_session,
@@ -269,4 +303,6 @@ def create_session_store(base_dir):
269
303
  "replace_session": replace_session,
270
304
  "read_session_state": read_session_state,
271
305
  "write_session_state": write_session_state,
306
+ "append_launch_history": append_launch_history,
307
+ "list_launch_history": list_launch_history,
272
308
  }
@@ -11,6 +11,7 @@ from .cli_render import (
11
11
 
12
12
  RESET_COUNTDOWN_SAFETY_SECONDS = 60
13
13
  PRIORITY_EMPTY_AVAILABLE_THRESHOLD = 5
14
+ BLOCKING_QUOTA_WARNING_THRESHOLD = 10
14
15
 
15
16
 
16
17
  def _format_reset_time(value):
@@ -42,7 +43,11 @@ def _format_reset_time(value):
42
43
  if remaining_minutes == 0:
43
44
  return f"in {hours}h"
44
45
  return f"in {hours}h {remaining_minutes}m"
45
- return value
46
+ days = int(delta_s // (24 * 60 * 60))
47
+ hours = int((delta_s % (24 * 60 * 60)) // (60 * 60))
48
+ if hours == 0:
49
+ return f"in {days}d"
50
+ return f"in {days}d {hours}h"
46
51
 
47
52
 
48
53
  def _style_reset_time(value, use_color=False):
@@ -77,7 +82,7 @@ def _format_status_rows(rows, use_color=False, small=False):
77
82
  priority = _recommend_priority_sessions(active_rows)
78
83
  table_rows = []
79
84
  for r in priority + disabled_rows:
80
- base = [r["session_name"]]
85
+ base = [_format_session_name(r)]
81
86
  if has_provider:
82
87
  base.append(r.get("provider") or "n/a")
83
88
  status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
@@ -133,6 +138,10 @@ def _format_current_session_line(rows):
133
138
  return f"Current: last launched {row['session_name']} ({label})."
134
139
 
135
140
 
141
+ def _format_session_name(row):
142
+ return f"{row['session_name']}*" if row.get("active") else row["session_name"]
143
+
144
+
136
145
  def _recommend_priority_sessions(rows):
137
146
  if not rows:
138
147
  return []
@@ -166,6 +175,9 @@ def _format_blocking_quota(row):
166
175
  remaining_week = row.get("remaining_week_pct")
167
176
  if remaining_5h is None and remaining_week is None:
168
177
  return "?"
178
+ known = [value for value in (remaining_5h, remaining_week) if value is not None]
179
+ if known and min(known) > BLOCKING_QUOTA_WARNING_THRESHOLD:
180
+ return "-"
169
181
  if remaining_5h is None:
170
182
  return "WEEK"
171
183
  if remaining_week is None:
@@ -284,7 +296,7 @@ def _now_timestamp():
284
296
 
285
297
  def _format_status_detail(row, use_color=False):
286
298
  lines = [
287
- f"{_style('Session:', '1', use_color)} {row['session_name']}",
299
+ f"{_style('Session:', '1', use_color)} {_format_session_name(row)}",
288
300
  f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
289
301
  f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
290
302
  f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
@@ -9,6 +9,10 @@ UPDATE_CHECK_TTL_SECONDS = 12 * 60 * 60
9
9
  LATEST_RELEASE_URL = "https://api.github.com/repos/AlexAgo83/cdx-manager/releases/latest"
10
10
 
11
11
 
12
+ class LatestReleaseCheckError(Exception):
13
+ pass
14
+
15
+
12
16
  def _parse_version(value):
13
17
  raw = str(value or "").strip().lstrip("v")
14
18
  parts = raw.split(".")
@@ -51,13 +55,22 @@ def _write_cache(path, payload):
51
55
  handle.write("\n")
52
56
 
53
57
 
54
- def _fetch_latest_release():
58
+ def _github_token(env=None):
59
+ env = env or os.environ
60
+ return env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")
61
+
62
+
63
+ def _fetch_latest_release(env=None):
64
+ headers = {
65
+ "Accept": "application/vnd.github+json",
66
+ "User-Agent": "cdx-manager-update-check",
67
+ }
68
+ token = _github_token(env)
69
+ if token:
70
+ headers["Authorization"] = f"Bearer {token}"
55
71
  request = urllib.request.Request(
56
72
  LATEST_RELEASE_URL,
57
- headers={
58
- "Accept": "application/vnd.github+json",
59
- "User-Agent": "cdx-manager-update-check",
60
- },
73
+ headers=headers,
61
74
  )
62
75
  with urllib.request.urlopen(request, timeout=5) as response:
63
76
  payload = json.loads(response.read().decode("utf-8"))
@@ -67,13 +80,39 @@ def _fetch_latest_release():
67
80
  }
68
81
 
69
82
 
70
- def fetch_latest_release():
83
+ def _format_fetch_error(error):
84
+ if isinstance(error, urllib.error.HTTPError):
85
+ if error.code == 403:
86
+ return (
87
+ "GitHub API rate limit reached while checking the latest cdx-manager release. "
88
+ "Try again later, set GH_TOKEN or GITHUB_TOKEN, or run cdx update --version <version>."
89
+ )
90
+ if error.code == 404:
91
+ return "Unable to find the latest cdx-manager release on GitHub."
92
+ return f"GitHub returned HTTP {error.code} while checking the latest cdx-manager release."
93
+ if isinstance(error, urllib.error.URLError):
94
+ reason = getattr(error, "reason", None)
95
+ suffix = f" ({reason})" if reason else ""
96
+ return f"Unable to reach GitHub while checking the latest cdx-manager release{suffix}."
97
+ if isinstance(error, TimeoutError):
98
+ return "Timed out while checking the latest cdx-manager release."
99
+ return "Unable to check for the latest cdx-manager release."
100
+
101
+
102
+ def fetch_latest_release(env=None):
71
103
  try:
72
- return _fetch_latest_release()
104
+ return _fetch_latest_release(env=env)
73
105
  except (urllib.error.URLError, TimeoutError, ValueError, OSError):
74
106
  return None
75
107
 
76
108
 
109
+ def fetch_latest_release_or_raise(env=None):
110
+ try:
111
+ return _fetch_latest_release(env=env)
112
+ except (urllib.error.URLError, TimeoutError, ValueError, OSError) as error:
113
+ raise LatestReleaseCheckError(_format_fetch_error(error)) from error
114
+
115
+
77
116
  def check_for_update(base_dir, current_version, env=None, now_fn=None):
78
117
  env = env or os.environ
79
118
  now_fn = now_fn or (lambda: datetime.now(timezone.utc).timestamp())
@@ -94,7 +133,7 @@ def check_for_update(base_dir, current_version, env=None, now_fn=None):
94
133
  }
95
134
  return None
96
135
 
97
- latest = fetch_latest_release()
136
+ latest = fetch_latest_release(env=env)
98
137
  if not latest:
99
138
  return None
100
139