codex-manager 1.0.1__tar.gz → 2.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-1.0.1 → codex_manager-2.0.0}/PKG-INFO +1 -1
- {codex_manager-1.0.1 → codex_manager-2.0.0}/pyproject.toml +1 -1
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/__init__.py +1 -1
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/args.py +46 -79
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/backup.py +1 -2
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/cli.py +28 -57
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/config.py +0 -29
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/cooldown.py +12 -12
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/list_backups.py +0 -1
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/recommend.py +4 -4
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/restore.py +0 -1
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/status.py +1 -1
- codex_manager-2.0.0/src/codex_manager/utils.py +11 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/PKG-INFO +1 -1
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/SOURCES.txt +1 -4
- codex_manager-2.0.0/tests/test_cooldown.py +78 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_recommend.py +25 -24
- codex_manager-1.0.1/src/codex_manager/inventory.py +0 -32
- codex_manager-1.0.1/src/codex_manager/normalize.py +0 -119
- codex_manager-1.0.1/tests/test_cooldown.py +0 -85
- codex_manager-1.0.1/tests/test_inventory.py +0 -30
- codex_manager-1.0.1/tests/test_normalize.py +0 -92
- {codex_manager-1.0.1 → codex_manager-2.0.0}/CODEX_MANAGER_SPEC.md +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/README.md +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/setup.cfg +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/doctor.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/profile.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/prune.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/prune_backups.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/sync.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/use_account.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/dependency_links.txt +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/entry_points.txt +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/requires.txt +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/top_level.txt +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_backup.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_config.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_doctor.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_list_backups.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_profile.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_prune.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_prune_backups.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_restore.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_status.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_sync.py +0 -0
- {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_use.py +0 -0
|
@@ -4,11 +4,7 @@ import argparse
|
|
|
4
4
|
from .config import (
|
|
5
5
|
DEFAULT_BACKUP_DIR,
|
|
6
6
|
DEFAULT_COOLDOWN_DISPLAY_LIMIT,
|
|
7
|
-
DEFAULT_INVENTORY_PATH,
|
|
8
7
|
DEFAULT_CODEX_HOME,
|
|
9
|
-
DEFAULT_REFERENCE_YEAR,
|
|
10
|
-
DEFAULT_SAMPLE_HOME,
|
|
11
|
-
DEFAULT_SESSION_DURATION_HOURS,
|
|
12
8
|
load_config,
|
|
13
9
|
)
|
|
14
10
|
|
|
@@ -22,69 +18,14 @@ def get_parser() -> argparse.ArgumentParser:
|
|
|
22
18
|
parser = argparse.ArgumentParser(prog="codex-manager")
|
|
23
19
|
subparsers = parser.add_subparsers(dest="command")
|
|
24
20
|
|
|
25
|
-
normalize_parser = subparsers.add_parser(
|
|
26
|
-
"normalize",
|
|
27
|
-
help="Normalize legacy Codex auth snapshot names into a machine-readable inventory.",
|
|
28
|
-
)
|
|
29
|
-
normalize_parser.add_argument(
|
|
30
|
-
"--source-dir",
|
|
31
|
-
default=str(DEFAULT_SAMPLE_HOME),
|
|
32
|
-
help="Directory containing legacy *_auth.json files.",
|
|
33
|
-
)
|
|
34
|
-
normalize_parser.add_argument(
|
|
35
|
-
"--session-duration-hours",
|
|
36
|
-
type=float,
|
|
37
|
-
default=DEFAULT_SESSION_DURATION_HOURS,
|
|
38
|
-
help="Hours to subtract from auth file mtime to infer session start.",
|
|
39
|
-
)
|
|
40
|
-
normalize_parser.add_argument(
|
|
41
|
-
"--reference-year",
|
|
42
|
-
type=int,
|
|
43
|
-
default=DEFAULT_REFERENCE_YEAR,
|
|
44
|
-
help="Year used when expanding legacy day-month tokens such as 21apr.",
|
|
45
|
-
)
|
|
46
|
-
normalize_parser.add_argument(
|
|
47
|
-
"--write-inventory",
|
|
48
|
-
action="store_true",
|
|
49
|
-
help="Write the normalized inventory to disk.",
|
|
50
|
-
)
|
|
51
|
-
normalize_parser.add_argument(
|
|
52
|
-
"--inventory-path",
|
|
53
|
-
default=str(DEFAULT_INVENTORY_PATH),
|
|
54
|
-
help="Path to write the normalized inventory JSON.",
|
|
55
|
-
)
|
|
56
|
-
normalize_parser.set_defaults(write_inventory=True)
|
|
57
|
-
|
|
58
21
|
cooldown_parser = subparsers.add_parser(
|
|
59
22
|
"cooldown",
|
|
60
|
-
help="Show weekly availability from
|
|
61
|
-
)
|
|
62
|
-
cooldown_parser.add_argument(
|
|
63
|
-
"--inventory-path",
|
|
64
|
-
default=str(DEFAULT_INVENTORY_PATH),
|
|
65
|
-
help="Path to the normalized inventory JSON.",
|
|
66
|
-
)
|
|
67
|
-
cooldown_parser.add_argument(
|
|
68
|
-
"--source-dir",
|
|
69
|
-
default=str(DEFAULT_SAMPLE_HOME),
|
|
70
|
-
help="Directory containing legacy *_auth.json files when refreshing inventory.",
|
|
71
|
-
)
|
|
72
|
-
cooldown_parser.add_argument(
|
|
73
|
-
"--session-duration-hours",
|
|
74
|
-
type=float,
|
|
75
|
-
default=DEFAULT_SESSION_DURATION_HOURS,
|
|
76
|
-
help="Hours to subtract from auth file mtime to infer session start.",
|
|
23
|
+
help="Show weekly availability from backup metadata, optionally merged with live Codex status.",
|
|
77
24
|
)
|
|
78
25
|
cooldown_parser.add_argument(
|
|
79
|
-
"--
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
help="Year used when expanding legacy day-month tokens such as 21apr.",
|
|
83
|
-
)
|
|
84
|
-
cooldown_parser.add_argument(
|
|
85
|
-
"--refresh",
|
|
86
|
-
action="store_true",
|
|
87
|
-
help="Regenerate inventory from source-dir before showing cooldowns.",
|
|
26
|
+
"--backup-dir",
|
|
27
|
+
default=str(DEFAULT_BACKUP_DIR),
|
|
28
|
+
help="Directory containing backup archives and metadata.",
|
|
88
29
|
)
|
|
89
30
|
cooldown_parser.add_argument(
|
|
90
31
|
"--live",
|
|
@@ -138,34 +79,60 @@ def get_parser() -> argparse.ArgumentParser:
|
|
|
138
79
|
|
|
139
80
|
recommend_parser = subparsers.add_parser(
|
|
140
81
|
"recommend",
|
|
141
|
-
help="Recommend the best account to use next from
|
|
82
|
+
help="Recommend the best account to use next from backup metadata, optionally merged with live Codex status.",
|
|
142
83
|
)
|
|
143
84
|
recommend_parser.add_argument(
|
|
144
|
-
"--
|
|
145
|
-
default=str(
|
|
146
|
-
help="
|
|
85
|
+
"--backup-dir",
|
|
86
|
+
default=str(DEFAULT_BACKUP_DIR),
|
|
87
|
+
help="Directory containing backup archives and metadata.",
|
|
147
88
|
)
|
|
148
89
|
recommend_parser.add_argument(
|
|
149
|
-
"--
|
|
150
|
-
|
|
151
|
-
help="
|
|
90
|
+
"--live",
|
|
91
|
+
action="store_true",
|
|
92
|
+
help="Query current live account via /status and merge with stored backups.",
|
|
152
93
|
)
|
|
153
94
|
recommend_parser.add_argument(
|
|
154
|
-
"--
|
|
155
|
-
|
|
156
|
-
default=DEFAULT_SESSION_DURATION_HOURS,
|
|
157
|
-
help="Hours to subtract from auth file mtime to infer session start.",
|
|
95
|
+
"--status-command",
|
|
96
|
+
help="Shell command that prints parseable Codex status text for --live mode.",
|
|
158
97
|
)
|
|
159
98
|
recommend_parser.add_argument(
|
|
160
99
|
"--reference-year",
|
|
161
100
|
type=int,
|
|
162
|
-
|
|
163
|
-
help="Year used when expanding legacy day-month tokens such as 21apr.",
|
|
101
|
+
help="Year used when the status text omits the year in reset time.",
|
|
164
102
|
)
|
|
165
103
|
recommend_parser.add_argument(
|
|
166
|
-
"--
|
|
167
|
-
|
|
168
|
-
help="
|
|
104
|
+
"--codex-command",
|
|
105
|
+
default="codex --no-alt-screen",
|
|
106
|
+
help="Command used to launch Codex for live tmux capture in --live mode.",
|
|
107
|
+
)
|
|
108
|
+
recommend_parser.add_argument(
|
|
109
|
+
"--tmux-session-name",
|
|
110
|
+
default="codexmgr_capture",
|
|
111
|
+
help="Temporary tmux session name used for live status capture in --live mode.",
|
|
112
|
+
)
|
|
113
|
+
recommend_parser.add_argument(
|
|
114
|
+
"--tmux-cols",
|
|
115
|
+
type=int,
|
|
116
|
+
default=120,
|
|
117
|
+
help="tmux capture width for live status capture in --live mode.",
|
|
118
|
+
)
|
|
119
|
+
recommend_parser.add_argument(
|
|
120
|
+
"--tmux-rows",
|
|
121
|
+
type=int,
|
|
122
|
+
default=40,
|
|
123
|
+
help="tmux capture height for live status capture in --live mode.",
|
|
124
|
+
)
|
|
125
|
+
recommend_parser.add_argument(
|
|
126
|
+
"--startup-timeout-seconds",
|
|
127
|
+
type=float,
|
|
128
|
+
default=20.0,
|
|
129
|
+
help="Seconds to wait for the Codex prompt in --live mode.",
|
|
130
|
+
)
|
|
131
|
+
recommend_parser.add_argument(
|
|
132
|
+
"--status-timeout-seconds",
|
|
133
|
+
type=float,
|
|
134
|
+
default=20.0,
|
|
135
|
+
help="Seconds to wait for the status panel in --live mode.",
|
|
169
136
|
)
|
|
170
137
|
|
|
171
138
|
status_parser = subparsers.add_parser(
|
|
@@ -5,14 +5,13 @@ import shutil
|
|
|
5
5
|
import subprocess
|
|
6
6
|
import tarfile
|
|
7
7
|
import tempfile
|
|
8
|
-
from dataclasses import asdict
|
|
9
8
|
from datetime import datetime
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
|
|
12
11
|
from .config import DEFAULT_BACKUP_DIR, DEFAULT_CODEX_HOME
|
|
13
|
-
from .normalize import isoformat_local
|
|
14
12
|
from .prune import perform_prune
|
|
15
13
|
from .status import LiveStatus, capture_tmux_status_text, parse_live_status_text
|
|
14
|
+
from .utils import isoformat_local
|
|
16
15
|
|
|
17
16
|
EXCLUDED_TOP_LEVEL_NAMES = {".tmp", "tmp"}
|
|
18
17
|
AUTH_ONLY_INCLUDES = {"auth.json", "config.toml", "installation_id"}
|
|
@@ -7,9 +7,7 @@ from .args import get_parser
|
|
|
7
7
|
from .backup import backup_result_to_text, perform_backup
|
|
8
8
|
from .cooldown import CooldownStatus, evaluate_records, statuses_to_table
|
|
9
9
|
from .doctor import run_doctor
|
|
10
|
-
from .inventory import load_inventory, write_inventory
|
|
11
10
|
from .list_backups import entries_to_table, list_backups
|
|
12
|
-
from .normalize import normalize_directory, records_to_json
|
|
13
11
|
from .profile import export_profile, import_profile
|
|
14
12
|
from .prune import perform_prune, prune_result_to_text
|
|
15
13
|
from .prune_backups import perform_prune_backups
|
|
@@ -27,68 +25,41 @@ def main() -> None:
|
|
|
27
25
|
parser = get_parser()
|
|
28
26
|
args = parser.parse_args()
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
def build_live_status(args) -> CooldownStatus | None:
|
|
29
|
+
if not getattr(args, "live", False):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
from .backup import read_status_text_from_args
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
|
|
35
|
+
status_text = read_status_text_from_args(args)
|
|
36
|
+
ls = parse_live_status_text(status_text, reference_year=getattr(args, "reference_year", None))
|
|
37
|
+
|
|
38
|
+
now = datetime.now().astimezone()
|
|
39
|
+
remaining_seconds = int((ls.reset_at - now).total_seconds())
|
|
40
|
+
status = "ready" if remaining_seconds <= 0 else "cooldown"
|
|
41
|
+
|
|
42
|
+
return CooldownStatus(
|
|
43
|
+
email=ls.email,
|
|
44
|
+
status=status,
|
|
45
|
+
session_start_at=ls.session_start_at,
|
|
46
|
+
next_available_at=ls.reset_at,
|
|
47
|
+
quota_end_detected_at=now,
|
|
48
|
+
validation_status="live",
|
|
49
|
+
proposed_archive_name=ls.proposed_archive_name,
|
|
50
|
+
remaining_seconds=max(0, remaining_seconds),
|
|
35
51
|
)
|
|
36
|
-
if args.write_inventory:
|
|
37
|
-
write_inventory(records, Path(args.inventory_path))
|
|
38
|
-
print(records_to_json(records))
|
|
39
|
-
return
|
|
40
52
|
|
|
41
53
|
if args.command == "cooldown":
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Path(args.source_dir),
|
|
46
|
-
session_duration_hours=args.session_duration_hours,
|
|
47
|
-
reference_year=args.reference_year,
|
|
48
|
-
)
|
|
49
|
-
write_inventory(records, inventory_path)
|
|
50
|
-
else:
|
|
51
|
-
records = load_inventory(inventory_path)
|
|
52
|
-
|
|
53
|
-
live_status = None
|
|
54
|
-
if args.live:
|
|
55
|
-
from .backup import read_status_text_from_args
|
|
56
|
-
from datetime import datetime
|
|
57
|
-
status_text = read_status_text_from_args(args)
|
|
58
|
-
ls = parse_live_status_text(status_text)
|
|
59
|
-
|
|
60
|
-
now = datetime.now().astimezone()
|
|
61
|
-
remaining_seconds = int((ls.reset_at - now).total_seconds())
|
|
62
|
-
s = "ready" if remaining_seconds <= 0 else "cooldown"
|
|
63
|
-
|
|
64
|
-
live_status = CooldownStatus(
|
|
65
|
-
email=ls.email,
|
|
66
|
-
status=s,
|
|
67
|
-
session_start_at=ls.session_start_at,
|
|
68
|
-
next_available_at=ls.reset_at,
|
|
69
|
-
quota_end_detected_at=now,
|
|
70
|
-
validation_status="live",
|
|
71
|
-
proposed_archive_name=ls.proposed_archive_name,
|
|
72
|
-
remaining_seconds=max(0, remaining_seconds),
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
statuses = evaluate_records(records, live_status=live_status)[: args.limit]
|
|
54
|
+
entries = list_backups(Path(args.backup_dir).expanduser(), latest_per_email=True)
|
|
55
|
+
live_status = build_live_status(args)
|
|
56
|
+
statuses = evaluate_records(entries, live_status=live_status)[: args.limit]
|
|
76
57
|
print(statuses_to_table(statuses, live_email=live_status.email if live_status else None))
|
|
77
58
|
return
|
|
78
59
|
|
|
79
60
|
if args.command == "recommend":
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
records = normalize_directory(
|
|
83
|
-
Path(args.source_dir),
|
|
84
|
-
session_duration_hours=args.session_duration_hours,
|
|
85
|
-
reference_year=args.reference_year,
|
|
86
|
-
)
|
|
87
|
-
write_inventory(records, inventory_path)
|
|
88
|
-
else:
|
|
89
|
-
records = load_inventory(inventory_path)
|
|
90
|
-
|
|
91
|
-
recommendation = choose_best_account(evaluate_records(records))
|
|
61
|
+
entries = list_backups(Path(args.backup_dir).expanduser(), latest_per_email=True)
|
|
62
|
+
recommendation = choose_best_account(evaluate_records(entries, live_status=build_live_status(args)))
|
|
92
63
|
print(recommendation_to_text(recommendation))
|
|
93
64
|
return
|
|
94
65
|
|
|
@@ -1,43 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import re
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
|
|
7
6
|
CODEX_MANAGER_HOME = Path(
|
|
8
7
|
os.path.expanduser(os.environ.get("CODEXMGR_HOME", "~/.codexmgr"))
|
|
9
8
|
)
|
|
10
9
|
DEFAULT_CODEX_HOME = Path(os.path.expanduser("~/.codex"))
|
|
11
|
-
DEFAULT_SAMPLE_HOME = Path(".codex_sample")
|
|
12
|
-
DEFAULT_INVENTORY_PATH = CODEX_MANAGER_HOME / "inventory.json"
|
|
13
10
|
DEFAULT_BACKUP_DIR = CODEX_MANAGER_HOME / "backups"
|
|
14
11
|
DEFAULT_COOLDOWN_DISPLAY_LIMIT = 200
|
|
15
|
-
DEFAULT_SESSION_DURATION_HOURS = 2.0
|
|
16
|
-
DEFAULT_REFERENCE_YEAR = 2026
|
|
17
|
-
|
|
18
|
-
LEGACY_AUTH_FILENAME_RE = re.compile(
|
|
19
|
-
r"^(?P<day>\d{1,2})(?P<month>[a-z]{3})_(?P<email>.+)_auth\.json$",
|
|
20
|
-
re.IGNORECASE,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
NORMALIZED_ARCHIVE_RE = re.compile(
|
|
24
|
-
r"^\d{4}-\d{2}-\d{2}-\d{6}-.+-codex\.tar\.gz$"
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
MONTH_LOOKUP = {
|
|
28
|
-
"jan": 1,
|
|
29
|
-
"feb": 2,
|
|
30
|
-
"mar": 3,
|
|
31
|
-
"apr": 4,
|
|
32
|
-
"may": 5,
|
|
33
|
-
"jun": 6,
|
|
34
|
-
"jul": 7,
|
|
35
|
-
"aug": 8,
|
|
36
|
-
"sep": 9,
|
|
37
|
-
"oct": 10,
|
|
38
|
-
"nov": 11,
|
|
39
|
-
"dec": 12,
|
|
40
|
-
}
|
|
41
12
|
|
|
42
13
|
def load_config() -> dict[str, str | int | float | bool]:
|
|
43
14
|
import json
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
|
|
6
|
-
from .
|
|
6
|
+
from .list_backups import BackupEntry
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
@dataclass(frozen=True)
|
|
@@ -22,32 +22,32 @@ def parse_iso_datetime(value: str) -> datetime:
|
|
|
22
22
|
return datetime.fromisoformat(value)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def evaluate_entry(entry: BackupEntry, now: datetime | None = None) -> CooldownStatus:
|
|
26
26
|
current = now.astimezone() if now is not None else datetime.now().astimezone()
|
|
27
|
-
session_start_at = parse_iso_datetime(
|
|
28
|
-
next_available_at = parse_iso_datetime(
|
|
29
|
-
quota_end_detected_at = parse_iso_datetime(
|
|
27
|
+
session_start_at = parse_iso_datetime(entry.session_start_at)
|
|
28
|
+
next_available_at = parse_iso_datetime(entry.reset_at)
|
|
29
|
+
quota_end_detected_at = parse_iso_datetime(entry.created_at)
|
|
30
30
|
remaining_seconds = int((next_available_at - current).total_seconds())
|
|
31
31
|
status = "ready" if remaining_seconds <= 0 else "cooldown"
|
|
32
32
|
|
|
33
33
|
return CooldownStatus(
|
|
34
|
-
email=
|
|
34
|
+
email=entry.email,
|
|
35
35
|
status=status,
|
|
36
36
|
session_start_at=session_start_at,
|
|
37
37
|
next_available_at=next_available_at,
|
|
38
38
|
quota_end_detected_at=quota_end_detected_at,
|
|
39
|
-
validation_status=
|
|
40
|
-
proposed_archive_name=
|
|
39
|
+
validation_status="backup",
|
|
40
|
+
proposed_archive_name=entry.archive_path.name,
|
|
41
41
|
remaining_seconds=max(0, remaining_seconds),
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def evaluate_records(
|
|
46
|
-
|
|
46
|
+
entries: list[BackupEntry],
|
|
47
47
|
now: datetime | None = None,
|
|
48
48
|
live_status: CooldownStatus | None = None,
|
|
49
49
|
) -> list[CooldownStatus]:
|
|
50
|
-
statuses = [
|
|
50
|
+
statuses = [evaluate_entry(entry, now=now) for entry in entries]
|
|
51
51
|
|
|
52
52
|
if live_status is not None:
|
|
53
53
|
# replace any historical status for the live account
|
|
@@ -83,8 +83,8 @@ def statuses_to_table(statuses: list[CooldownStatus], live_email: str | None = N
|
|
|
83
83
|
"Status",
|
|
84
84
|
"Available",
|
|
85
85
|
"Session Start",
|
|
86
|
-
"
|
|
87
|
-
"
|
|
86
|
+
"Reset At",
|
|
87
|
+
"Source",
|
|
88
88
|
]
|
|
89
89
|
rows = []
|
|
90
90
|
for status in statuses:
|
|
@@ -19,17 +19,17 @@ def choose_best_account(statuses: list[CooldownStatus]) -> Recommendation:
|
|
|
19
19
|
statuses,
|
|
20
20
|
key=lambda item: (
|
|
21
21
|
item.status != "ready",
|
|
22
|
-
item.validation_status != "
|
|
22
|
+
item.validation_status != "live",
|
|
23
23
|
item.next_available_at if item.status != "ready" else item.session_start_at,
|
|
24
24
|
item.email,
|
|
25
25
|
),
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
if selected.status == "ready":
|
|
29
|
-
if selected.validation_status == "
|
|
30
|
-
reason = "Ready now
|
|
29
|
+
if selected.validation_status == "live":
|
|
30
|
+
reason = "Ready now from live Codex status."
|
|
31
31
|
else:
|
|
32
|
-
reason = "Ready now
|
|
32
|
+
reason = "Ready now from backup metadata."
|
|
33
33
|
else:
|
|
34
34
|
reason = (
|
|
35
35
|
"No account is ready. This account becomes available first in "
|
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from datetime import datetime, timedelta
|
|
8
8
|
from typing import Sequence
|
|
9
9
|
|
|
10
|
-
from .
|
|
10
|
+
from .utils import build_archive_name, isoformat_local
|
|
11
11
|
|
|
12
12
|
STATUS_PANEL_ACCOUNT_RE = re.compile(r"Account:\s+(\S+@\S+)")
|
|
13
13
|
STATUS_PANEL_WEEKLY_RE = re.compile(r"Weekly limit:\s+(.*?)(?:\n|$)", re.DOTALL)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def isoformat_local(dt: datetime) -> str:
|
|
7
|
+
return dt.astimezone().isoformat(timespec="seconds")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_archive_name(session_start_at: datetime, email: str) -> str:
|
|
11
|
+
return f"{session_start_at.strftime('%Y-%m-%d-%H%M%S')}-{email}-codex.tar.gz"
|
|
@@ -8,9 +8,7 @@ src/codex_manager/cli.py
|
|
|
8
8
|
src/codex_manager/config.py
|
|
9
9
|
src/codex_manager/cooldown.py
|
|
10
10
|
src/codex_manager/doctor.py
|
|
11
|
-
src/codex_manager/inventory.py
|
|
12
11
|
src/codex_manager/list_backups.py
|
|
13
|
-
src/codex_manager/normalize.py
|
|
14
12
|
src/codex_manager/profile.py
|
|
15
13
|
src/codex_manager/prune.py
|
|
16
14
|
src/codex_manager/prune_backups.py
|
|
@@ -19,6 +17,7 @@ src/codex_manager/restore.py
|
|
|
19
17
|
src/codex_manager/status.py
|
|
20
18
|
src/codex_manager/sync.py
|
|
21
19
|
src/codex_manager/use_account.py
|
|
20
|
+
src/codex_manager/utils.py
|
|
22
21
|
src/codex_manager.egg-info/PKG-INFO
|
|
23
22
|
src/codex_manager.egg-info/SOURCES.txt
|
|
24
23
|
src/codex_manager.egg-info/dependency_links.txt
|
|
@@ -29,9 +28,7 @@ tests/test_backup.py
|
|
|
29
28
|
tests/test_config.py
|
|
30
29
|
tests/test_cooldown.py
|
|
31
30
|
tests/test_doctor.py
|
|
32
|
-
tests/test_inventory.py
|
|
33
31
|
tests/test_list_backups.py
|
|
34
|
-
tests/test_normalize.py
|
|
35
32
|
tests/test_profile.py
|
|
36
33
|
tests/test_prune.py
|
|
37
34
|
tests/test_prune_backups.py
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from codex_manager.cooldown import CooldownStatus, evaluate_entry, evaluate_records, format_remaining
|
|
7
|
+
from codex_manager.list_backups import BackupEntry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_entry(reset_at: str, email: str = "a@example.com") -> BackupEntry:
|
|
11
|
+
return BackupEntry(
|
|
12
|
+
archive_path=Path(f"/tmp/{email}.tar.gz"),
|
|
13
|
+
email=email,
|
|
14
|
+
session_start_at="2026-04-14T15:55:00+00:00",
|
|
15
|
+
reset_at=reset_at,
|
|
16
|
+
created_at="2026-04-14T17:55:00+00:00",
|
|
17
|
+
quota_percent_left=0,
|
|
18
|
+
quota_text="[####] 0% left",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_evaluate_record_ready() -> None:
|
|
23
|
+
entry = make_entry("2026-04-20T15:55:00+00:00")
|
|
24
|
+
status = evaluate_entry(entry, now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc))
|
|
25
|
+
assert status.status == "ready"
|
|
26
|
+
assert status.remaining_seconds == 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_evaluate_record_cooldown() -> None:
|
|
30
|
+
entry = make_entry("2026-04-21T18:00:00+00:00")
|
|
31
|
+
status = evaluate_entry(entry, now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc))
|
|
32
|
+
assert status.status == "cooldown"
|
|
33
|
+
assert status.remaining_seconds == 7200
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_evaluate_records_sorts_ready_first() -> None:
|
|
37
|
+
ready = make_entry("2026-04-20T15:55:00+00:00", email="ready@example.com")
|
|
38
|
+
locked = make_entry("2026-04-22T15:55:00+00:00", email="locked@example.com")
|
|
39
|
+
statuses = evaluate_records(
|
|
40
|
+
[locked, ready],
|
|
41
|
+
now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc),
|
|
42
|
+
)
|
|
43
|
+
assert statuses[0].email == "ready@example.com"
|
|
44
|
+
assert statuses[1].email == "locked@example.com"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_evaluate_records_merges_live_status() -> None:
|
|
48
|
+
now = datetime(2026, 4, 18, 12, 0, 0, tzinfo=timezone.utc)
|
|
49
|
+
entry1 = BackupEntry(
|
|
50
|
+
archive_path=Path("/tmp/a@example.com.tar.gz"),
|
|
51
|
+
email="a@example.com",
|
|
52
|
+
session_start_at="2026-04-12T10:00:00+00:00",
|
|
53
|
+
reset_at="2026-04-19T10:00:00+00:00",
|
|
54
|
+
created_at="2026-04-12T12:00:00+00:00",
|
|
55
|
+
quota_percent_left=0,
|
|
56
|
+
quota_text="[####] 0% left",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
live_status = CooldownStatus(
|
|
60
|
+
email="a@example.com",
|
|
61
|
+
status="cooldown",
|
|
62
|
+
session_start_at=datetime(2026, 4, 13, 10, 0, 0, tzinfo=timezone.utc),
|
|
63
|
+
next_available_at=datetime(2026, 4, 20, 10, 0, 0, tzinfo=timezone.utc),
|
|
64
|
+
quota_end_detected_at=datetime(2026, 4, 13, 12, 0, 0, tzinfo=timezone.utc),
|
|
65
|
+
validation_status="live",
|
|
66
|
+
proposed_archive_name="2026-04-13-100000-a@example.com-codex.tar.gz",
|
|
67
|
+
remaining_seconds=1000,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
statuses = evaluate_records([entry1], now=now, live_status=live_status)
|
|
71
|
+
assert len(statuses) == 1
|
|
72
|
+
assert statuses[0].validation_status == "live"
|
|
73
|
+
assert statuses[0].next_available_at.day == 20
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_format_remaining() -> None:
|
|
77
|
+
assert format_remaining(0) == "now"
|
|
78
|
+
assert format_remaining(5400) == "1h 30m"
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
|
|
5
|
-
from codex_manager.cooldown import evaluate_records
|
|
6
|
-
from codex_manager.
|
|
6
|
+
from codex_manager.cooldown import CooldownStatus, evaluate_records
|
|
7
|
+
from codex_manager.list_backups import BackupEntry
|
|
7
8
|
from codex_manager.recommend import choose_best_account
|
|
8
9
|
|
|
9
10
|
|
|
@@ -12,44 +13,44 @@ def make_record(
|
|
|
12
13
|
email: str,
|
|
13
14
|
session_start_at: str,
|
|
14
15
|
next_available_at: str,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
) -> BackupEntry:
|
|
17
|
+
return BackupEntry(
|
|
18
|
+
archive_path=Path(f"/tmp/{email}.tar.gz"),
|
|
18
19
|
email=email,
|
|
19
|
-
legacy_auth_filename=f"21apr_{email}_auth.json",
|
|
20
|
-
legacy_quota_day_token="21apr",
|
|
21
|
-
quota_end_detected_at="2026-04-14T17:55:00+00:00",
|
|
22
20
|
session_start_at=session_start_at,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
validation_details="x",
|
|
28
|
-
source_path=f".codex_sample/21apr_{email}_auth.json",
|
|
21
|
+
reset_at=next_available_at,
|
|
22
|
+
created_at="2026-04-14T17:55:00+00:00",
|
|
23
|
+
quota_percent_left=0,
|
|
24
|
+
quota_text="[####] 0% left",
|
|
29
25
|
)
|
|
30
26
|
|
|
31
27
|
|
|
32
|
-
def
|
|
28
|
+
def test_choose_best_account_prefers_ready_and_live_first() -> None:
|
|
33
29
|
statuses = evaluate_records(
|
|
34
30
|
[
|
|
35
31
|
make_record(
|
|
36
|
-
email="
|
|
32
|
+
email="backup@example.com",
|
|
37
33
|
session_start_at="2026-04-10T10:00:00+00:00",
|
|
38
34
|
next_available_at="2026-04-17T10:00:00+00:00",
|
|
39
|
-
validation_status="mismatch",
|
|
40
|
-
),
|
|
41
|
-
make_record(
|
|
42
|
-
email="ok@example.com",
|
|
43
|
-
session_start_at="2026-04-11T10:00:00+00:00",
|
|
44
|
-
next_available_at="2026-04-18T10:00:00+00:00",
|
|
45
|
-
validation_status="ok",
|
|
46
35
|
),
|
|
47
36
|
],
|
|
48
37
|
now=datetime(2026, 4, 19, 10, 0, tzinfo=timezone.utc),
|
|
49
38
|
)
|
|
39
|
+
statuses.append(
|
|
40
|
+
CooldownStatus(
|
|
41
|
+
email="live@example.com",
|
|
42
|
+
status="ready",
|
|
43
|
+
session_start_at=datetime(2026, 4, 11, 10, 0, tzinfo=timezone.utc),
|
|
44
|
+
next_available_at=datetime(2026, 4, 18, 10, 0, tzinfo=timezone.utc),
|
|
45
|
+
quota_end_detected_at=datetime(2026, 4, 11, 12, 0, tzinfo=timezone.utc),
|
|
46
|
+
validation_status="live",
|
|
47
|
+
proposed_archive_name="2026-04-11-100000-live@example.com-codex.tar.gz",
|
|
48
|
+
remaining_seconds=0,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
50
51
|
|
|
51
52
|
recommendation = choose_best_account(statuses)
|
|
52
|
-
assert recommendation.selected.email == "
|
|
53
|
+
assert recommendation.selected.email == "live@example.com"
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
def test_choose_best_account_uses_earliest_unlock_when_none_ready() -> None:
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from dataclasses import asdict
|
|
5
|
-
from datetime import datetime
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from .normalize import NormalizedRecord, isoformat_local
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def build_inventory_payload(records: list[NormalizedRecord]) -> dict:
|
|
12
|
-
return {
|
|
13
|
-
"generated_at": isoformat_local(datetime.now().astimezone()),
|
|
14
|
-
"record_count": len(records),
|
|
15
|
-
"records": [asdict(record) for record in records],
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def write_inventory(records: list[NormalizedRecord], inventory_path: Path) -> None:
|
|
20
|
-
inventory_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
-
inventory_path.write_text(
|
|
22
|
-
json.dumps(build_inventory_payload(records), indent=2),
|
|
23
|
-
encoding="utf-8",
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def load_inventory(inventory_path: Path) -> list[NormalizedRecord]:
|
|
28
|
-
payload = json.loads(inventory_path.read_text(encoding="utf-8"))
|
|
29
|
-
raw_records = payload.get("records", [])
|
|
30
|
-
if not isinstance(raw_records, list):
|
|
31
|
-
raise ValueError(f"Invalid inventory payload in {inventory_path}")
|
|
32
|
-
return [NormalizedRecord(**record) for record in raw_records]
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from dataclasses import asdict, dataclass
|
|
4
|
-
from datetime import datetime, timedelta
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Iterable
|
|
7
|
-
|
|
8
|
-
from .config import LEGACY_AUTH_FILENAME_RE, MONTH_LOOKUP, NORMALIZED_ARCHIVE_RE
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@dataclass(frozen=True)
|
|
12
|
-
class NormalizedRecord:
|
|
13
|
-
email: str
|
|
14
|
-
legacy_auth_filename: str
|
|
15
|
-
legacy_quota_day_token: str
|
|
16
|
-
quota_end_detected_at: str
|
|
17
|
-
session_start_at: str
|
|
18
|
-
next_available_at: str
|
|
19
|
-
inferred_session_duration_seconds: int
|
|
20
|
-
proposed_archive_name: str
|
|
21
|
-
validation_status: str
|
|
22
|
-
validation_details: str
|
|
23
|
-
source_path: str
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def parse_legacy_auth_filename(filename: str) -> tuple[int, int, str, str]:
|
|
27
|
-
match = LEGACY_AUTH_FILENAME_RE.match(filename)
|
|
28
|
-
if not match:
|
|
29
|
-
raise ValueError(f"Unsupported legacy auth filename: {filename}")
|
|
30
|
-
|
|
31
|
-
day = int(match.group("day"))
|
|
32
|
-
month_token = match.group("month").lower()
|
|
33
|
-
email = match.group("email")
|
|
34
|
-
month = MONTH_LOOKUP.get(month_token)
|
|
35
|
-
if month is None:
|
|
36
|
-
raise ValueError(f"Unsupported month token in filename: {filename}")
|
|
37
|
-
|
|
38
|
-
return day, month, month_token, email
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def isoformat_local(dt: datetime) -> str:
|
|
42
|
-
return dt.astimezone().isoformat(timespec="seconds")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def build_archive_name(session_start_at: datetime, email: str) -> str:
|
|
46
|
-
name = f"{session_start_at.strftime('%Y-%m-%d-%H%M%S')}-{email}-codex.tar.gz"
|
|
47
|
-
if not NORMALIZED_ARCHIVE_RE.match(name):
|
|
48
|
-
raise ValueError(f"Generated invalid archive name: {name}")
|
|
49
|
-
return name
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def normalize_auth_file(
|
|
53
|
-
auth_path: Path,
|
|
54
|
-
*,
|
|
55
|
-
session_duration_hours: float,
|
|
56
|
-
reference_year: int,
|
|
57
|
-
) -> NormalizedRecord:
|
|
58
|
-
day, month, month_token, email = parse_legacy_auth_filename(auth_path.name)
|
|
59
|
-
quota_end_detected_at = datetime.fromtimestamp(auth_path.stat().st_mtime).astimezone()
|
|
60
|
-
session_start_at = quota_end_detected_at - timedelta(hours=session_duration_hours)
|
|
61
|
-
next_available_at = session_start_at + timedelta(days=7)
|
|
62
|
-
target_date = datetime(reference_year, month, day).date()
|
|
63
|
-
|
|
64
|
-
if next_available_at.date() == target_date:
|
|
65
|
-
validation_status = "ok"
|
|
66
|
-
validation_details = "Legacy quota-day token matches computed next availability date."
|
|
67
|
-
else:
|
|
68
|
-
validation_status = "mismatch"
|
|
69
|
-
validation_details = (
|
|
70
|
-
"Legacy quota-day token does not match computed next availability date: "
|
|
71
|
-
f"expected {target_date.isoformat()}, got {next_available_at.date().isoformat()}."
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
return NormalizedRecord(
|
|
75
|
-
email=email,
|
|
76
|
-
legacy_auth_filename=auth_path.name,
|
|
77
|
-
legacy_quota_day_token=f"{day:02d}{month_token}",
|
|
78
|
-
quota_end_detected_at=isoformat_local(quota_end_detected_at),
|
|
79
|
-
session_start_at=isoformat_local(session_start_at),
|
|
80
|
-
next_available_at=isoformat_local(next_available_at),
|
|
81
|
-
inferred_session_duration_seconds=int(session_duration_hours * 3600),
|
|
82
|
-
proposed_archive_name=build_archive_name(session_start_at, email),
|
|
83
|
-
validation_status=validation_status,
|
|
84
|
-
validation_details=validation_details,
|
|
85
|
-
source_path=str(auth_path),
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def iter_legacy_auth_files(source_dir: Path) -> Iterable[Path]:
|
|
90
|
-
if not source_dir.exists():
|
|
91
|
-
raise FileNotFoundError(f"Source directory does not exist: {source_dir}")
|
|
92
|
-
if not source_dir.is_dir():
|
|
93
|
-
raise NotADirectoryError(f"Source path is not a directory: {source_dir}")
|
|
94
|
-
|
|
95
|
-
for path in sorted(source_dir.iterdir(), key=lambda item: item.name):
|
|
96
|
-
if path.is_file() and LEGACY_AUTH_FILENAME_RE.match(path.name):
|
|
97
|
-
yield path
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def normalize_directory(
|
|
101
|
-
source_dir: Path,
|
|
102
|
-
*,
|
|
103
|
-
session_duration_hours: float,
|
|
104
|
-
reference_year: int,
|
|
105
|
-
) -> list[NormalizedRecord]:
|
|
106
|
-
return [
|
|
107
|
-
normalize_auth_file(
|
|
108
|
-
path,
|
|
109
|
-
session_duration_hours=session_duration_hours,
|
|
110
|
-
reference_year=reference_year,
|
|
111
|
-
)
|
|
112
|
-
for path in iter_legacy_auth_files(source_dir)
|
|
113
|
-
]
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def records_to_json(records: list[NormalizedRecord]) -> str:
|
|
117
|
-
import json
|
|
118
|
-
|
|
119
|
-
return json.dumps([asdict(record) for record in records], indent=2)
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import datetime, timezone
|
|
4
|
-
|
|
5
|
-
from codex_manager.cooldown import evaluate_record, evaluate_records, format_remaining, CooldownStatus
|
|
6
|
-
from codex_manager.normalize import NormalizedRecord
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def make_record(next_available_at: str, email: str = "a@example.com") -> NormalizedRecord:
|
|
10
|
-
return NormalizedRecord(
|
|
11
|
-
email=email,
|
|
12
|
-
legacy_auth_filename="21apr_a@example.com_auth.json",
|
|
13
|
-
legacy_quota_day_token="21apr",
|
|
14
|
-
quota_end_detected_at="2026-04-14T17:55:00+00:00",
|
|
15
|
-
session_start_at="2026-04-14T15:55:00+00:00",
|
|
16
|
-
next_available_at=next_available_at,
|
|
17
|
-
inferred_session_duration_seconds=7200,
|
|
18
|
-
proposed_archive_name=f"2026-04-14-155500-{email}-codex.tar.gz",
|
|
19
|
-
validation_status="ok",
|
|
20
|
-
validation_details="ok",
|
|
21
|
-
source_path=".codex_sample/21apr_a@example.com_auth.json",
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_evaluate_record_ready() -> None:
|
|
26
|
-
record = make_record("2026-04-20T15:55:00+00:00")
|
|
27
|
-
status = evaluate_record(record, now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc))
|
|
28
|
-
assert status.status == "ready"
|
|
29
|
-
assert status.remaining_seconds == 0
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_evaluate_record_cooldown() -> None:
|
|
33
|
-
record = make_record("2026-04-21T18:00:00+00:00")
|
|
34
|
-
status = evaluate_record(record, now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc))
|
|
35
|
-
assert status.status == "cooldown"
|
|
36
|
-
assert status.remaining_seconds == 7200
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_evaluate_records_sorts_ready_first() -> None:
|
|
40
|
-
ready = make_record("2026-04-20T15:55:00+00:00", email="ready@example.com")
|
|
41
|
-
locked = make_record("2026-04-22T15:55:00+00:00", email="locked@example.com")
|
|
42
|
-
statuses = evaluate_records(
|
|
43
|
-
[locked, ready],
|
|
44
|
-
now=datetime(2026, 4, 21, 16, 0, tzinfo=timezone.utc),
|
|
45
|
-
)
|
|
46
|
-
assert statuses[0].email == "ready@example.com"
|
|
47
|
-
assert statuses[1].email == "locked@example.com"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_evaluate_records_merges_live_status() -> None:
|
|
51
|
-
now = datetime(2026, 4, 18, 12, 0, 0, tzinfo=timezone.utc)
|
|
52
|
-
record1 = NormalizedRecord(
|
|
53
|
-
email="a@example.com",
|
|
54
|
-
legacy_auth_filename="test.json",
|
|
55
|
-
legacy_quota_day_token="19apr",
|
|
56
|
-
quota_end_detected_at="2026-04-12T12:00:00+00:00",
|
|
57
|
-
session_start_at="2026-04-12T10:00:00+00:00",
|
|
58
|
-
next_available_at="2026-04-19T10:00:00+00:00",
|
|
59
|
-
inferred_session_duration_seconds=7200,
|
|
60
|
-
proposed_archive_name="2026-04-12-100000-a@example.com-codex.tar.gz",
|
|
61
|
-
validation_status="ok",
|
|
62
|
-
validation_details="ok",
|
|
63
|
-
source_path=".codex_sample/19apr_a@example.com_auth.json",
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
live_status = CooldownStatus(
|
|
67
|
-
email="a@example.com",
|
|
68
|
-
status="cooldown",
|
|
69
|
-
session_start_at=datetime(2026, 4, 13, 10, 0, 0, tzinfo=timezone.utc),
|
|
70
|
-
next_available_at=datetime(2026, 4, 20, 10, 0, 0, tzinfo=timezone.utc),
|
|
71
|
-
quota_end_detected_at=datetime(2026, 4, 13, 12, 0, 0, tzinfo=timezone.utc),
|
|
72
|
-
validation_status="live",
|
|
73
|
-
proposed_archive_name="2026-04-13-100000-a@example.com-codex.tar.gz",
|
|
74
|
-
remaining_seconds=1000,
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
statuses = evaluate_records([record1], now=now, live_status=live_status)
|
|
78
|
-
assert len(statuses) == 1
|
|
79
|
-
assert statuses[0].validation_status == "live"
|
|
80
|
-
assert statuses[0].next_available_at.day == 20
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_format_remaining() -> None:
|
|
84
|
-
assert format_remaining(0) == "now"
|
|
85
|
-
assert format_remaining(5400) == "1h 30m"
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from codex_manager.inventory import load_inventory, write_inventory
|
|
6
|
-
from codex_manager.normalize import NormalizedRecord
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_inventory_roundtrip(tmp_path: Path) -> None:
|
|
10
|
-
inventory_path = tmp_path / "inventory.json"
|
|
11
|
-
records = [
|
|
12
|
-
NormalizedRecord(
|
|
13
|
-
email="a@example.com",
|
|
14
|
-
legacy_auth_filename="21apr_a@example.com_auth.json",
|
|
15
|
-
legacy_quota_day_token="21apr",
|
|
16
|
-
quota_end_detected_at="2026-04-14T17:55:00+05:30",
|
|
17
|
-
session_start_at="2026-04-14T15:55:00+05:30",
|
|
18
|
-
next_available_at="2026-04-21T15:55:00+05:30",
|
|
19
|
-
inferred_session_duration_seconds=7200,
|
|
20
|
-
proposed_archive_name="2026-04-14-155500-a@example.com-codex.tar.gz",
|
|
21
|
-
validation_status="ok",
|
|
22
|
-
validation_details="ok",
|
|
23
|
-
source_path=".codex_sample/21apr_a@example.com_auth.json",
|
|
24
|
-
)
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
write_inventory(records, inventory_path)
|
|
28
|
-
loaded = load_inventory(inventory_path)
|
|
29
|
-
|
|
30
|
-
assert loaded == records
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import datetime, timedelta, timezone
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
from codex_manager.normalize import (
|
|
7
|
-
build_archive_name,
|
|
8
|
-
normalize_auth_file,
|
|
9
|
-
normalize_directory,
|
|
10
|
-
parse_legacy_auth_filename,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_parse_legacy_auth_filename() -> None:
|
|
15
|
-
day, month, month_token, email = parse_legacy_auth_filename(
|
|
16
|
-
"21apr_drdpsbose023@gmail.com_auth.json"
|
|
17
|
-
)
|
|
18
|
-
assert day == 21
|
|
19
|
-
assert month == 4
|
|
20
|
-
assert month_token == "apr"
|
|
21
|
-
assert email == "drdpsbose023@gmail.com"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_build_archive_name() -> None:
|
|
25
|
-
session_start = datetime(2026, 4, 14, 15, 55, 0, tzinfo=timezone.utc)
|
|
26
|
-
assert (
|
|
27
|
-
build_archive_name(session_start, "drdpsbose023@gmail.com")
|
|
28
|
-
== "2026-04-14-155500-drdpsbose023@gmail.com-codex.tar.gz"
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_normalize_auth_file_matches_example(tmp_path: Path, mocker) -> None:
|
|
33
|
-
auth_file = tmp_path / "21apr_drdpsbose023@gmail.com_auth.json"
|
|
34
|
-
auth_file.write_text("{}", encoding="utf-8")
|
|
35
|
-
ist = timezone(timedelta(hours=5, minutes=30))
|
|
36
|
-
quota_end = datetime(2026, 4, 14, 17, 55, 0, tzinfo=ist)
|
|
37
|
-
epoch = quota_end.timestamp()
|
|
38
|
-
auth_file.touch()
|
|
39
|
-
auth_file.chmod(0o600)
|
|
40
|
-
import os
|
|
41
|
-
os.utime(auth_file, (epoch, epoch))
|
|
42
|
-
|
|
43
|
-
# Avoid patching datetime which causes issues.
|
|
44
|
-
# Just calculate what the name should be based on the actual mtime
|
|
45
|
-
mtime = auth_file.stat().st_mtime
|
|
46
|
-
actual_quota_end = datetime.fromtimestamp(mtime).astimezone()
|
|
47
|
-
actual_session_start = actual_quota_end - timedelta(hours=2)
|
|
48
|
-
expected_name = f"{actual_session_start.strftime('%Y-%m-%d-%H%M%S')}-drdpsbose023@gmail.com-codex.tar.gz"
|
|
49
|
-
|
|
50
|
-
record = normalize_auth_file(
|
|
51
|
-
auth_file,
|
|
52
|
-
session_duration_hours=2,
|
|
53
|
-
reference_year=2026,
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
assert record.email == "drdpsbose023@gmail.com"
|
|
57
|
-
assert record.legacy_quota_day_token == "21apr"
|
|
58
|
-
assert record.proposed_archive_name == expected_name
|
|
59
|
-
assert record.validation_status == "ok"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_normalize_auth_file_detects_mismatch(tmp_path: Path) -> None:
|
|
63
|
-
auth_file = tmp_path / "21apr_drdpsbose023@gmail.com_auth.json"
|
|
64
|
-
auth_file.write_text("{}", encoding="utf-8")
|
|
65
|
-
ist = timezone(timedelta(hours=5, minutes=30))
|
|
66
|
-
quota_end = datetime(2026, 4, 14, 17, 55, 0, tzinfo=ist) + timedelta(days=1)
|
|
67
|
-
epoch = quota_end.timestamp()
|
|
68
|
-
import os
|
|
69
|
-
os.utime(auth_file, (epoch, epoch))
|
|
70
|
-
|
|
71
|
-
record = normalize_auth_file(
|
|
72
|
-
auth_file,
|
|
73
|
-
session_duration_hours=2,
|
|
74
|
-
reference_year=2026,
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
assert record.validation_status == "mismatch"
|
|
78
|
-
assert "expected 2026-04-21" in record.validation_details
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_normalize_directory_filters_non_matching_files(tmp_path: Path) -> None:
|
|
82
|
-
(tmp_path / "21apr_a@gmail.com_auth.json").write_text("{}", encoding="utf-8")
|
|
83
|
-
(tmp_path / "README.txt").write_text("x", encoding="utf-8")
|
|
84
|
-
|
|
85
|
-
records = normalize_directory(
|
|
86
|
-
tmp_path,
|
|
87
|
-
session_duration_hours=2,
|
|
88
|
-
reference_year=2026,
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
assert len(records) == 1
|
|
92
|
-
assert records[0].email == "a@gmail.com"
|
|
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
|