cemi-cli 0.1.1__py3-none-any.whl

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.
cemi/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CEMI CLI — authenticate, create runs, and stream metrics."""
2
+
3
+ __version__ = "0.1.1"
cemi/api.py ADDED
@@ -0,0 +1,49 @@
1
+ """CEMI backend API client for CLI.
2
+
3
+ Retained for future cloud work, but not surfaced in the closed-beta CLI.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ import requests
11
+
12
+
13
+ def start_run(
14
+ api_base: str,
15
+ access_token: str,
16
+ project: str | None,
17
+ name: str | None,
18
+ ) -> dict:
19
+ url = f"{api_base.rstrip('/')}/runs/start"
20
+ payload = {"project": project, "name": name}
21
+ r = requests.post(
22
+ url,
23
+ json=payload,
24
+ headers={"Authorization": f"Bearer {access_token}"},
25
+ timeout=30,
26
+ )
27
+ r.raise_for_status()
28
+ return r.json()
29
+
30
+
31
+ def send_run_record(
32
+ api_base: str,
33
+ run_id: str,
34
+ run_token: str,
35
+ record: dict[str, Any],
36
+ ) -> dict:
37
+ """Send a run_record event to the ingestion API (future cloud path)."""
38
+ url = f"{api_base.rstrip('/')}/runs/{run_id}/events"
39
+ r = requests.post(
40
+ url,
41
+ json=record,
42
+ headers={
43
+ "Authorization": f"Bearer {run_token}",
44
+ "Content-Type": "application/json",
45
+ },
46
+ timeout=30,
47
+ )
48
+ r.raise_for_status()
49
+ return r.json() if r.text else {}
cemi/auth.py ADDED
@@ -0,0 +1,107 @@
1
+ """MSAL device-code auth and token cache for CEMI CLI.
2
+
3
+ Retained for future cloud work, but not surfaced in the closed-beta CLI.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ import msal
12
+
13
+
14
+ ALLOWED_DOMAIN = os.environ.get("CEMI_ALLOWED_EMAIL_DOMAIN", "").strip().lower()
15
+
16
+
17
+ def _load_cache(cache_path: Path) -> msal.SerializableTokenCache:
18
+ cache = msal.SerializableTokenCache()
19
+ if cache_path.exists():
20
+ cache.deserialize(cache_path.read_text())
21
+ return cache
22
+
23
+
24
+ def _save_cache(cache_path: Path, cache: msal.SerializableTokenCache) -> None:
25
+ if cache.has_state_changed:
26
+ cache_path.write_text(cache.serialize())
27
+ try:
28
+ os.chmod(cache_path, 0o600)
29
+ except OSError:
30
+ pass
31
+
32
+
33
+ def _build_app(
34
+ client_id: str,
35
+ authority: str,
36
+ cache: msal.SerializableTokenCache,
37
+ ) -> msal.PublicClientApplication:
38
+ return msal.PublicClientApplication(
39
+ client_id=client_id,
40
+ authority=authority,
41
+ token_cache=cache,
42
+ )
43
+
44
+
45
+ def ensure_domain(result: dict) -> None:
46
+ if not ALLOWED_DOMAIN:
47
+ return
48
+ claims = result.get("id_token_claims") or {}
49
+ username = (claims.get("preferred_username") or claims.get("upn") or "").lower()
50
+ if not username.endswith("@" + ALLOWED_DOMAIN):
51
+ raise RuntimeError(
52
+ f"Only @{ALLOWED_DOMAIN} accounts are allowed (got: {username or 'unknown'})."
53
+ )
54
+
55
+
56
+ def device_code_login(
57
+ client_id: str,
58
+ authority: str,
59
+ scopes: list[str],
60
+ cache_path: Path,
61
+ ) -> dict:
62
+ import json
63
+
64
+ cache = _load_cache(cache_path)
65
+ app = _build_app(client_id, authority, cache)
66
+
67
+ flow = app.initiate_device_flow(scopes=scopes)
68
+ if "user_code" not in flow:
69
+ raise RuntimeError(f"Failed to start device flow: {json.dumps(flow, indent=2)}")
70
+
71
+ print(flow["message"], flush=True)
72
+ result = app.acquire_token_by_device_flow(flow)
73
+
74
+ if "access_token" not in result:
75
+ err = result.get("error", "")
76
+ desc = result.get("error_description", "")
77
+ if "AADSTS7000218" in desc or "client_assertion" in desc or "client_secret" in desc:
78
+ raise RuntimeError(
79
+ "Auth failed: this app registration requires a client secret. "
80
+ "For CLI device-code flow, in Azure Portal go to App registration → Authentication → "
81
+ "Advanced settings → set 'Allow public client flows' to Yes, then try again."
82
+ )
83
+ raise RuntimeError(f"Auth failed: {err} - {desc}")
84
+
85
+ ensure_domain(result)
86
+ _save_cache(cache_path, cache)
87
+ return result
88
+
89
+
90
+ def acquire_token_silent(
91
+ client_id: str,
92
+ authority: str,
93
+ scopes: list[str],
94
+ cache_path: Path,
95
+ ) -> str | None:
96
+ cache = _load_cache(cache_path)
97
+ app = _build_app(client_id, authority, cache)
98
+
99
+ accounts = app.get_accounts()
100
+ if not accounts:
101
+ return None
102
+
103
+ result = app.acquire_token_silent(scopes=scopes, account=accounts[0])
104
+ if result and "access_token" in result:
105
+ _save_cache(cache_path, cache)
106
+ return result["access_token"]
107
+ return None