Skip to content

Commit 09fa462

Browse files
authored
ci(api): codecarbon test ci pushes emissions to api (#1123)
* ci(api): codecarbon test ci pushes emissions to api * trigger it * fix: secrets access * fix: cli uses hierarchical config as emissions tracker * tests: add cli tests
1 parent 46ac6dd commit 09fa462

5 files changed

Lines changed: 220 additions & 20 deletions

File tree

.github/workflows/test-package.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ on:
99
- "tests/**"
1010
- "pyproject.toml"
1111
- "uv.lock"
12+
- ".github/workflows/test-package.yml"
1213
push:
1314
branches: [master]
1415

1516
jobs:
1617
python-test:
1718
runs-on: ubuntu-24.04
19+
env:
20+
CODECARBON_API_KEY: ${{ secrets.CODECARBON_API_TOKEN }}
21+
CODECARBON_EXPERIMENT_ID: ${{ secrets.CODECARBON_EXPERIMENT_ID_TEST_PACKAGE }}
1822
strategy:
1923
matrix:
2024
python-version: ["3.12", "3.13", "3.14"]
@@ -29,7 +33,7 @@ jobs:
2933
- name: Install dependencies
3034
run: uv sync --python ${{ matrix.python-version }}
3135
- name: Test package
32-
run: uv run --python ${{ matrix.python-version }} task test-coverage
36+
run: uv run --python ${{ matrix.python-version }} codecarbon monitor -- uv run --python ${{ matrix.python-version }} task test-coverage
3337
- name: Upload coverage to Codecov
3438
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
3539
with:

codecarbon/cli/cli_utils.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import typer
66
from rich.prompt import Confirm
77

8+
from codecarbon.core.config import get_hierarchical_config
9+
810

911
def get_config(path: Optional[Path] = None):
1012
p = path or Path.cwd().resolve() / ".codecarbon.config"
@@ -37,22 +39,16 @@ def get_api_endpoint(path: Optional[Path] = None):
3739
return "https://api.codecarbon.io"
3840

3941

40-
def get_existing_local_exp_id(path: Optional[Path] = None):
41-
p = path or Path.cwd().resolve() / ".codecarbon.config"
42-
if p.exists():
43-
existing_path = p
44-
else:
45-
existing_path = Path("~/.codecarbon.config").expanduser()
46-
return _get_local_exp_id(existing_path)
47-
48-
49-
def _get_local_exp_id(p: Optional[Path] = None):
50-
config = configparser.ConfigParser()
51-
config.read(str(p))
52-
if "codecarbon" in config.sections():
53-
d = dict(config["codecarbon"])
54-
if "experiment_id" in d:
55-
return d["experiment_id"]
42+
def get_existing_exp_id() -> Optional[str]:
43+
"""
44+
Return experiment_id resolved from the same hierarchical config strategy
45+
used by EmissionsTracker (global file, local file, then CODECARBON_* env).
46+
"""
47+
try:
48+
conf = get_hierarchical_config()
49+
except KeyError:
50+
return None
51+
return conf.get("experiment_id")
5652

5753

5854
def write_local_exp_id(exp_id, path: Optional[Path] = None):

codecarbon/cli/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
create_new_config_file,
1919
get_api_endpoint,
2020
get_config,
21-
get_existing_local_exp_id,
21+
get_existing_exp_id,
2222
overwrite_local_config,
2323
)
2424
from codecarbon.cli.monitor import run_and_monitor
@@ -356,10 +356,10 @@ def monitor(
356356
"region": region,
357357
}
358358
else:
359-
experiment_id = get_existing_local_exp_id()
359+
experiment_id = get_existing_exp_id()
360360
if api and experiment_id is None:
361361
print(
362-
"ERROR: No experiment id, call 'codecarbon config' first. Or run in offline mode with `--offline --country-iso-code FRA` flag if you don't want to connect to the API.",
362+
"ERROR: No experiment id. Set CODECARBON_EXPERIMENT_ID, call 'codecarbon config' first, or run in offline mode with `--offline --country-iso-code FRA`.",
363363
file=sys.stderr,
364364
)
365365
raise typer.Exit(1)

tests/cli/test_cli_main.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Tests for the CodeCarbon CLI main function."""
22

3+
from types import SimpleNamespace
4+
5+
import pytest
36
from typer.testing import CliRunner
47

58
from codecarbon.cli import main as cli_main
@@ -112,3 +115,183 @@ def fake_get_access_token():
112115
captured = capsys.readouterr()
113116
assert "Could not validate remote configuration details" in captured.out
114117
assert "Not able to retrieve the access token" in captured.out
118+
119+
120+
def test_main_exits_with_error_when_command_raises(monkeypatch, capsys):
121+
def fake_cli():
122+
raise RuntimeError("boom")
123+
124+
monkeypatch.setattr(cli_main, "codecarbon", fake_cli)
125+
126+
with pytest.raises(SystemExit) as exc_info:
127+
cli_main.main()
128+
129+
captured = capsys.readouterr()
130+
assert exc_info.value.code == 1
131+
assert "Error:" in captured.out
132+
assert "boom" in captured.out
133+
134+
135+
def test_login_calls_authorize_and_auth_check(monkeypatch):
136+
calls = {"authorize": 0, "set_token": None, "check_auth": 0}
137+
138+
class FakeApiClient:
139+
def __init__(self, endpoint_url=None):
140+
self.endpoint_url = endpoint_url
141+
142+
def set_access_token(self, token):
143+
calls["set_token"] = token
144+
145+
def check_auth(self):
146+
calls["check_auth"] += 1
147+
148+
monkeypatch.setattr(cli_main, "ApiClient", FakeApiClient)
149+
monkeypatch.setattr(
150+
cli_main,
151+
"authorize",
152+
lambda: calls.__setitem__("authorize", calls["authorize"] + 1),
153+
)
154+
monkeypatch.setattr(cli_main, "get_access_token", lambda: "login-token")
155+
156+
runner = CliRunner()
157+
result = runner.invoke(cli_main.codecarbon, ["login"])
158+
assert result.exit_code == 0
159+
assert calls["authorize"] == 1
160+
assert calls["set_token"] == "login-token"
161+
assert calls["check_auth"] == 1
162+
163+
164+
def test_get_api_key_uses_bearer_token(monkeypatch):
165+
captured = {}
166+
167+
class FakeResponse:
168+
def json(self):
169+
return {"token": "project-api-token"}
170+
171+
def fake_post(url, json, headers):
172+
captured["url"] = url
173+
captured["json"] = json
174+
captured["headers"] = headers
175+
return FakeResponse()
176+
177+
monkeypatch.setattr(cli_main, "get_access_token", lambda: "access-token")
178+
monkeypatch.setattr(cli_main.requests, "post", fake_post)
179+
180+
token = cli_main.get_api_key("proj-123")
181+
assert token == "project-api-token"
182+
assert captured["url"].endswith("/projects/proj-123/api-tokens")
183+
assert captured["json"]["project_id"] == "proj-123"
184+
assert captured["headers"]["Authorization"] == "Bearer access-token"
185+
186+
187+
def test_get_token_command_prints_token(monkeypatch):
188+
monkeypatch.setattr(cli_main, "get_api_key", lambda project_id: "abc123")
189+
runner = CliRunner()
190+
result = runner.invoke(cli_main.codecarbon, ["get-token", "proj-id"])
191+
assert result.exit_code == 0
192+
assert "Your token: abc123" in result.output
193+
194+
195+
def test_show_config_prints_missing_project_and_experiment(
196+
monkeypatch, tmp_path, capsys
197+
):
198+
class FakeApiClient:
199+
def __init__(self, endpoint_url=None):
200+
self.endpoint_url = endpoint_url
201+
202+
def set_access_token(self, token):
203+
self.token = token
204+
205+
def get_organization(self, organization_id):
206+
return {"id": organization_id}
207+
208+
def get_project(self, project_id):
209+
return {"id": project_id}
210+
211+
def get_experiment(self, experiment_id):
212+
return {"id": experiment_id}
213+
214+
monkeypatch.setattr(cli_main, "ApiClient", FakeApiClient)
215+
monkeypatch.setattr(cli_main, "get_access_token", lambda: "fake-token")
216+
monkeypatch.setattr(
217+
cli_main, "get_api_endpoint", lambda path: "https://api.codecarbon.io"
218+
)
219+
220+
monkeypatch.setattr(
221+
cli_main,
222+
"get_config",
223+
lambda path: {
224+
"api_endpoint": "https://api.codecarbon.io",
225+
"organization_id": "org-id",
226+
},
227+
)
228+
cli_main.show_config(tmp_path / ".codecarbon.config")
229+
captured = capsys.readouterr()
230+
assert "No project_id in config" in captured.out
231+
232+
monkeypatch.setattr(
233+
cli_main,
234+
"get_config",
235+
lambda path: {
236+
"api_endpoint": "https://api.codecarbon.io",
237+
"organization_id": "org-id",
238+
"project_id": "project-id",
239+
},
240+
)
241+
cli_main.show_config(tmp_path / ".codecarbon.config")
242+
captured = capsys.readouterr()
243+
assert "No experiment_id in config" in captured.out
244+
245+
246+
def test_monitor_online_requires_experiment_id(monkeypatch):
247+
monkeypatch.setattr(cli_main, "get_existing_exp_id", lambda: None)
248+
runner = CliRunner()
249+
result = runner.invoke(cli_main.codecarbon, ["monitor"])
250+
assert result.exit_code == 1
251+
assert "No experiment id" in result.output
252+
253+
254+
def test_monitor_offline_initializes_offline_tracker(monkeypatch):
255+
calls = {"kwargs": None, "started": 0}
256+
257+
class FakeOfflineTracker:
258+
def __init__(self, **kwargs):
259+
calls["kwargs"] = kwargs
260+
self._another_instance_already_running = True
261+
262+
def start(self):
263+
calls["started"] += 1
264+
265+
def stop(self):
266+
return None
267+
268+
monkeypatch.setattr(cli_main, "OfflineEmissionsTracker", FakeOfflineTracker)
269+
monkeypatch.setattr(cli_main.signal, "signal", lambda *args, **kwargs: None)
270+
271+
runner = CliRunner()
272+
result = runner.invoke(
273+
cli_main.codecarbon,
274+
["monitor", "--offline", "--country-iso-code", "FRA", "--region", "IDF"],
275+
)
276+
assert result.exit_code == 0
277+
assert calls["started"] == 1
278+
assert calls["kwargs"]["country_iso_code"] == "FRA"
279+
assert calls["kwargs"]["region"] == "IDF"
280+
281+
282+
def test_monitor_delegates_to_run_and_monitor_with_extra_args(monkeypatch):
283+
captured = {}
284+
285+
def fake_run_and_monitor(ctx, **kwargs):
286+
captured["args"] = list(ctx.args)
287+
captured["kwargs"] = kwargs
288+
return "ok"
289+
290+
monkeypatch.setattr(cli_main, "run_and_monitor", fake_run_and_monitor)
291+
monkeypatch.setattr(cli_main, "get_existing_exp_id", lambda: "exp-1")
292+
293+
ctx = SimpleNamespace(args=["python", "train.py"])
294+
result = cli_main.monitor(ctx=ctx, api=False)
295+
assert result == "ok"
296+
assert captured["args"] == ["python", "train.py"]
297+
assert captured["kwargs"]["save_to_api"] is False

tests/test_config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@
1717

1818

1919
class TestConfig(unittest.TestCase):
20+
def setUp(self):
21+
self._original_environ = os.environ.copy()
22+
for key in [
23+
"CODECARBON_API_KEY",
24+
"CODECARBON_EXPERIMENT_ID",
25+
"CODECARBON_API_ENDPOINT",
26+
"codecarbon_api_key",
27+
"codecarbon_experiment_id",
28+
"codecarbon_api_endpoint",
29+
]:
30+
os.environ.pop(key, None)
31+
os.environ.setdefault("CODECARBON_ALLOW_MULTIPLE_RUNS", "True")
32+
33+
def tearDown(self):
34+
os.environ.clear()
35+
os.environ.update(self._original_environ)
36+
2037
def test_clean_env_key(self):
2138
for key in [1, None, 0.2, [], set()]:
2239
with self.assertRaises(AssertionError):

0 commit comments

Comments
 (0)