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 +3 -0
- cemi/api.py +49 -0
- cemi/auth.py +107 -0
- cemi/cli.py +629 -0
- cemi/config.py +78 -0
- cemi/contract.py +474 -0
- cemi/decision_layer.py +353 -0
- cemi/defaults.py +16 -0
- cemi/examples/__init__.py +3 -0
- cemi/examples/test_writer.py +50 -0
- cemi/local_server.py +704 -0
- cemi/workspace_dist/assets/bc28cd3b23be4b191421f0ead27bb2b9b7c23ff5-BAdrl-eN.png +0 -0
- cemi/workspace_dist/assets/index-APOCwJvs.js +234 -0
- cemi/workspace_dist/assets/index-S1cVBpyp.css +1 -0
- cemi/workspace_dist/index.html +45 -0
- cemi/writer.py +1239 -0
- cemi_cli-0.1.1.dist-info/METADATA +362 -0
- cemi_cli-0.1.1.dist-info/RECORD +21 -0
- cemi_cli-0.1.1.dist-info/WHEEL +5 -0
- cemi_cli-0.1.1.dist-info/entry_points.txt +2 -0
- cemi_cli-0.1.1.dist-info/top_level.txt +1 -0
cemi/__init__.py
ADDED
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
|