codex-manager 7.0.0__tar.gz → 9.0.0__tar.gz
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.
- {codex_manager-7.0.0 → codex_manager-9.0.0}/PKG-INFO +1 -1
- {codex_manager-7.0.0 → codex_manager-9.0.0}/pyproject.toml +1 -1
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/__init__.py +1 -1
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/account_status.py +13 -8
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/cli.py +6 -4
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/cooldown.py +38 -11
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/status.py +12 -5
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/PKG-INFO +1 -1
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_account_status_coverage.py +22 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_more.py +26 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cooldown.py +19 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_status2.py +20 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/README.md +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/setup.cfg +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/args.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/backup.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/cloud.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/config.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/credentials.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/doctor.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/list_backups.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/profile.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/prune.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/prune_backups.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/purge.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/recommend.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/registry.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/remove.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/restore.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/sync.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/ui.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/use_account.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager/utils.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/SOURCES.txt +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/dependency_links.txt +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/entry_points.txt +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/requires.txt +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/src/codex_manager.egg-info/top_level.txt +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_account_status_more2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_args_cli.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_args_cli_3.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_args_cli_extra.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_backup.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_backup2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_backup_ux.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_3.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_4.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_5.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_6.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_7.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_8.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cli_coverage_extra.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cloud.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_config.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_cooldown2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_credentials2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_doctor.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_doctor2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_dry_run_coverage.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_list_backups.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_list_backups2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_list_backups3.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_list_backups_coverage.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_next_gen_upgrade.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_profile.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_prune.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_prune_backups.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_prune_backups2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_purge.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_recommend.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_recommend2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_registry_coverage_more.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_registry_dry_run_coverage.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_remove.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_restore.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_restore2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_status.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_status3.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_sync.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_sync2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_ui.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_ui_2.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_use.py +0 -0
- {codex_manager-7.0.0 → codex_manager-9.0.0}/tests/test_use2.py +0 -0
|
@@ -30,6 +30,10 @@ def patch_metadata(
|
|
|
30
30
|
# We will compute the final reset_at and session_start_at to save to registry
|
|
31
31
|
final_reset_at = reset_at
|
|
32
32
|
final_session_start_at = session_start_at
|
|
33
|
+
if is_expired and final_reset_at is None:
|
|
34
|
+
final_reset_at = datetime.now().astimezone()
|
|
35
|
+
if is_expired and final_session_start_at is None and final_reset_at is not None:
|
|
36
|
+
final_session_start_at = final_reset_at - timedelta(days=7)
|
|
33
37
|
|
|
34
38
|
if backup_dir.exists():
|
|
35
39
|
# Find any metadata file containing this email
|
|
@@ -151,15 +155,16 @@ def patch_metadata(
|
|
|
151
155
|
try:
|
|
152
156
|
cp.download_file(metadata_name, local_metadata)
|
|
153
157
|
data = json.loads(local_metadata.read_text(encoding="utf-8"))
|
|
154
|
-
|
|
155
|
-
reset_at
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
if final_reset_at is not None:
|
|
159
|
+
data["reset_at"] = (
|
|
160
|
+
final_reset_at.isoformat() if hasattr(final_reset_at, "isoformat") else str(final_reset_at)
|
|
161
|
+
)
|
|
162
|
+
data["next_available_at"] = data["reset_at"]
|
|
163
|
+
if final_session_start_at:
|
|
159
164
|
data["session_start_at"] = (
|
|
160
|
-
|
|
161
|
-
if hasattr(
|
|
162
|
-
else str(
|
|
165
|
+
final_session_start_at.isoformat()
|
|
166
|
+
if hasattr(final_session_start_at, "isoformat")
|
|
167
|
+
else str(final_session_start_at)
|
|
163
168
|
)
|
|
164
169
|
data["quota_text"] = quota_text
|
|
165
170
|
if quota_percent_left is not None:
|
|
@@ -47,12 +47,11 @@ def list_entries_from_args(args: Any) -> list[BackupEntry]:
|
|
|
47
47
|
)
|
|
48
48
|
)
|
|
49
49
|
|
|
50
|
-
if getattr(args, "cloud", False)
|
|
50
|
+
if getattr(args, "cloud", False):
|
|
51
51
|
cp = get_cloud_provider(args)
|
|
52
52
|
if cp:
|
|
53
53
|
# Sync registry (cooldown.json) when cloud is enabled
|
|
54
|
-
|
|
55
|
-
sync_registry_with_cloud(cp)
|
|
54
|
+
sync_registry_with_cloud(cp)
|
|
56
55
|
|
|
57
56
|
all_entries.extend(
|
|
58
57
|
list_cloud_backups(
|
|
@@ -72,7 +71,8 @@ def list_entries_from_args(args: Any) -> list[BackupEntry]:
|
|
|
72
71
|
sys.exit(1)
|
|
73
72
|
|
|
74
73
|
if latest_per_email:
|
|
75
|
-
|
|
74
|
+
# Sort by reset_at descending so the newest state is picked
|
|
75
|
+
all_entries.sort(key=lambda entry: entry.reset_at, reverse=True)
|
|
76
76
|
seen_emails: dict[str, BackupEntry] = {}
|
|
77
77
|
for entry in all_entries:
|
|
78
78
|
if entry.email not in seen_emails:
|
|
@@ -166,6 +166,8 @@ def build_live_status(args: Any) -> CooldownStatus | None:
|
|
|
166
166
|
validation_status="live",
|
|
167
167
|
proposed_archive_name=live_status.proposed_archive_name,
|
|
168
168
|
remaining_seconds=max(0, remaining_seconds),
|
|
169
|
+
quota_text=live_status.quota_text,
|
|
170
|
+
quota_percent_left=live_status.quota_percent_left,
|
|
169
171
|
is_expired=live_status.is_expired,
|
|
170
172
|
)
|
|
171
173
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
5
|
|
|
6
6
|
from .list_backups import BackupEntry
|
|
7
7
|
|
|
@@ -16,6 +16,8 @@ class CooldownStatus:
|
|
|
16
16
|
validation_status: str
|
|
17
17
|
proposed_archive_name: str
|
|
18
18
|
remaining_seconds: int
|
|
19
|
+
quota_text: str | None = None
|
|
20
|
+
quota_percent_left: int | None = None
|
|
19
21
|
is_expired: bool = False
|
|
20
22
|
|
|
21
23
|
|
|
@@ -34,9 +36,6 @@ def evaluate_entry(entry: BackupEntry, now: datetime | None = None) -> CooldownS
|
|
|
34
36
|
remaining_seconds = int((next_available_at - current).total_seconds())
|
|
35
37
|
status = "ready" if remaining_seconds <= 0 else "cooldown"
|
|
36
38
|
|
|
37
|
-
# metadata for entry might have is_expired
|
|
38
|
-
# we need to load it if not already there, but BackupEntry doesn't have it.
|
|
39
|
-
# However, list_backups.py build_backup_entry could be updated to include it.
|
|
40
39
|
is_expired = getattr(entry, "is_expired", False)
|
|
41
40
|
|
|
42
41
|
return CooldownStatus(
|
|
@@ -48,6 +47,8 @@ def evaluate_entry(entry: BackupEntry, now: datetime | None = None) -> CooldownS
|
|
|
48
47
|
validation_status="backup",
|
|
49
48
|
proposed_archive_name=entry.archive_path.name,
|
|
50
49
|
remaining_seconds=max(0, remaining_seconds),
|
|
50
|
+
quota_text=getattr(entry, "quota_text", None),
|
|
51
|
+
quota_percent_left=getattr(entry, "quota_percent_left", None),
|
|
51
52
|
is_expired=is_expired,
|
|
52
53
|
)
|
|
53
54
|
|
|
@@ -66,10 +67,12 @@ def evaluate_records(
|
|
|
66
67
|
current = now.astimezone() if now is not None else datetime.now().astimezone()
|
|
67
68
|
|
|
68
69
|
for email, reg_entry in registry_data.items():
|
|
69
|
-
if "
|
|
70
|
+
if "updated_at" not in reg_entry:
|
|
70
71
|
continue
|
|
71
72
|
|
|
72
73
|
reg_updated_at = parse_iso_datetime(reg_entry["updated_at"])
|
|
74
|
+
reg_reset_at = reg_entry.get("reset_at")
|
|
75
|
+
reg_is_expired = reg_entry.get("is_expired", False)
|
|
73
76
|
|
|
74
77
|
# Check if we already have a status for this email from backups
|
|
75
78
|
existing_idx = next((i for i, s in enumerate(statuses) if s.email == email), None)
|
|
@@ -78,8 +81,14 @@ def evaluate_records(
|
|
|
78
81
|
existing_status = statuses[existing_idx]
|
|
79
82
|
# If registry is newer, update the status
|
|
80
83
|
if reg_updated_at > existing_status.quota_end_detected_at:
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
if reg_reset_at is not None:
|
|
85
|
+
next_available_at = parse_iso_datetime(reg_reset_at)
|
|
86
|
+
session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_reset_at))
|
|
87
|
+
elif reg_is_expired:
|
|
88
|
+
next_available_at = existing_status.next_available_at
|
|
89
|
+
session_start_at = existing_status.session_start_at
|
|
90
|
+
else:
|
|
91
|
+
continue
|
|
83
92
|
remaining_seconds = int((next_available_at - current).total_seconds())
|
|
84
93
|
statuses[existing_idx] = CooldownStatus(
|
|
85
94
|
email=email,
|
|
@@ -90,12 +99,20 @@ def evaluate_records(
|
|
|
90
99
|
validation_status="registry",
|
|
91
100
|
proposed_archive_name=existing_status.proposed_archive_name,
|
|
92
101
|
remaining_seconds=max(0, remaining_seconds),
|
|
93
|
-
|
|
102
|
+
quota_text=reg_entry.get("quota_text"),
|
|
103
|
+
quota_percent_left=reg_entry.get("quota_percent_left"),
|
|
104
|
+
is_expired=reg_is_expired
|
|
94
105
|
)
|
|
95
106
|
else:
|
|
96
107
|
# Create a new status from registry
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
if reg_reset_at is not None:
|
|
109
|
+
next_available_at = parse_iso_datetime(reg_reset_at)
|
|
110
|
+
session_start_at = parse_iso_datetime(reg_entry.get("session_start_at", reg_reset_at))
|
|
111
|
+
elif reg_is_expired:
|
|
112
|
+
next_available_at = reg_updated_at
|
|
113
|
+
session_start_at = reg_updated_at - timedelta(days=7)
|
|
114
|
+
else:
|
|
115
|
+
continue
|
|
99
116
|
remaining_seconds = int((next_available_at - current).total_seconds())
|
|
100
117
|
statuses.append(
|
|
101
118
|
CooldownStatus(
|
|
@@ -107,7 +124,9 @@ def evaluate_records(
|
|
|
107
124
|
validation_status="registry",
|
|
108
125
|
proposed_archive_name="none",
|
|
109
126
|
remaining_seconds=max(0, remaining_seconds),
|
|
110
|
-
|
|
127
|
+
quota_text=reg_entry.get("quota_text"),
|
|
128
|
+
quota_percent_left=reg_entry.get("quota_percent_left"),
|
|
129
|
+
is_expired=reg_is_expired
|
|
111
130
|
)
|
|
112
131
|
)
|
|
113
132
|
|
|
@@ -145,6 +164,7 @@ def print_statuses_table(statuses: list[CooldownStatus], live_email: str | None
|
|
|
145
164
|
table = Table(show_header=True, header_style="bold bright_magenta")
|
|
146
165
|
table.add_column("Account", style="bright_cyan")
|
|
147
166
|
table.add_column("Status", justify="center", no_wrap=True)
|
|
167
|
+
table.add_column("Quota", justify="right", style="bright_yellow")
|
|
148
168
|
table.add_column("Available", justify="right", style="bright_yellow")
|
|
149
169
|
table.add_column("Session Start", justify="right", style="dim")
|
|
150
170
|
table.add_column("Reset At", justify="right", style="dim")
|
|
@@ -161,9 +181,16 @@ def print_statuses_table(statuses: list[CooldownStatus], live_email: str | None
|
|
|
161
181
|
else:
|
|
162
182
|
status_display = f"[bold bright_green]{status.status.upper()}[/]" if status.status == "ready" else f"[bold bright_yellow]{status.status.upper()}[/]"
|
|
163
183
|
|
|
184
|
+
quota_display = (
|
|
185
|
+
f"{status.quota_percent_left}%"
|
|
186
|
+
if status.quota_percent_left is not None
|
|
187
|
+
else "unknown"
|
|
188
|
+
)
|
|
189
|
+
|
|
164
190
|
table.add_row(
|
|
165
191
|
account_display,
|
|
166
192
|
status_display,
|
|
193
|
+
quota_display,
|
|
167
194
|
format_remaining(status.remaining_seconds),
|
|
168
195
|
status.session_start_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
169
196
|
status.next_available_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
@@ -109,11 +109,14 @@ def capture_tmux_status_text(
|
|
|
109
109
|
if has_session:
|
|
110
110
|
run_command(["tmux", "kill-session", "-t", session_name], check=False)
|
|
111
111
|
|
|
112
|
-
run_command(
|
|
112
|
+
session = run_command(
|
|
113
113
|
[
|
|
114
114
|
"tmux",
|
|
115
115
|
"new-session",
|
|
116
116
|
"-d",
|
|
117
|
+
"-P",
|
|
118
|
+
"-F",
|
|
119
|
+
"#{pane_id}",
|
|
117
120
|
"-s",
|
|
118
121
|
session_name,
|
|
119
122
|
"-x",
|
|
@@ -123,12 +126,16 @@ def capture_tmux_status_text(
|
|
|
123
126
|
codex_command,
|
|
124
127
|
]
|
|
125
128
|
)
|
|
129
|
+
pane_id = session.stdout.strip()
|
|
130
|
+
if not pane_id:
|
|
131
|
+
raise RuntimeError("tmux did not return a pane id for the temporary capture session.")
|
|
132
|
+
run_command(["tmux", "set-option", "-t", session_name, "remain-on-exit", "on"])
|
|
126
133
|
|
|
127
134
|
try:
|
|
128
135
|
start = time.time()
|
|
129
136
|
with console.status("[cyan]Waiting for Codex prompt...[/cyan]", spinner="dots"):
|
|
130
137
|
while True:
|
|
131
|
-
output = run_command(["tmux", "capture-pane", "-t",
|
|
138
|
+
output = run_command(["tmux", "capture-pane", "-t", pane_id, "-p"]).stdout
|
|
132
139
|
if "›" in output:
|
|
133
140
|
break
|
|
134
141
|
|
|
@@ -136,13 +143,13 @@ def capture_tmux_status_text(
|
|
|
136
143
|
raise RuntimeError("Timed out waiting for Codex prompt.")
|
|
137
144
|
time.sleep(0.5)
|
|
138
145
|
|
|
139
|
-
run_command(["tmux", "send-keys", "-t",
|
|
146
|
+
run_command(["tmux", "send-keys", "-t", pane_id, "/status", "Enter"])
|
|
140
147
|
|
|
141
148
|
start = time.time()
|
|
142
149
|
last_retry = start
|
|
143
150
|
with console.status("[cyan]Checking Codex status...[/cyan]", spinner="dots"):
|
|
144
151
|
while True:
|
|
145
|
-
output = run_command(["tmux", "capture-pane", "-t",
|
|
152
|
+
output = run_command(["tmux", "capture-pane", "-t", pane_id, "-p"]).stdout
|
|
146
153
|
|
|
147
154
|
# If we have the full panel, return it
|
|
148
155
|
if "Account:" in output and "Weekly limit:" in output:
|
|
@@ -163,7 +170,7 @@ def capture_tmux_status_text(
|
|
|
163
170
|
|
|
164
171
|
# Periodic retry of /status if it seems stuck or refreshing
|
|
165
172
|
if time.time() - last_retry > 5.0:
|
|
166
|
-
run_command(["tmux", "send-keys", "-t",
|
|
173
|
+
run_command(["tmux", "send-keys", "-t", pane_id, "/status", "Enter"])
|
|
167
174
|
last_retry = time.time()
|
|
168
175
|
|
|
169
176
|
time.sleep(0.5)
|
|
@@ -55,6 +55,28 @@ def test_patch_metadata_fallback_datetime_parsing(mocker, tmp_path) -> None:
|
|
|
55
55
|
mock_update.assert_called_once()
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def test_patch_metadata_expired_without_existing_metadata_sets_registry_times(mocker, tmp_path) -> None:
|
|
59
|
+
class Args:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
args = Args()
|
|
63
|
+
args.backup_dir = str(tmp_path / "missing-backups")
|
|
64
|
+
|
|
65
|
+
mock_update = mocker.patch("codex_manager.account_status.update_registry_entry")
|
|
66
|
+
|
|
67
|
+
patch_metadata(
|
|
68
|
+
"expired@example.com",
|
|
69
|
+
quota_text="TOKEN EXPIRED: Re-login required.",
|
|
70
|
+
args=args,
|
|
71
|
+
is_expired=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
call = mock_update.call_args.kwargs
|
|
75
|
+
assert call["is_expired"] is True
|
|
76
|
+
assert call["reset_at"] is not None
|
|
77
|
+
assert call["session_start_at"] is not None
|
|
78
|
+
|
|
79
|
+
|
|
58
80
|
|
|
59
81
|
def test_sync_current_account_status_no_email(tmp_path, capsys):
|
|
60
82
|
class Args:
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
+
from unittest.mock import MagicMock
|
|
4
5
|
|
|
5
6
|
from codex_manager.cli import (
|
|
6
7
|
_ensure_cloud_archive,
|
|
7
8
|
_read_status_command_input,
|
|
8
9
|
handle_status,
|
|
10
|
+
handle_recommend,
|
|
9
11
|
list_entries_from_args,
|
|
10
12
|
)
|
|
11
13
|
from codex_manager.status import TokenExpiredError
|
|
@@ -67,3 +69,27 @@ def test_handle_status_token_expired(mocker, capsys):
|
|
|
67
69
|
|
|
68
70
|
with pytest.raises(SystemExit):
|
|
69
71
|
handle_status(args)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_handle_recommend_without_cloud_does_not_fetch_cloud(mocker, tmp_path):
|
|
75
|
+
class Args:
|
|
76
|
+
command = "recommend"
|
|
77
|
+
backup_dir = str(tmp_path)
|
|
78
|
+
cloud = False
|
|
79
|
+
live = False
|
|
80
|
+
|
|
81
|
+
args = Args()
|
|
82
|
+
mocker.patch("codex_manager.cli.list_backups", return_value=[])
|
|
83
|
+
mock_cloud = mocker.patch("codex_manager.cli.list_cloud_backups")
|
|
84
|
+
recommendation = MagicMock()
|
|
85
|
+
recommendation.selected.email = "test@example.com"
|
|
86
|
+
recommendation.selected.status = "ready"
|
|
87
|
+
recommendation.selected.remaining_seconds = 0
|
|
88
|
+
recommendation.selected.next_available_at.strftime.return_value = "2026-04-29 00:00:00 +0000"
|
|
89
|
+
recommendation.selected.validation_status = "backup"
|
|
90
|
+
recommendation.reason = "ready now"
|
|
91
|
+
mocker.patch("codex_manager.cli.choose_best_account", return_value=recommendation)
|
|
92
|
+
|
|
93
|
+
handle_recommend(args)
|
|
94
|
+
|
|
95
|
+
mock_cloud.assert_not_called()
|
|
@@ -83,6 +83,25 @@ def test_evaluate_records_merges_live_status(mock_reg) -> None:
|
|
|
83
83
|
assert statuses[0].next_available_at.day == 20
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
@patch(
|
|
87
|
+
"codex_manager.registry.load_registry",
|
|
88
|
+
return_value={
|
|
89
|
+
"expired@example.com": {
|
|
90
|
+
"updated_at": "2026-04-21T16:00:00+00:00",
|
|
91
|
+
"is_expired": True,
|
|
92
|
+
"quota_text": "TOKEN EXPIRED: Re-login required.",
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
def test_evaluate_records_includes_expired_registry_entries_without_reset_at(mock_reg) -> None:
|
|
97
|
+
statuses = evaluate_records([], now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc))
|
|
98
|
+
assert len(statuses) == 1
|
|
99
|
+
assert statuses[0].email == "expired@example.com"
|
|
100
|
+
assert statuses[0].validation_status == "registry"
|
|
101
|
+
assert statuses[0].is_expired is True
|
|
102
|
+
assert statuses[0].status == "ready"
|
|
103
|
+
|
|
104
|
+
|
|
86
105
|
def test_format_remaining() -> None:
|
|
87
106
|
assert format_remaining(0) == "now"
|
|
88
107
|
assert format_remaining(5400) == "1h 30m"
|
|
@@ -56,6 +56,8 @@ def test_capture_tmux_status_text_timeout(mock_sleep, mock_run):
|
|
|
56
56
|
@patch("codex_manager.status.time.sleep")
|
|
57
57
|
def test_capture_tmux_status_text_success(mock_sleep, mock_run):
|
|
58
58
|
def mock_run_side_effect(args, **kwargs):
|
|
59
|
+
if args[:3] == ["tmux", "new-session", "-d"]:
|
|
60
|
+
return MagicMock(stdout="%42")
|
|
59
61
|
if "capture-pane" in args:
|
|
60
62
|
# We need to simulate the prompt '›' appearing, then the status appearing.
|
|
61
63
|
# Let's count how many times capture-pane is called
|
|
@@ -72,11 +74,16 @@ def test_capture_tmux_status_text_success(mock_sleep, mock_run):
|
|
|
72
74
|
mock_run.side_effect = mock_run_side_effect
|
|
73
75
|
res = capture_tmux_status_text(startup_timeout_seconds=1.0, status_timeout_seconds=1.0)
|
|
74
76
|
assert "Account: a@b.com" in res
|
|
77
|
+
capture_targets = [call.args[0] for call in mock_run.call_args_list if "capture-pane" in call.args[0]]
|
|
78
|
+
assert capture_targets
|
|
79
|
+
assert all("%42" in args for args in capture_targets)
|
|
75
80
|
|
|
76
81
|
@patch("codex_manager.status.run_command")
|
|
77
82
|
@patch("codex_manager.status.time.sleep")
|
|
78
83
|
def test_capture_tmux_status_text_retry_and_timeout(mock_sleep, mock_run):
|
|
79
84
|
def mock_run_side_effect(args, **kwargs):
|
|
85
|
+
if args[:3] == ["tmux", "new-session", "-d"]:
|
|
86
|
+
return MagicMock(stdout="%42")
|
|
80
87
|
if "capture-pane" in args:
|
|
81
88
|
# First phase: '›'
|
|
82
89
|
# Second phase: status timeout
|
|
@@ -98,6 +105,19 @@ def test_capture_tmux_status_text_retry_and_timeout(mock_sleep, mock_run):
|
|
|
98
105
|
with pytest.raises(RuntimeError):
|
|
99
106
|
capture_tmux_status_text(startup_timeout_seconds=10.0, status_timeout_seconds=5.0)
|
|
100
107
|
|
|
108
|
+
|
|
109
|
+
@patch("codex_manager.status.run_command")
|
|
110
|
+
@patch("codex_manager.status.time.sleep")
|
|
111
|
+
def test_capture_tmux_status_text_requires_pane_id(mock_sleep, mock_run):
|
|
112
|
+
def mock_run_side_effect(args, **kwargs):
|
|
113
|
+
if args[:3] == ["tmux", "new-session", "-d"]:
|
|
114
|
+
return MagicMock(stdout="")
|
|
115
|
+
return MagicMock()
|
|
116
|
+
|
|
117
|
+
mock_run.side_effect = mock_run_side_effect
|
|
118
|
+
with pytest.raises(RuntimeError, match="pane id"):
|
|
119
|
+
capture_tmux_status_text(startup_timeout_seconds=1.0, status_timeout_seconds=1.0)
|
|
120
|
+
|
|
101
121
|
@patch("codex_manager.status.subprocess.run")
|
|
102
122
|
def test_run_command_fail(mock_run):
|
|
103
123
|
mock_run.return_value = MagicMock(returncode=1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|