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.
Files changed (46) hide show
  1. {codex_manager-1.0.1 → codex_manager-2.0.0}/PKG-INFO +1 -1
  2. {codex_manager-1.0.1 → codex_manager-2.0.0}/pyproject.toml +1 -1
  3. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/__init__.py +1 -1
  4. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/args.py +46 -79
  5. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/backup.py +1 -2
  6. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/cli.py +28 -57
  7. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/config.py +0 -29
  8. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/cooldown.py +12 -12
  9. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/list_backups.py +0 -1
  10. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/recommend.py +4 -4
  11. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/restore.py +0 -1
  12. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/status.py +1 -1
  13. codex_manager-2.0.0/src/codex_manager/utils.py +11 -0
  14. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/PKG-INFO +1 -1
  15. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/SOURCES.txt +1 -4
  16. codex_manager-2.0.0/tests/test_cooldown.py +78 -0
  17. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_recommend.py +25 -24
  18. codex_manager-1.0.1/src/codex_manager/inventory.py +0 -32
  19. codex_manager-1.0.1/src/codex_manager/normalize.py +0 -119
  20. codex_manager-1.0.1/tests/test_cooldown.py +0 -85
  21. codex_manager-1.0.1/tests/test_inventory.py +0 -30
  22. codex_manager-1.0.1/tests/test_normalize.py +0 -92
  23. {codex_manager-1.0.1 → codex_manager-2.0.0}/CODEX_MANAGER_SPEC.md +0 -0
  24. {codex_manager-1.0.1 → codex_manager-2.0.0}/README.md +0 -0
  25. {codex_manager-1.0.1 → codex_manager-2.0.0}/setup.cfg +0 -0
  26. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/doctor.py +0 -0
  27. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/profile.py +0 -0
  28. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/prune.py +0 -0
  29. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/prune_backups.py +0 -0
  30. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/sync.py +0 -0
  31. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager/use_account.py +0 -0
  32. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/dependency_links.txt +0 -0
  33. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/entry_points.txt +0 -0
  34. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/requires.txt +0 -0
  35. {codex_manager-1.0.1 → codex_manager-2.0.0}/src/codex_manager.egg-info/top_level.txt +0 -0
  36. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_backup.py +0 -0
  37. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_config.py +0 -0
  38. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_doctor.py +0 -0
  39. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_list_backups.py +0 -0
  40. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_profile.py +0 -0
  41. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_prune.py +0 -0
  42. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_prune_backups.py +0 -0
  43. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_restore.py +0 -0
  44. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_status.py +0 -0
  45. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_sync.py +0 -0
  46. {codex_manager-1.0.1 → codex_manager-2.0.0}/tests/test_use.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-manager
3
- Version: 1.0.1
3
+ Version: 2.0.0
4
4
  Summary: Codex account snapshot manager
5
5
  Author-email: Dhruv <dhruv13x@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-manager"
3
- version = "1.0.1"
3
+ version = "2.0.0"
4
4
  description = "Codex account snapshot manager"
5
5
  readme = "CODEX_MANAGER_SPEC.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "2.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 normalized inventory records.",
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
- "--reference-year",
80
- type=int,
81
- default=DEFAULT_REFERENCE_YEAR,
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 normalized inventory records.",
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
- "--inventory-path",
145
- default=str(DEFAULT_INVENTORY_PATH),
146
- help="Path to the normalized inventory JSON.",
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
- "--source-dir",
150
- default=str(DEFAULT_SAMPLE_HOME),
151
- help="Directory containing legacy *_auth.json files when refreshing inventory.",
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
- "--session-duration-hours",
155
- type=float,
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
- default=DEFAULT_REFERENCE_YEAR,
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
- "--refresh",
167
- action="store_true",
168
- help="Regenerate inventory from source-dir before recommending an account.",
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
- if args.command == "normalize":
31
- records = normalize_directory(
32
- Path(args.source_dir),
33
- session_duration_hours=args.session_duration_hours,
34
- reference_year=args.reference_year,
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
- inventory_path = Path(args.inventory_path)
43
- if args.refresh or not inventory_path.exists():
44
- records = normalize_directory(
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
- inventory_path = Path(args.inventory_path)
81
- if args.refresh or not inventory_path.exists():
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 .normalize import NormalizedRecord
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 evaluate_record(record: NormalizedRecord, now: datetime | None = None) -> CooldownStatus:
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(record.session_start_at)
28
- next_available_at = parse_iso_datetime(record.next_available_at)
29
- quota_end_detected_at = parse_iso_datetime(record.quota_end_detected_at)
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=record.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=record.validation_status,
40
- proposed_archive_name=record.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
- records: list[NormalizedRecord],
46
+ entries: list[BackupEntry],
47
47
  now: datetime | None = None,
48
48
  live_status: CooldownStatus | None = None,
49
49
  ) -> list[CooldownStatus]:
50
- statuses = [evaluate_record(record, now=now) for record in records]
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
- "Quota End",
87
- "Valid",
86
+ "Reset At",
87
+ "Source",
88
88
  ]
89
89
  rows = []
90
90
  for status in statuses:
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  from pathlib import Path
5
5
 
6
- from .config import DEFAULT_BACKUP_DIR
7
6
  from .restore import load_metadata_for_archive
8
7
 
9
8
 
@@ -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 != "ok",
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 == "ok":
30
- reason = "Ready now with validated timing metadata."
29
+ if selected.validation_status == "live":
30
+ reason = "Ready now from live Codex status."
31
31
  else:
32
- reason = "Ready now, but timing metadata did not validate against the legacy quota token."
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 "
@@ -8,7 +8,6 @@ from datetime import datetime
8
8
  from pathlib import Path
9
9
 
10
10
  from .config import DEFAULT_BACKUP_DIR, DEFAULT_CODEX_HOME
11
- from .normalize import isoformat_local
12
11
 
13
12
 
14
13
  def resolve_archive_path(args) -> Path:
@@ -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 .normalize import build_archive_name, isoformat_local
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-manager
3
- Version: 1.0.1
3
+ Version: 2.0.0
4
4
  Summary: Codex account snapshot manager
5
5
  Author-email: Dhruv <dhruv13x@gmail.com>
6
6
  License: MIT
@@ -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.normalize import NormalizedRecord
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
- validation_status: str = "ok",
16
- ) -> NormalizedRecord:
17
- return NormalizedRecord(
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
- next_available_at=next_available_at,
24
- inferred_session_duration_seconds=7200,
25
- proposed_archive_name=f"2026-04-14-155500-{email}-codex.tar.gz",
26
- validation_status=validation_status,
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 test_choose_best_account_prefers_ready_and_validated() -> None:
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="mismatch@example.com",
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 == "ok@example.com"
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