artificial-intelligence-usage 0.0.1__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.
- artificial_intelligence_usage-0.0.1/PKG-INFO +104 -0
- artificial_intelligence_usage-0.0.1/README.md +93 -0
- artificial_intelligence_usage-0.0.1/pyproject.toml +23 -0
- artificial_intelligence_usage-0.0.1/setup.cfg +4 -0
- artificial_intelligence_usage-0.0.1/src/artificial_intelligence_usage.egg-info/PKG-INFO +104 -0
- artificial_intelligence_usage-0.0.1/src/artificial_intelligence_usage.egg-info/SOURCES.txt +10 -0
- artificial_intelligence_usage-0.0.1/src/artificial_intelligence_usage.egg-info/dependency_links.txt +1 -0
- artificial_intelligence_usage-0.0.1/src/artificial_intelligence_usage.egg-info/entry_points.txt +2 -0
- artificial_intelligence_usage-0.0.1/src/artificial_intelligence_usage.egg-info/top_level.txt +1 -0
- artificial_intelligence_usage-0.0.1/src/artificial_intelligence_usage.py +359 -0
- artificial_intelligence_usage-0.0.1/tests/test_artificial_intelligence_usage.py +197 -0
- artificial_intelligence_usage-0.0.1/tests/test_publish_pypi.py +141 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: artificial-intelligence-usage
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Analyze recent Codex sessions and report usage information.
|
|
5
|
+
Author: Kaizten Analytics
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Artificial Intelligence Usage
|
|
13
|
+
|
|
14
|
+
`artificial-intelligence-usage` analyzes local Codex session rollouts and prints a summary of the
|
|
15
|
+
latest session followed by daily session, turn, token, and rate-limit usage.
|
|
16
|
+
|
|
17
|
+
## Behavior
|
|
18
|
+
|
|
19
|
+
- Reads rollout files below `~/.codex/sessions`.
|
|
20
|
+
- Shows status details from the most recently modified session.
|
|
21
|
+
- Reports sessions from the last 14 calendar days by default, including today.
|
|
22
|
+
- Counts user turns and cumulative tokens for each session.
|
|
23
|
+
- Shows the remaining five-hour and weekly allowance recorded in each day's
|
|
24
|
+
last session.
|
|
25
|
+
- Ignores malformed JSON lines and incomplete status events.
|
|
26
|
+
|
|
27
|
+
The tool only reads local session files and does not modify them.
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- Python 3.10 or newer
|
|
32
|
+
|
|
33
|
+
## Building
|
|
34
|
+
|
|
35
|
+
### Testing
|
|
36
|
+
|
|
37
|
+
Run the unit tests from the project root:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python3 -m unittest discover -s tests -v
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Package artifact
|
|
44
|
+
|
|
45
|
+
Build and validate the package artifacts:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
./build-package.sh
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This creates the wheel and source distribution under `dist/` and checks the
|
|
52
|
+
package metadata with Twine.
|
|
53
|
+
|
|
54
|
+
### Publish artifact
|
|
55
|
+
|
|
56
|
+
Publish the generated artifacts to PyPI:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
./publish-pypi.sh
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The publish script checks that Twine is available, creates a local `.venv` for
|
|
63
|
+
Twine when needed, verifies that `dist/` contains generated artifacts, and then
|
|
64
|
+
uploads them. If PyPI rejects a version that was already published, increment
|
|
65
|
+
the version in `pyproject.toml`, rebuild, and publish again.
|
|
66
|
+
|
|
67
|
+
### Install artifact
|
|
68
|
+
|
|
69
|
+
Install the package in a virtual environment:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python3 -m venv .venv
|
|
73
|
+
source .venv/bin/activate
|
|
74
|
+
python3 -m pip install artificial-intelligence-usage
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
After installation, run:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
artificial-intelligence-usage
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Choose a different reporting window with `--days`:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
artificial-intelligence-usage --days 30
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
From a source checkout, `./artificial-intelligence-usage.py` is the canonical launcher and
|
|
92
|
+
`./script-sessions.py` remains available for compatibility.
|
|
93
|
+
|
|
94
|
+
### Help
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
usage: artificial-intelligence-usage [-h] [--days DAYS]
|
|
98
|
+
|
|
99
|
+
Analyze recent Codex sessions.
|
|
100
|
+
|
|
101
|
+
options:
|
|
102
|
+
-h, --help show this help message and exit
|
|
103
|
+
--days DAYS number of calendar days to analyze, including today (default: 14)
|
|
104
|
+
```
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Artificial Intelligence Usage
|
|
2
|
+
|
|
3
|
+
`artificial-intelligence-usage` analyzes local Codex session rollouts and prints a summary of the
|
|
4
|
+
latest session followed by daily session, turn, token, and rate-limit usage.
|
|
5
|
+
|
|
6
|
+
## Behavior
|
|
7
|
+
|
|
8
|
+
- Reads rollout files below `~/.codex/sessions`.
|
|
9
|
+
- Shows status details from the most recently modified session.
|
|
10
|
+
- Reports sessions from the last 14 calendar days by default, including today.
|
|
11
|
+
- Counts user turns and cumulative tokens for each session.
|
|
12
|
+
- Shows the remaining five-hour and weekly allowance recorded in each day's
|
|
13
|
+
last session.
|
|
14
|
+
- Ignores malformed JSON lines and incomplete status events.
|
|
15
|
+
|
|
16
|
+
The tool only reads local session files and does not modify them.
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Python 3.10 or newer
|
|
21
|
+
|
|
22
|
+
## Building
|
|
23
|
+
|
|
24
|
+
### Testing
|
|
25
|
+
|
|
26
|
+
Run the unit tests from the project root:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python3 -m unittest discover -s tests -v
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Package artifact
|
|
33
|
+
|
|
34
|
+
Build and validate the package artifacts:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
./build-package.sh
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This creates the wheel and source distribution under `dist/` and checks the
|
|
41
|
+
package metadata with Twine.
|
|
42
|
+
|
|
43
|
+
### Publish artifact
|
|
44
|
+
|
|
45
|
+
Publish the generated artifacts to PyPI:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
./publish-pypi.sh
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The publish script checks that Twine is available, creates a local `.venv` for
|
|
52
|
+
Twine when needed, verifies that `dist/` contains generated artifacts, and then
|
|
53
|
+
uploads them. If PyPI rejects a version that was already published, increment
|
|
54
|
+
the version in `pyproject.toml`, rebuild, and publish again.
|
|
55
|
+
|
|
56
|
+
### Install artifact
|
|
57
|
+
|
|
58
|
+
Install the package in a virtual environment:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python3 -m venv .venv
|
|
62
|
+
source .venv/bin/activate
|
|
63
|
+
python3 -m pip install artificial-intelligence-usage
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
After installation, run:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
artificial-intelligence-usage
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Choose a different reporting window with `--days`:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
artificial-intelligence-usage --days 30
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
From a source checkout, `./artificial-intelligence-usage.py` is the canonical launcher and
|
|
81
|
+
`./script-sessions.py` remains available for compatibility.
|
|
82
|
+
|
|
83
|
+
### Help
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
usage: artificial-intelligence-usage [-h] [--days DAYS]
|
|
87
|
+
|
|
88
|
+
Analyze recent Codex sessions.
|
|
89
|
+
|
|
90
|
+
options:
|
|
91
|
+
-h, --help show this help message and exit
|
|
92
|
+
--days DAYS number of calendar days to analyze, including today (default: 14)
|
|
93
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "artificial-intelligence-usage"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Analyze recent Codex sessions and report usage information."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "LicenseRef-Proprietary"
|
|
12
|
+
authors = [{ name = "Kaizten Analytics" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
artificial-intelligence-usage = "artificial_intelligence_usage:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
package-dir = { "" = "src" }
|
|
23
|
+
py-modules = ["artificial_intelligence_usage"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: artificial-intelligence-usage
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Analyze recent Codex sessions and report usage information.
|
|
5
|
+
Author: Kaizten Analytics
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Artificial Intelligence Usage
|
|
13
|
+
|
|
14
|
+
`artificial-intelligence-usage` analyzes local Codex session rollouts and prints a summary of the
|
|
15
|
+
latest session followed by daily session, turn, token, and rate-limit usage.
|
|
16
|
+
|
|
17
|
+
## Behavior
|
|
18
|
+
|
|
19
|
+
- Reads rollout files below `~/.codex/sessions`.
|
|
20
|
+
- Shows status details from the most recently modified session.
|
|
21
|
+
- Reports sessions from the last 14 calendar days by default, including today.
|
|
22
|
+
- Counts user turns and cumulative tokens for each session.
|
|
23
|
+
- Shows the remaining five-hour and weekly allowance recorded in each day's
|
|
24
|
+
last session.
|
|
25
|
+
- Ignores malformed JSON lines and incomplete status events.
|
|
26
|
+
|
|
27
|
+
The tool only reads local session files and does not modify them.
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- Python 3.10 or newer
|
|
32
|
+
|
|
33
|
+
## Building
|
|
34
|
+
|
|
35
|
+
### Testing
|
|
36
|
+
|
|
37
|
+
Run the unit tests from the project root:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python3 -m unittest discover -s tests -v
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Package artifact
|
|
44
|
+
|
|
45
|
+
Build and validate the package artifacts:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
./build-package.sh
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This creates the wheel and source distribution under `dist/` and checks the
|
|
52
|
+
package metadata with Twine.
|
|
53
|
+
|
|
54
|
+
### Publish artifact
|
|
55
|
+
|
|
56
|
+
Publish the generated artifacts to PyPI:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
./publish-pypi.sh
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The publish script checks that Twine is available, creates a local `.venv` for
|
|
63
|
+
Twine when needed, verifies that `dist/` contains generated artifacts, and then
|
|
64
|
+
uploads them. If PyPI rejects a version that was already published, increment
|
|
65
|
+
the version in `pyproject.toml`, rebuild, and publish again.
|
|
66
|
+
|
|
67
|
+
### Install artifact
|
|
68
|
+
|
|
69
|
+
Install the package in a virtual environment:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python3 -m venv .venv
|
|
73
|
+
source .venv/bin/activate
|
|
74
|
+
python3 -m pip install artificial-intelligence-usage
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
After installation, run:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
artificial-intelligence-usage
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Choose a different reporting window with `--days`:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
artificial-intelligence-usage --days 30
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
From a source checkout, `./artificial-intelligence-usage.py` is the canonical launcher and
|
|
92
|
+
`./script-sessions.py` remains available for compatibility.
|
|
93
|
+
|
|
94
|
+
### Help
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
usage: artificial-intelligence-usage [-h] [--days DAYS]
|
|
98
|
+
|
|
99
|
+
Analyze recent Codex sessions.
|
|
100
|
+
|
|
101
|
+
options:
|
|
102
|
+
-h, --help show this help message and exit
|
|
103
|
+
--days DAYS number of calendar days to analyze, including today (default: 14)
|
|
104
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/artificial_intelligence_usage.py
|
|
4
|
+
src/artificial_intelligence_usage.egg-info/PKG-INFO
|
|
5
|
+
src/artificial_intelligence_usage.egg-info/SOURCES.txt
|
|
6
|
+
src/artificial_intelligence_usage.egg-info/dependency_links.txt
|
|
7
|
+
src/artificial_intelligence_usage.egg-info/entry_points.txt
|
|
8
|
+
src/artificial_intelligence_usage.egg-info/top_level.txt
|
|
9
|
+
tests/test_artificial_intelligence_usage.py
|
|
10
|
+
tests/test_publish_pypi.py
|
artificial_intelligence_usage-0.0.1/src/artificial_intelligence_usage.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
artificial_intelligence_usage
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Analyze recent Codex sessions and report usage information."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from collections import Counter, defaultdict
|
|
9
|
+
from datetime import date, datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_DAYS = 14
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def positive_int(value: str) -> int:
|
|
18
|
+
"""Return value as a positive integer for command-line arguments."""
|
|
19
|
+
try:
|
|
20
|
+
number = int(value)
|
|
21
|
+
except ValueError as error:
|
|
22
|
+
raise argparse.ArgumentTypeError("must be an integer") from error
|
|
23
|
+
|
|
24
|
+
if number < 1:
|
|
25
|
+
raise argparse.ArgumentTypeError("must be at least 1")
|
|
26
|
+
|
|
27
|
+
return number
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_timestamp(value: str | int | float | None) -> str:
|
|
31
|
+
"""Format an ISO or Unix timestamp in the local timezone."""
|
|
32
|
+
try:
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
35
|
+
elif isinstance(value, (int, float)):
|
|
36
|
+
timestamp = datetime.fromtimestamp(value).astimezone()
|
|
37
|
+
else:
|
|
38
|
+
return "unknown"
|
|
39
|
+
|
|
40
|
+
return timestamp.astimezone().strftime("%Y-%m-%d %H:%M %Z")
|
|
41
|
+
except (OverflowError, OSError, ValueError):
|
|
42
|
+
return "unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def format_window(minutes: Any) -> str:
|
|
46
|
+
"""Return a compact label for a rate-limit window."""
|
|
47
|
+
if not isinstance(minutes, (int, float)):
|
|
48
|
+
return "Rate limit"
|
|
49
|
+
if minutes % (7 * 24 * 60) == 0:
|
|
50
|
+
return f"{minutes / (7 * 24 * 60):g}w limit"
|
|
51
|
+
if minutes % (24 * 60) == 0:
|
|
52
|
+
return f"{minutes / (24 * 60):g}d limit"
|
|
53
|
+
if minutes % 60 == 0:
|
|
54
|
+
return f"{minutes / 60:g}h limit"
|
|
55
|
+
return f"{minutes:g}m limit"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_status(session_file: Path | None) -> dict[str, Any]:
|
|
59
|
+
"""Read the latest status-related values from a Codex rollout."""
|
|
60
|
+
status: dict[str, Any] = {
|
|
61
|
+
"session": {},
|
|
62
|
+
"context": {},
|
|
63
|
+
"token_count": {},
|
|
64
|
+
"updated_at": None,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if session_file is None:
|
|
68
|
+
return status
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
with session_file.open("r", encoding="utf-8") as file_handle:
|
|
72
|
+
for line in file_handle:
|
|
73
|
+
try:
|
|
74
|
+
event = json.loads(line)
|
|
75
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
status["updated_at"] = event.get("timestamp") or status["updated_at"]
|
|
79
|
+
payload = event.get("payload") or {}
|
|
80
|
+
|
|
81
|
+
if event.get("type") == "session_meta":
|
|
82
|
+
status["session"] = payload
|
|
83
|
+
elif event.get("type") == "turn_context":
|
|
84
|
+
status["context"] = payload
|
|
85
|
+
elif (
|
|
86
|
+
event.get("type") == "event_msg"
|
|
87
|
+
and payload.get("type") == "token_count"
|
|
88
|
+
):
|
|
89
|
+
status["token_count"] = payload
|
|
90
|
+
except (OSError, UnicodeError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
return status
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def print_rate_limit(limit: Any, fallback_label: str) -> None:
|
|
97
|
+
"""Print one rate-limit window from a token-count event."""
|
|
98
|
+
if not isinstance(limit, dict):
|
|
99
|
+
print(f"{fallback_label + ':':<16} unknown")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
label = format_window(limit.get("window_minutes"))
|
|
103
|
+
used_percent = limit.get("used_percent")
|
|
104
|
+
remaining = (
|
|
105
|
+
max(0.0, 100.0 - float(used_percent))
|
|
106
|
+
if isinstance(used_percent, (int, float))
|
|
107
|
+
else None
|
|
108
|
+
)
|
|
109
|
+
availability = f"{remaining:g}% remaining" if remaining is not None else "unknown"
|
|
110
|
+
reset = format_timestamp(limit.get("resets_at"))
|
|
111
|
+
print(f"{label + ':':<16} {availability} (resets {reset})")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_remaining(limit: Any) -> str:
|
|
115
|
+
"""Return the remaining allowance for one rate-limit window."""
|
|
116
|
+
if not isinstance(limit, dict):
|
|
117
|
+
return "unknown"
|
|
118
|
+
|
|
119
|
+
used_percent = limit.get("used_percent")
|
|
120
|
+
if not isinstance(used_percent, (int, float)):
|
|
121
|
+
return "unknown"
|
|
122
|
+
|
|
123
|
+
remaining = min(100.0, max(0.0, 100.0 - float(used_percent)))
|
|
124
|
+
return f"{remaining:g}% remaining"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def session_rate_limits(session_file: Path) -> tuple[str, str]:
|
|
128
|
+
"""Return the 5-hour and weekly limits recorded by one session."""
|
|
129
|
+
token_count = read_status(session_file)["token_count"]
|
|
130
|
+
rate_limits = token_count.get("rate_limits") or {}
|
|
131
|
+
limits = [rate_limits.get("primary"), rate_limits.get("secondary")]
|
|
132
|
+
|
|
133
|
+
five_hour = next(
|
|
134
|
+
(
|
|
135
|
+
limit
|
|
136
|
+
for limit in limits
|
|
137
|
+
if isinstance(limit, dict) and limit.get("window_minutes") == 5 * 60
|
|
138
|
+
),
|
|
139
|
+
rate_limits.get("primary"),
|
|
140
|
+
)
|
|
141
|
+
weekly = next(
|
|
142
|
+
(
|
|
143
|
+
limit
|
|
144
|
+
for limit in limits
|
|
145
|
+
if isinstance(limit, dict)
|
|
146
|
+
and limit.get("window_minutes") == 7 * 24 * 60
|
|
147
|
+
),
|
|
148
|
+
rate_limits.get("secondary"),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return format_remaining(five_hour), format_remaining(weekly)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def print_status(session_file: Path | None) -> None:
|
|
155
|
+
"""Print a /status-like summary for the latest Codex session."""
|
|
156
|
+
status = read_status(session_file)
|
|
157
|
+
session = status["session"]
|
|
158
|
+
context = status["context"]
|
|
159
|
+
token_count = status["token_count"]
|
|
160
|
+
info = token_count.get("info") or {}
|
|
161
|
+
total_usage = info.get("total_token_usage") or {}
|
|
162
|
+
last_usage = info.get("last_token_usage") or {}
|
|
163
|
+
rate_limits = token_count.get("rate_limits") or {}
|
|
164
|
+
|
|
165
|
+
model = context.get("model") or "unknown"
|
|
166
|
+
effort = context.get("effort")
|
|
167
|
+
if effort:
|
|
168
|
+
model = f"{model} ({effort})"
|
|
169
|
+
|
|
170
|
+
sandbox = (context.get("sandbox_policy") or {}).get("type") or "unknown"
|
|
171
|
+
approval_policy = context.get("approval_policy") or {}
|
|
172
|
+
approval = next(iter(approval_policy), "unknown")
|
|
173
|
+
|
|
174
|
+
current_tokens = last_usage.get("total_tokens")
|
|
175
|
+
context_window = info.get("model_context_window")
|
|
176
|
+
if isinstance(current_tokens, (int, float)) and isinstance(
|
|
177
|
+
context_window, (int, float)
|
|
178
|
+
):
|
|
179
|
+
context_percent = min(100.0, current_tokens / context_window * 100)
|
|
180
|
+
context_value = (
|
|
181
|
+
f"{int(current_tokens):,} / {int(context_window):,} tokens "
|
|
182
|
+
f"({context_percent:.1f}% used)"
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
context_value = "unknown"
|
|
186
|
+
|
|
187
|
+
total_tokens = total_usage.get("total_tokens")
|
|
188
|
+
total_value = (
|
|
189
|
+
f"{int(total_tokens):,}"
|
|
190
|
+
if isinstance(total_tokens, (int, float))
|
|
191
|
+
else "unknown"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
print("Codex status (latest session)")
|
|
195
|
+
print("-" * 57)
|
|
196
|
+
print(f"{'Updated:':<16} {format_timestamp(status['updated_at'])}")
|
|
197
|
+
print(
|
|
198
|
+
f"{'Session:':<16} "
|
|
199
|
+
f"{session.get('session_id') or session.get('id') or 'unknown'}"
|
|
200
|
+
)
|
|
201
|
+
print(f"{'Codex CLI:':<16} {session.get('cli_version') or 'unknown'}")
|
|
202
|
+
print(f"{'Model:':<16} {model}")
|
|
203
|
+
print(
|
|
204
|
+
f"{'Workspace:':<16} "
|
|
205
|
+
f"{context.get('cwd') or session.get('cwd') or 'unknown'}"
|
|
206
|
+
)
|
|
207
|
+
print(f"{'Permissions:':<16} {sandbox} (approval: {approval})")
|
|
208
|
+
print(f"{'Context:':<16} {context_value}")
|
|
209
|
+
print(f"{'Session tokens:':<16} {total_value}")
|
|
210
|
+
print(f"{'Plan:':<16} {rate_limits.get('plan_type') or 'unknown'}")
|
|
211
|
+
print_rate_limit(rate_limits.get("primary"), "Primary limit")
|
|
212
|
+
print_rate_limit(rate_limits.get("secondary"), "Secondary limit")
|
|
213
|
+
print()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def analyze_session(session_file: Path) -> tuple[int, int]:
|
|
217
|
+
"""Return ``(user_turns, total_tokens)`` for one Codex session."""
|
|
218
|
+
turns = 0
|
|
219
|
+
total_tokens = 0
|
|
220
|
+
|
|
221
|
+
with session_file.open("r", encoding="utf-8") as file_handle:
|
|
222
|
+
for line in file_handle:
|
|
223
|
+
try:
|
|
224
|
+
event = json.loads(line)
|
|
225
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
payload = event.get("payload") or {}
|
|
229
|
+
|
|
230
|
+
if (
|
|
231
|
+
event.get("type") == "response_item"
|
|
232
|
+
and payload.get("type") == "message"
|
|
233
|
+
and payload.get("role") == "user"
|
|
234
|
+
):
|
|
235
|
+
turns += 1
|
|
236
|
+
|
|
237
|
+
if (
|
|
238
|
+
event.get("type") == "event_msg"
|
|
239
|
+
and payload.get("type") == "token_count"
|
|
240
|
+
):
|
|
241
|
+
info = payload.get("info") or {}
|
|
242
|
+
usage = info.get("total_token_usage") or {}
|
|
243
|
+
value = usage.get("total_tokens")
|
|
244
|
+
|
|
245
|
+
if isinstance(value, (int, float)):
|
|
246
|
+
total_tokens = max(total_tokens, int(value))
|
|
247
|
+
|
|
248
|
+
return turns, total_tokens
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
252
|
+
"""Build the command-line argument parser."""
|
|
253
|
+
parser = argparse.ArgumentParser(description="Analyze recent Codex sessions.")
|
|
254
|
+
parser.add_argument(
|
|
255
|
+
"--days",
|
|
256
|
+
type=positive_int,
|
|
257
|
+
default=DEFAULT_DAYS,
|
|
258
|
+
help=f"number of calendar days to analyze, including today (default: {DEFAULT_DAYS})",
|
|
259
|
+
)
|
|
260
|
+
return parser
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def run_report(sessions_dir: Path, days: int, today: date | None = None) -> int:
|
|
264
|
+
"""Analyze sessions and print the status and daily usage report."""
|
|
265
|
+
current_day = today or date.today()
|
|
266
|
+
first_day = current_day - timedelta(days=days - 1)
|
|
267
|
+
session_counts: Counter[str] = Counter()
|
|
268
|
+
turn_counts: defaultdict[str, list[int]] = defaultdict(list)
|
|
269
|
+
token_counts: Counter[str] = Counter()
|
|
270
|
+
last_session_by_date: dict[str, Path] = {}
|
|
271
|
+
|
|
272
|
+
all_session_files = list(sessions_dir.glob("*/*/*/rollout-*.jsonl"))
|
|
273
|
+
latest_session = (
|
|
274
|
+
max(all_session_files, key=lambda path: path.stat().st_mtime)
|
|
275
|
+
if all_session_files
|
|
276
|
+
else None
|
|
277
|
+
)
|
|
278
|
+
print_status(latest_session)
|
|
279
|
+
|
|
280
|
+
for session_file in all_session_files:
|
|
281
|
+
relative = session_file.relative_to(sessions_dir)
|
|
282
|
+
year, month, day = relative.parts[:3]
|
|
283
|
+
date_label = f"{year}-{month}-{day}"
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
session_date = date.fromisoformat(date_label)
|
|
287
|
+
except ValueError:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if not first_day <= session_date <= current_day:
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
turns, tokens = analyze_session(session_file)
|
|
294
|
+
session_counts[date_label] += 1
|
|
295
|
+
turn_counts[date_label].append(turns)
|
|
296
|
+
token_counts[date_label] += tokens
|
|
297
|
+
|
|
298
|
+
previous_last = last_session_by_date.get(date_label)
|
|
299
|
+
if previous_last is None or session_file.name > previous_last.name:
|
|
300
|
+
last_session_by_date[date_label] = session_file
|
|
301
|
+
|
|
302
|
+
if not session_counts:
|
|
303
|
+
print(f"No Codex sessions found in the last {days} days in {sessions_dir}")
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
print(
|
|
307
|
+
f"{'Date':<12} "
|
|
308
|
+
f"{'Sessions':>8} "
|
|
309
|
+
f"{'Turns':>8} "
|
|
310
|
+
f"{'Avg turns':>10} "
|
|
311
|
+
f"{'Tokens':>15} "
|
|
312
|
+
f"{'5h last session':>17} "
|
|
313
|
+
f"{'Weekly last session':>20}"
|
|
314
|
+
)
|
|
315
|
+
print("-" * 96)
|
|
316
|
+
|
|
317
|
+
for date_label in sorted(session_counts):
|
|
318
|
+
sessions = session_counts[date_label]
|
|
319
|
+
total_turns = sum(turn_counts[date_label])
|
|
320
|
+
avg_turns = total_turns / sessions
|
|
321
|
+
tokens = token_counts[date_label]
|
|
322
|
+
five_hour, weekly = session_rate_limits(last_session_by_date[date_label])
|
|
323
|
+
|
|
324
|
+
print(
|
|
325
|
+
f"{date_label:<12} "
|
|
326
|
+
f"{sessions:>8} "
|
|
327
|
+
f"{total_turns:>8} "
|
|
328
|
+
f"{avg_turns:>10.1f} "
|
|
329
|
+
f"{tokens:>15,} "
|
|
330
|
+
f"{five_hour:>17} "
|
|
331
|
+
f"{weekly:>20}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
total_sessions = sum(session_counts.values())
|
|
335
|
+
total_turns = sum(sum(turns) for turns in turn_counts.values())
|
|
336
|
+
total_tokens = sum(token_counts.values())
|
|
337
|
+
overall_average = total_turns / total_sessions
|
|
338
|
+
|
|
339
|
+
print("-" * 96)
|
|
340
|
+
print(
|
|
341
|
+
f"{'Total':<12} "
|
|
342
|
+
f"{total_sessions:>8} "
|
|
343
|
+
f"{total_turns:>8} "
|
|
344
|
+
f"{overall_average:>10.1f} "
|
|
345
|
+
f"{total_tokens:>15,} "
|
|
346
|
+
f"{'-':>17} "
|
|
347
|
+
f"{'-':>20}"
|
|
348
|
+
)
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def main(argv: list[str] | None = None) -> int:
|
|
353
|
+
"""Run the artificial-intelligence-usage command."""
|
|
354
|
+
args = build_parser().parse_args(argv)
|
|
355
|
+
return run_report(Path.home() / ".codex" / "sessions", args.days)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SRC = Path(__file__).resolve().parents[1] / "src"
|
|
13
|
+
sys.path.insert(0, str(SRC))
|
|
14
|
+
|
|
15
|
+
import artificial_intelligence_usage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ArtificialIntelligenceUsageTests(unittest.TestCase):
|
|
19
|
+
def write_session(
|
|
20
|
+
self,
|
|
21
|
+
sessions_dir: Path,
|
|
22
|
+
day: date,
|
|
23
|
+
name: str,
|
|
24
|
+
events: list[dict],
|
|
25
|
+
) -> Path:
|
|
26
|
+
target = sessions_dir / f"{day:%Y}" / f"{day:%m}" / f"{day:%d}"
|
|
27
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
session_file = target / f"rollout-{name}.jsonl"
|
|
29
|
+
session_file.write_text(
|
|
30
|
+
"\n".join(json.dumps(event) for event in events) + "\n",
|
|
31
|
+
encoding="utf-8",
|
|
32
|
+
)
|
|
33
|
+
return session_file
|
|
34
|
+
|
|
35
|
+
def token_event(
|
|
36
|
+
self,
|
|
37
|
+
tokens: int,
|
|
38
|
+
five_hour_used: int = 25,
|
|
39
|
+
weekly_used: int = 40,
|
|
40
|
+
) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"timestamp": "2026-10-04T10:00:00Z",
|
|
43
|
+
"type": "event_msg",
|
|
44
|
+
"payload": {
|
|
45
|
+
"type": "token_count",
|
|
46
|
+
"info": {
|
|
47
|
+
"total_token_usage": {"total_tokens": tokens},
|
|
48
|
+
"last_token_usage": {"total_tokens": 100},
|
|
49
|
+
"model_context_window": 1000,
|
|
50
|
+
},
|
|
51
|
+
"rate_limits": {
|
|
52
|
+
"plan_type": "team",
|
|
53
|
+
"primary": {
|
|
54
|
+
"window_minutes": 300,
|
|
55
|
+
"used_percent": five_hour_used,
|
|
56
|
+
"resets_at": 0,
|
|
57
|
+
},
|
|
58
|
+
"secondary": {
|
|
59
|
+
"window_minutes": 10080,
|
|
60
|
+
"used_percent": weekly_used,
|
|
61
|
+
"resets_at": 0,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def test_positive_int_accepts_positive_values(self):
|
|
68
|
+
self.assertEqual(artificial_intelligence_usage.positive_int("7"), 7)
|
|
69
|
+
|
|
70
|
+
def test_positive_int_rejects_zero_and_non_integer_values(self):
|
|
71
|
+
with self.assertRaisesRegex(Exception, "at least 1"):
|
|
72
|
+
artificial_intelligence_usage.positive_int("0")
|
|
73
|
+
with self.assertRaisesRegex(Exception, "must be an integer"):
|
|
74
|
+
artificial_intelligence_usage.positive_int("nope")
|
|
75
|
+
|
|
76
|
+
def test_analyze_session_ignores_malformed_lines_and_uses_maximum_tokens(self):
|
|
77
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
78
|
+
session_file = Path(directory) / "rollout-test.jsonl"
|
|
79
|
+
session_file.write_text(
|
|
80
|
+
"not-json\n"
|
|
81
|
+
+ json.dumps(
|
|
82
|
+
{
|
|
83
|
+
"type": "response_item",
|
|
84
|
+
"payload": {"type": "message", "role": "user"},
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
+ "\n"
|
|
88
|
+
+ json.dumps(self.token_event(120))
|
|
89
|
+
+ "\n"
|
|
90
|
+
+ json.dumps(self.token_event(95))
|
|
91
|
+
+ "\n",
|
|
92
|
+
encoding="utf-8",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self.assertEqual(
|
|
96
|
+
artificial_intelligence_usage.analyze_session(session_file),
|
|
97
|
+
(1, 120),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def test_session_rate_limits_are_selected_by_window(self):
|
|
101
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
102
|
+
session_file = Path(directory) / "rollout-test.jsonl"
|
|
103
|
+
event = self.token_event(100)
|
|
104
|
+
limits = event["payload"]["rate_limits"]
|
|
105
|
+
limits["primary"], limits["secondary"] = (
|
|
106
|
+
limits["secondary"],
|
|
107
|
+
limits["primary"],
|
|
108
|
+
)
|
|
109
|
+
session_file.write_text(json.dumps(event) + "\n", encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
self.assertEqual(
|
|
112
|
+
artificial_intelligence_usage.session_rate_limits(session_file),
|
|
113
|
+
("75% remaining", "60% remaining"),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def test_empty_directory_prints_status_and_no_sessions_message(self):
|
|
117
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
118
|
+
output = io.StringIO()
|
|
119
|
+
with contextlib.redirect_stdout(output):
|
|
120
|
+
result = artificial_intelligence_usage.run_report(
|
|
121
|
+
Path(directory), days=14, today=date(2026, 10, 4)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self.assertEqual(result, 0)
|
|
125
|
+
self.assertIn("Codex status (latest session)", output.getvalue())
|
|
126
|
+
self.assertIn("No Codex sessions found in the last 14 days", output.getvalue())
|
|
127
|
+
|
|
128
|
+
def test_report_aggregates_sessions_turns_tokens_and_limits(self):
|
|
129
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
130
|
+
sessions_dir = Path(directory)
|
|
131
|
+
session_meta = {
|
|
132
|
+
"timestamp": "2026-10-04T09:00:00Z",
|
|
133
|
+
"type": "session_meta",
|
|
134
|
+
"payload": {"id": "session-1", "cli_version": "1.2.3"},
|
|
135
|
+
}
|
|
136
|
+
turn_context = {
|
|
137
|
+
"timestamp": "2026-10-04T09:01:00Z",
|
|
138
|
+
"type": "turn_context",
|
|
139
|
+
"payload": {
|
|
140
|
+
"model": "gpt-test",
|
|
141
|
+
"effort": "high",
|
|
142
|
+
"cwd": "/work",
|
|
143
|
+
"sandbox_policy": {"type": "workspace-write"},
|
|
144
|
+
"approval_policy": {"on-request": {}},
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
user_turn = {
|
|
148
|
+
"type": "response_item",
|
|
149
|
+
"payload": {"type": "message", "role": "user"},
|
|
150
|
+
}
|
|
151
|
+
self.write_session(
|
|
152
|
+
sessions_dir,
|
|
153
|
+
date(2026, 10, 4),
|
|
154
|
+
"01",
|
|
155
|
+
[session_meta, turn_context, user_turn, self.token_event(100)],
|
|
156
|
+
)
|
|
157
|
+
self.write_session(
|
|
158
|
+
sessions_dir,
|
|
159
|
+
date(2026, 10, 4),
|
|
160
|
+
"02",
|
|
161
|
+
[user_turn, user_turn, self.token_event(250, 30, 45)],
|
|
162
|
+
)
|
|
163
|
+
self.write_session(
|
|
164
|
+
sessions_dir,
|
|
165
|
+
date(2026, 9, 1),
|
|
166
|
+
"old",
|
|
167
|
+
[user_turn, self.token_event(999)],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
output = io.StringIO()
|
|
171
|
+
with contextlib.redirect_stdout(output):
|
|
172
|
+
result = artificial_intelligence_usage.run_report(
|
|
173
|
+
sessions_dir, days=2, today=date(2026, 10, 4)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
report = output.getvalue()
|
|
177
|
+
self.assertEqual(result, 0)
|
|
178
|
+
self.assertRegex(report, r"2026-10-04\s+2\s+3\s+1\.5\s+350")
|
|
179
|
+
self.assertIn("70% remaining", report)
|
|
180
|
+
self.assertIn("55% remaining", report)
|
|
181
|
+
daily_report = report[report.index("Date") :]
|
|
182
|
+
self.assertNotIn("999", daily_report)
|
|
183
|
+
|
|
184
|
+
def test_main_can_be_called_repeatedly(self):
|
|
185
|
+
with patch.object(
|
|
186
|
+
artificial_intelligence_usage, "run_report", return_value=0
|
|
187
|
+
) as run_report:
|
|
188
|
+
self.assertEqual(artificial_intelligence_usage.main(["--days", "3"]), 0)
|
|
189
|
+
self.assertEqual(artificial_intelligence_usage.main(["--days", "5"]), 0)
|
|
190
|
+
|
|
191
|
+
self.assertEqual(run_report.call_count, 2)
|
|
192
|
+
self.assertEqual(run_report.call_args_list[0].args[1], 3)
|
|
193
|
+
self.assertEqual(run_report.call_args_list[1].args[1], 5)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
unittest.main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import stat
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PublishPypiScriptTests(unittest.TestCase):
|
|
11
|
+
def run_script_with_fake_python(
|
|
12
|
+
self,
|
|
13
|
+
fake_python_body: str,
|
|
14
|
+
create_dist: bool = True,
|
|
15
|
+
create_artifact: bool = True,
|
|
16
|
+
):
|
|
17
|
+
source_script = Path(__file__).resolve().parents[1] / "publish-pypi.sh"
|
|
18
|
+
|
|
19
|
+
directory = tempfile.TemporaryDirectory()
|
|
20
|
+
self.addCleanup(directory.cleanup)
|
|
21
|
+
root = Path(directory.name)
|
|
22
|
+
script = root / "publish-pypi.sh"
|
|
23
|
+
shutil.copy(source_script, script)
|
|
24
|
+
script.chmod(script.stat().st_mode | stat.S_IXUSR)
|
|
25
|
+
|
|
26
|
+
if create_dist:
|
|
27
|
+
dist = root / "dist"
|
|
28
|
+
dist.mkdir()
|
|
29
|
+
if create_artifact:
|
|
30
|
+
(dist / "package-0.1.0.tar.gz").write_text(
|
|
31
|
+
"artifact", encoding="utf-8"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
bin_dir = root / "bin"
|
|
35
|
+
bin_dir.mkdir()
|
|
36
|
+
fake_python = bin_dir / "python3"
|
|
37
|
+
fake_python.write_text(fake_python_body, encoding="utf-8")
|
|
38
|
+
fake_python.chmod(fake_python.stat().st_mode | stat.S_IXUSR)
|
|
39
|
+
|
|
40
|
+
env = os.environ.copy()
|
|
41
|
+
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
|
|
42
|
+
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
[str(script)],
|
|
45
|
+
cwd=root,
|
|
46
|
+
env=env,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
check=False,
|
|
50
|
+
)
|
|
51
|
+
return result, root
|
|
52
|
+
|
|
53
|
+
def test_reports_already_published_when_twine_returns_duplicate_400(self):
|
|
54
|
+
result, _root = self.run_script_with_fake_python(
|
|
55
|
+
"""#!/usr/bin/env bash
|
|
56
|
+
set -euo pipefail
|
|
57
|
+
if [[ "$1" == "-c" ]]; then exit 0; fi
|
|
58
|
+
if [[ "$1" == "-m" && "$2" == "twine" && "$3" == "upload" ]]; then
|
|
59
|
+
echo "HTTPError: 400 Bad Request" >&2
|
|
60
|
+
echo "File already exists" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
exit 2
|
|
64
|
+
"""
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
output = result.stdout + result.stderr
|
|
68
|
+
self.assertEqual(result.returncode, 1)
|
|
69
|
+
self.assertIn("HTTP 400", output)
|
|
70
|
+
self.assertIn("already been published", output)
|
|
71
|
+
|
|
72
|
+
def test_requires_dist_directory(self):
|
|
73
|
+
result, _root = self.run_script_with_fake_python(
|
|
74
|
+
"""#!/usr/bin/env bash
|
|
75
|
+
set -euo pipefail
|
|
76
|
+
if [[ "$1" == "-c" ]]; then exit 0; fi
|
|
77
|
+
exit 2
|
|
78
|
+
""",
|
|
79
|
+
create_dist=False,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.assertEqual(result.returncode, 1)
|
|
83
|
+
self.assertIn("dist/ was not found", result.stderr)
|
|
84
|
+
|
|
85
|
+
def test_requires_dist_artifacts(self):
|
|
86
|
+
result, _root = self.run_script_with_fake_python(
|
|
87
|
+
"""#!/usr/bin/env bash
|
|
88
|
+
set -euo pipefail
|
|
89
|
+
if [[ "$1" == "-c" ]]; then exit 0; fi
|
|
90
|
+
exit 2
|
|
91
|
+
""",
|
|
92
|
+
create_artifact=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self.assertEqual(result.returncode, 1)
|
|
96
|
+
self.assertIn("dist/ is empty", result.stderr)
|
|
97
|
+
|
|
98
|
+
def test_uploads_generated_dist_artifacts(self):
|
|
99
|
+
result, root = self.run_script_with_fake_python(
|
|
100
|
+
"""#!/usr/bin/env bash
|
|
101
|
+
set -euo pipefail
|
|
102
|
+
if [[ "$1" == "-c" ]]; then exit 0; fi
|
|
103
|
+
if [[ "$1" == "-m" && "$2" == "twine" && "$3" == "upload" ]]; then
|
|
104
|
+
printf '%s\n' "$@" > upload-args.txt
|
|
105
|
+
echo "Uploaded"
|
|
106
|
+
exit 0
|
|
107
|
+
fi
|
|
108
|
+
exit 2
|
|
109
|
+
"""
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
self.assertEqual(result.returncode, 0)
|
|
113
|
+
self.assertIn("Uploaded", result.stdout)
|
|
114
|
+
upload_args = (root / "upload-args.txt").read_text(
|
|
115
|
+
encoding="utf-8"
|
|
116
|
+
).splitlines()
|
|
117
|
+
self.assertEqual(upload_args[:3], ["-m", "twine", "upload"])
|
|
118
|
+
self.assertIn("dist/package-0.1.0.tar.gz", upload_args)
|
|
119
|
+
self.assertIn("--verbose", upload_args)
|
|
120
|
+
|
|
121
|
+
def test_generic_twine_error_does_not_report_already_published(self):
|
|
122
|
+
result, _root = self.run_script_with_fake_python(
|
|
123
|
+
"""#!/usr/bin/env bash
|
|
124
|
+
set -euo pipefail
|
|
125
|
+
if [[ "$1" == "-c" ]]; then exit 0; fi
|
|
126
|
+
if [[ "$1" == "-m" && "$2" == "twine" && "$3" == "upload" ]]; then
|
|
127
|
+
echo "HTTPError: 500 Server Error" >&2
|
|
128
|
+
exit 1
|
|
129
|
+
fi
|
|
130
|
+
exit 2
|
|
131
|
+
"""
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
output = result.stdout + result.stderr
|
|
135
|
+
self.assertEqual(result.returncode, 1)
|
|
136
|
+
self.assertIn("500 Server Error", output)
|
|
137
|
+
self.assertNotIn("already been published", output)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
unittest.main()
|