proxyml 0.9.0__tar.gz → 0.10.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.
- {proxyml-0.9.0 → proxyml-0.10.0}/PKG-INFO +5 -1
- {proxyml-0.9.0 → proxyml-0.10.0}/README.md +4 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/pyproject.toml +1 -1
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml/__init__.py +4 -0
- proxyml-0.10.0/src/proxyml/ci.py +147 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml/client.py +80 -4
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml/local/challenger.py +12 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml.egg-info/PKG-INFO +5 -1
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml.egg-info/SOURCES.txt +2 -0
- proxyml-0.10.0/tests/test_ci.py +133 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/tests/test_client.py +78 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/LICENSE +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/setup.cfg +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml/local/__init__.py +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml/schema_builder.py +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml.egg-info/dependency_links.txt +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml.egg-info/requires.txt +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/src/proxyml.egg-info/top_level.txt +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/tests/test_dependency_boundaries.py +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/tests/test_local_challenger.py +0 -0
- {proxyml-0.9.0 → proxyml-0.10.0}/tests/test_schema_builder.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyml
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Python SDK for calling the ProxyML API
|
|
5
5
|
Author-email: ProxyML <contact@proxyml.ai>
|
|
6
6
|
License: Apache License
|
|
@@ -333,6 +333,9 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
|
|
|
333
333
|
| `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
|
|
334
334
|
| `proxyml.local.train_challenger(df, target, schema, ...)` | Train a challenger model locally — no API round-trip (`pip install 'proxyml[local]'`) |
|
|
335
335
|
| `proxyml.local.train_auto_challenger(data, target_col, ...)` | Load data + train a local challenger in one call |
|
|
336
|
+
| `upload_challenger(payload, ci_token=None)` | Upload a challenger comparison headlessly, e.g. from CI (`PROXYML_CI_TOKEN`, not `PROXYML_API_KEY`) |
|
|
337
|
+
| `get_challenger_summary(ci_token=None)` | Fetch a CI-ready summary of the latest challenger comparison |
|
|
338
|
+
| `proxyml.ci.post_challenger_comment(...)` | Post/update a sticky PR comment with the challenger comparison |
|
|
336
339
|
|
|
337
340
|
Full documentation: [`docs/api.md`](docs/api.md)
|
|
338
341
|
|
|
@@ -344,6 +347,7 @@ Full documentation: [`docs/api.md`](docs/api.md)
|
|
|
344
347
|
- [`examples/multiclass_example.py`](examples/multiclass_example.py) — Multi-class classification with per-class feature importances
|
|
345
348
|
- [`examples/testing_example.py`](examples/testing_example.py) — Using a surrogate as a reference model in CI
|
|
346
349
|
- [`examples/surrogate_export_example.py`](examples/surrogate_export_example.py) — Exporting a surrogate and reproducing its predictions locally
|
|
350
|
+
- [`examples/ci_challenger_example.py`](examples/ci_challenger_example.py) + [`examples/github_actions_challenger.yml`](examples/github_actions_challenger.yml) — Training a challenger and posting the comparison as a PR comment from CI
|
|
347
351
|
|
|
348
352
|
## License
|
|
349
353
|
|
|
@@ -111,6 +111,9 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
|
|
|
111
111
|
| `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
|
|
112
112
|
| `proxyml.local.train_challenger(df, target, schema, ...)` | Train a challenger model locally — no API round-trip (`pip install 'proxyml[local]'`) |
|
|
113
113
|
| `proxyml.local.train_auto_challenger(data, target_col, ...)` | Load data + train a local challenger in one call |
|
|
114
|
+
| `upload_challenger(payload, ci_token=None)` | Upload a challenger comparison headlessly, e.g. from CI (`PROXYML_CI_TOKEN`, not `PROXYML_API_KEY`) |
|
|
115
|
+
| `get_challenger_summary(ci_token=None)` | Fetch a CI-ready summary of the latest challenger comparison |
|
|
116
|
+
| `proxyml.ci.post_challenger_comment(...)` | Post/update a sticky PR comment with the challenger comparison |
|
|
114
117
|
|
|
115
118
|
Full documentation: [`docs/api.md`](docs/api.md)
|
|
116
119
|
|
|
@@ -122,6 +125,7 @@ Full documentation: [`docs/api.md`](docs/api.md)
|
|
|
122
125
|
- [`examples/multiclass_example.py`](examples/multiclass_example.py) — Multi-class classification with per-class feature importances
|
|
123
126
|
- [`examples/testing_example.py`](examples/testing_example.py) — Using a surrogate as a reference model in CI
|
|
124
127
|
- [`examples/surrogate_export_example.py`](examples/surrogate_export_example.py) — Exporting a surrogate and reproducing its predictions locally
|
|
128
|
+
- [`examples/ci_challenger_example.py`](examples/ci_challenger_example.py) + [`examples/github_actions_challenger.yml`](examples/github_actions_challenger.yml) — Training a challenger and posting the comparison as a PR comment from CI
|
|
125
129
|
|
|
126
130
|
## License
|
|
127
131
|
|
|
@@ -26,6 +26,8 @@ from proxyml.client import (
|
|
|
26
26
|
explain_local,
|
|
27
27
|
explain_local_batch,
|
|
28
28
|
update_model,
|
|
29
|
+
upload_challenger,
|
|
30
|
+
get_challenger_summary,
|
|
29
31
|
)
|
|
30
32
|
from proxyml.schema_builder import get_schema
|
|
31
33
|
|
|
@@ -58,5 +60,7 @@ __all__ = [
|
|
|
58
60
|
"explain_local",
|
|
59
61
|
"explain_local_batch",
|
|
60
62
|
"update_model",
|
|
63
|
+
"upload_challenger",
|
|
64
|
+
"get_challenger_summary",
|
|
61
65
|
"get_schema",
|
|
62
66
|
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Post a champion/challenger comparison to a GitHub pull request from CI.
|
|
2
|
+
|
|
3
|
+
Typical use in a GitHub Actions job, after training and uploading a
|
|
4
|
+
challenger::
|
|
5
|
+
|
|
6
|
+
from proxyml import local, ci
|
|
7
|
+
from proxyml.client import upload_challenger
|
|
8
|
+
|
|
9
|
+
result = local.train_challenger(...)
|
|
10
|
+
payload = local.to_challenger_upload(result, champion_metrics=...)
|
|
11
|
+
upload_challenger(payload)
|
|
12
|
+
ci.post_challenger_comment()
|
|
13
|
+
|
|
14
|
+
Reads GITHUB_TOKEN, GITHUB_REPOSITORY, and GITHUB_EVENT_PATH from the
|
|
15
|
+
environment by default — all three are set automatically inside a GitHub
|
|
16
|
+
Actions job triggered by a pull_request event, alongside PROXYML_CI_TOKEN
|
|
17
|
+
(set as a repo secret), so the common case needs no arguments at all.
|
|
18
|
+
|
|
19
|
+
This never runs server-side — ProxyML's backend doesn't talk to GitHub —
|
|
20
|
+
so it lives in the SDK, not proxyml_core.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
|
|
29
|
+
from proxyml.client import get_challenger_summary
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_TIMEOUT = 30
|
|
34
|
+
_COMMENT_MARKER = "<!-- proxyml-challenger-comment -->"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _github_headers(github_token: str) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"Authorization": f"Bearer {github_token}",
|
|
40
|
+
"Accept": "application/vnd.github+json",
|
|
41
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_github_token(github_token: str | None) -> str:
|
|
46
|
+
token = github_token or os.getenv("GITHUB_TOKEN", "")
|
|
47
|
+
if not token:
|
|
48
|
+
raise EnvironmentError(
|
|
49
|
+
"GITHUB_TOKEN is not set and no github_token was provided. GitHub Actions "
|
|
50
|
+
"sets this automatically for a job that declares 'env: {GITHUB_TOKEN: "
|
|
51
|
+
"${{ secrets.GITHUB_TOKEN }}}' — add that to the step if it's missing."
|
|
52
|
+
)
|
|
53
|
+
return token
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_repo(repo: str | None) -> str:
|
|
57
|
+
resolved = repo or os.getenv("GITHUB_REPOSITORY", "")
|
|
58
|
+
if not resolved:
|
|
59
|
+
raise EnvironmentError(
|
|
60
|
+
"GITHUB_REPOSITORY is not set and no repo was provided. "
|
|
61
|
+
"This is set automatically inside GitHub Actions."
|
|
62
|
+
)
|
|
63
|
+
return resolved
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_pr_number(pr_number: int | None) -> int:
|
|
67
|
+
if pr_number is not None:
|
|
68
|
+
return pr_number
|
|
69
|
+
event_path = os.getenv("GITHUB_EVENT_PATH")
|
|
70
|
+
if not event_path:
|
|
71
|
+
raise EnvironmentError(
|
|
72
|
+
"GITHUB_EVENT_PATH is not set and no pr_number was provided. "
|
|
73
|
+
"This is set automatically inside GitHub Actions on pull_request events "
|
|
74
|
+
"— on other event types, pass pr_number explicitly."
|
|
75
|
+
)
|
|
76
|
+
with open(event_path) as f:
|
|
77
|
+
event = json.load(f)
|
|
78
|
+
number = (event.get("pull_request") or {}).get("number", event.get("number"))
|
|
79
|
+
if number is None:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Could not find a pull request number in {event_path}. This workflow may "
|
|
82
|
+
"not be running on a pull_request event — pass pr_number explicitly."
|
|
83
|
+
)
|
|
84
|
+
return number
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _find_existing_comment(repo: str, pr_number: int, headers: dict) -> int | None:
|
|
88
|
+
"""Look for a prior challenger comment on this PR, so re-runs edit it instead of piling up."""
|
|
89
|
+
url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
|
|
90
|
+
r = requests.get(url, headers=headers, timeout=_TIMEOUT)
|
|
91
|
+
r.raise_for_status()
|
|
92
|
+
for comment in r.json():
|
|
93
|
+
if _COMMENT_MARKER in comment.get("body", ""):
|
|
94
|
+
return comment["id"]
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def post_challenger_comment(
|
|
99
|
+
*,
|
|
100
|
+
ci_token: str | None = None,
|
|
101
|
+
github_token: str | None = None,
|
|
102
|
+
repo: str | None = None,
|
|
103
|
+
pr_number: int | None = None,
|
|
104
|
+
) -> dict | None:
|
|
105
|
+
"""Post or update a sticky PR comment summarizing the latest challenger comparison.
|
|
106
|
+
|
|
107
|
+
Call this after uploading a challenger via proxyml.client.upload_challenger()
|
|
108
|
+
— it fetches the CI-ready summary for the project authorized by the CI
|
|
109
|
+
token, then posts it to the pull request. A second run on the same PR
|
|
110
|
+
edits the existing comment in place (matched via a hidden HTML marker)
|
|
111
|
+
rather than adding a duplicate, the same way coverage bots behave.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
ci_token: overrides PROXYML_CI_TOKEN if provided.
|
|
115
|
+
github_token: overrides GITHUB_TOKEN if provided.
|
|
116
|
+
repo: "owner/name", overrides GITHUB_REPOSITORY if provided.
|
|
117
|
+
pr_number: overrides the PR number GitHub Actions reports via GITHUB_EVENT_PATH.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The created/updated GitHub comment JSON, or None if the challenger
|
|
121
|
+
summary couldn't be fetched or the GitHub API call failed.
|
|
122
|
+
"""
|
|
123
|
+
summary = get_challenger_summary(ci_token=ci_token)
|
|
124
|
+
if summary is None:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
headers = _github_headers(_resolve_github_token(github_token))
|
|
128
|
+
resolved_repo = _resolve_repo(repo)
|
|
129
|
+
resolved_pr = _resolve_pr_number(pr_number)
|
|
130
|
+
body = f"{_COMMENT_MARKER}\n{summary['markdown_summary']}"
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
existing_id = _find_existing_comment(resolved_repo, resolved_pr, headers)
|
|
134
|
+
base_url = f"https://api.github.com/repos/{resolved_repo}/issues"
|
|
135
|
+
if existing_id is not None:
|
|
136
|
+
r = requests.patch(
|
|
137
|
+
f"{base_url}/comments/{existing_id}", headers=headers, json={"body": body}, timeout=_TIMEOUT,
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
r = requests.post(
|
|
141
|
+
f"{base_url}/{resolved_pr}/comments", headers=headers, json={"body": body}, timeout=_TIMEOUT,
|
|
142
|
+
)
|
|
143
|
+
r.raise_for_status()
|
|
144
|
+
except requests.exceptions.RequestException as exc:
|
|
145
|
+
logger.error("Failed to post challenger comment to GitHub: %s", exc)
|
|
146
|
+
return None
|
|
147
|
+
return r.json()
|
|
@@ -48,13 +48,35 @@ def _headers() -> dict:
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def
|
|
51
|
+
def _ci_headers(ci_token: str | None = None) -> dict:
|
|
52
|
+
"""
|
|
53
|
+
Constructs request headers authenticated with a project-scoped CI token
|
|
54
|
+
rather than the account-wide PROXYML_API_KEY — see upload_challenger()
|
|
55
|
+
and get_challenger_summary().
|
|
56
|
+
"""
|
|
57
|
+
token = ci_token or os.getenv("PROXYML_CI_TOKEN", "")
|
|
58
|
+
if not token:
|
|
59
|
+
raise EnvironmentError(
|
|
60
|
+
"PROXYML_CI_TOKEN is not set and no ci_token was provided. "
|
|
61
|
+
"Mint one from the ProxyML dashboard for this project, then set the "
|
|
62
|
+
"environment variable or pass ci_token= explicitly."
|
|
63
|
+
)
|
|
64
|
+
return {
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
'X-CI-TOKEN': token,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def post(
|
|
71
|
+
endpoint: str, payload: dict, headers: dict | None = None
|
|
72
|
+
) -> requests.models.Response | _ErrorResponse:
|
|
52
73
|
"""
|
|
53
74
|
POSTs a request to a ProxyML API endpoint.
|
|
54
75
|
|
|
55
76
|
Args:
|
|
56
77
|
endpoint (str): ProxyML API endpoint. PROXYML_BASE_URL is prepended e.g. use endpoint='/schemas' not 'https://api.proxyml.ai/api/v1/schemas'.
|
|
57
78
|
payload (dict): JSON payload to POST.
|
|
79
|
+
headers (dict): overrides the default X-API-KEY auth headers, e.g. with _ci_headers().
|
|
58
80
|
|
|
59
81
|
Returns:
|
|
60
82
|
requests Response object.
|
|
@@ -63,7 +85,7 @@ def post(endpoint: str, payload: dict) -> requests.models.Response | _ErrorRespo
|
|
|
63
85
|
return requests.post(
|
|
64
86
|
url=f'{_base_url()}{endpoint}',
|
|
65
87
|
data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
|
|
66
|
-
headers=_headers(),
|
|
88
|
+
headers=headers if headers is not None else _headers(),
|
|
67
89
|
timeout=_TIMEOUT,
|
|
68
90
|
)
|
|
69
91
|
except requests.exceptions.RequestException as exc:
|
|
@@ -94,13 +116,16 @@ def put(endpoint: str, payload: dict) -> requests.models.Response | _ErrorRespon
|
|
|
94
116
|
return _ErrorResponse()
|
|
95
117
|
|
|
96
118
|
|
|
97
|
-
def get(
|
|
119
|
+
def get(
|
|
120
|
+
endpoint: str, params: dict | None = None, headers: dict | None = None
|
|
121
|
+
) -> requests.models.Response | _ErrorResponse:
|
|
98
122
|
"""
|
|
99
123
|
GETs a request to a ProxyML API endpoint.
|
|
100
124
|
|
|
101
125
|
Args:
|
|
102
126
|
endpoint (str): ProxyML API endpoint. PROXYML_BASE_URL is prepended e.g. use endpoint='/schemas' not 'https://api.proxyml.ai/api/v1/schemas'.
|
|
103
127
|
params (dict): query parameters.
|
|
128
|
+
headers (dict): overrides the default X-API-KEY auth headers, e.g. with _ci_headers().
|
|
104
129
|
|
|
105
130
|
Returns:
|
|
106
131
|
requests Response object.
|
|
@@ -108,7 +133,7 @@ def get(endpoint: str, params: dict | None = None) -> requests.models.Response |
|
|
|
108
133
|
try:
|
|
109
134
|
return requests.get(
|
|
110
135
|
url=f'{_base_url()}{endpoint}',
|
|
111
|
-
headers=_headers(),
|
|
136
|
+
headers=headers if headers is not None else _headers(),
|
|
112
137
|
params=params,
|
|
113
138
|
timeout=_TIMEOUT,
|
|
114
139
|
)
|
|
@@ -850,3 +875,54 @@ def delete_model(model_id: str) -> bool:
|
|
|
850
875
|
r.text,
|
|
851
876
|
)
|
|
852
877
|
return False
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def upload_challenger(payload: dict, *, ci_token: str | None = None) -> dict | None:
|
|
881
|
+
"""
|
|
882
|
+
Uploads a challenger comparison from a headless environment, e.g. a CI job.
|
|
883
|
+
|
|
884
|
+
Authenticated with a project-scoped CI token rather than PROXYML_API_KEY
|
|
885
|
+
— mint one from the ProxyML dashboard for the specific project this
|
|
886
|
+
challenger belongs to, since (unlike the account-wide API key) it only
|
|
887
|
+
ever authorizes writes to that one project. Set PROXYML_CI_TOKEN or pass
|
|
888
|
+
ci_token= explicitly.
|
|
889
|
+
|
|
890
|
+
Args:
|
|
891
|
+
payload (dict): output of proxyml.local.to_challenger_upload().
|
|
892
|
+
ci_token (str): overrides PROXYML_CI_TOKEN if provided.
|
|
893
|
+
|
|
894
|
+
Returns:
|
|
895
|
+
JSON object with the new challenger_version, or None if an error occurred.
|
|
896
|
+
"""
|
|
897
|
+
r = post(endpoint='/ci/challenger', payload=payload, headers=_ci_headers(ci_token))
|
|
898
|
+
if r.status_code == 202:
|
|
899
|
+
return r.json()
|
|
900
|
+
logger.error(
|
|
901
|
+
"Challenger upload failed with status %s: %s",
|
|
902
|
+
r.status_code,
|
|
903
|
+
r.text,
|
|
904
|
+
)
|
|
905
|
+
return None
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def get_challenger_summary(*, ci_token: str | None = None) -> dict | None:
|
|
909
|
+
"""
|
|
910
|
+
Fetches a CI-ready summary of the latest challenger comparison for the
|
|
911
|
+
project authorized by the given CI token: which model won, the primary
|
|
912
|
+
metric and its delta, and a ready-to-post markdown snippet.
|
|
913
|
+
|
|
914
|
+
Set PROXYML_CI_TOKEN or pass ci_token= explicitly.
|
|
915
|
+
|
|
916
|
+
Returns:
|
|
917
|
+
JSON object, or None if an error occurred (e.g. no challenger has
|
|
918
|
+
been uploaded to this project yet).
|
|
919
|
+
"""
|
|
920
|
+
r = get(endpoint='/ci/challenger/summary', headers=_ci_headers(ci_token))
|
|
921
|
+
if r.status_code == 200:
|
|
922
|
+
return r.json()
|
|
923
|
+
logger.error(
|
|
924
|
+
"Fetching challenger summary failed with status %s: %s",
|
|
925
|
+
r.status_code,
|
|
926
|
+
r.text,
|
|
927
|
+
)
|
|
928
|
+
return None
|
|
@@ -193,6 +193,18 @@ def train_challenger(
|
|
|
193
193
|
are dropped from both sides — champion and challenger are always
|
|
194
194
|
evaluated on the same labeled population, never on different ones.
|
|
195
195
|
|
|
196
|
+
Note that ``target_fingerprint`` (on the result) is computed from
|
|
197
|
+
``target`` exactly as passed in — NaNs and all — *before* this drop
|
|
198
|
+
happens, not from the labeled subset actually used for training/scoring.
|
|
199
|
+
That's transparent if you use ``champion_predictions`` above, since it's
|
|
200
|
+
matched against the same pre-drop array internally. But if you're
|
|
201
|
+
pairing this result with a champion scored separately (``score_champion()``
|
|
202
|
+
+ ``champion_labels=`` on ``to_challenger_upload()``), you must pass that
|
|
203
|
+
same original, uncleaned ``target`` array as ``champion_labels`` — not a
|
|
204
|
+
version you've already run ``dropna()`` on yourself — or the two
|
|
205
|
+
fingerprints won't match even though the actually-scored population is
|
|
206
|
+
identical.
|
|
207
|
+
|
|
196
208
|
Args:
|
|
197
209
|
df: samples to train on, one column per schema feature.
|
|
198
210
|
target: the value to predict for each row of ``df`` — ground-truth
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyml
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Python SDK for calling the ProxyML API
|
|
5
5
|
Author-email: ProxyML <contact@proxyml.ai>
|
|
6
6
|
License: Apache License
|
|
@@ -333,6 +333,9 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
|
|
|
333
333
|
| `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
|
|
334
334
|
| `proxyml.local.train_challenger(df, target, schema, ...)` | Train a challenger model locally — no API round-trip (`pip install 'proxyml[local]'`) |
|
|
335
335
|
| `proxyml.local.train_auto_challenger(data, target_col, ...)` | Load data + train a local challenger in one call |
|
|
336
|
+
| `upload_challenger(payload, ci_token=None)` | Upload a challenger comparison headlessly, e.g. from CI (`PROXYML_CI_TOKEN`, not `PROXYML_API_KEY`) |
|
|
337
|
+
| `get_challenger_summary(ci_token=None)` | Fetch a CI-ready summary of the latest challenger comparison |
|
|
338
|
+
| `proxyml.ci.post_challenger_comment(...)` | Post/update a sticky PR comment with the challenger comparison |
|
|
336
339
|
|
|
337
340
|
Full documentation: [`docs/api.md`](docs/api.md)
|
|
338
341
|
|
|
@@ -344,6 +347,7 @@ Full documentation: [`docs/api.md`](docs/api.md)
|
|
|
344
347
|
- [`examples/multiclass_example.py`](examples/multiclass_example.py) — Multi-class classification with per-class feature importances
|
|
345
348
|
- [`examples/testing_example.py`](examples/testing_example.py) — Using a surrogate as a reference model in CI
|
|
346
349
|
- [`examples/surrogate_export_example.py`](examples/surrogate_export_example.py) — Exporting a surrogate and reproducing its predictions locally
|
|
350
|
+
- [`examples/ci_challenger_example.py`](examples/ci_challenger_example.py) + [`examples/github_actions_challenger.yml`](examples/github_actions_challenger.yml) — Training a challenger and posting the comparison as a PR comment from CI
|
|
347
351
|
|
|
348
352
|
## License
|
|
349
353
|
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/proxyml/__init__.py
|
|
5
|
+
src/proxyml/ci.py
|
|
5
6
|
src/proxyml/client.py
|
|
6
7
|
src/proxyml/schema_builder.py
|
|
7
8
|
src/proxyml.egg-info/PKG-INFO
|
|
@@ -11,6 +12,7 @@ src/proxyml.egg-info/requires.txt
|
|
|
11
12
|
src/proxyml.egg-info/top_level.txt
|
|
12
13
|
src/proxyml/local/__init__.py
|
|
13
14
|
src/proxyml/local/challenger.py
|
|
15
|
+
tests/test_ci.py
|
|
14
16
|
tests/test_client.py
|
|
15
17
|
tests/test_dependency_boundaries.py
|
|
16
18
|
tests/test_local_challenger.py
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from proxyml.ci import (
|
|
8
|
+
_COMMENT_MARKER,
|
|
9
|
+
_resolve_github_token,
|
|
10
|
+
_resolve_pr_number,
|
|
11
|
+
_resolve_repo,
|
|
12
|
+
post_challenger_comment,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _mock_response(status_code, json_body):
|
|
17
|
+
r = MagicMock()
|
|
18
|
+
r.status_code = status_code
|
|
19
|
+
r.json.return_value = json_body
|
|
20
|
+
r.raise_for_status = MagicMock()
|
|
21
|
+
if status_code >= 400:
|
|
22
|
+
r.raise_for_status.side_effect = requests.exceptions.HTTPError(f"{status_code} error")
|
|
23
|
+
return r
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# env resolution
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def test_resolve_github_token_raises_when_missing(monkeypatch):
|
|
31
|
+
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
32
|
+
with pytest.raises(EnvironmentError, match="GITHUB_TOKEN"):
|
|
33
|
+
_resolve_github_token(None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_resolve_github_token_explicit_overrides_env(monkeypatch):
|
|
37
|
+
monkeypatch.setenv("GITHUB_TOKEN", "env-token")
|
|
38
|
+
assert _resolve_github_token("explicit-token") == "explicit-token"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_resolve_repo_raises_when_missing(monkeypatch):
|
|
42
|
+
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
|
|
43
|
+
with pytest.raises(EnvironmentError, match="GITHUB_REPOSITORY"):
|
|
44
|
+
_resolve_repo(None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_resolve_repo_reads_env(monkeypatch):
|
|
48
|
+
monkeypatch.setenv("GITHUB_REPOSITORY", "acme/widgets")
|
|
49
|
+
assert _resolve_repo(None) == "acme/widgets"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_resolve_pr_number_explicit_skips_event_file(monkeypatch):
|
|
53
|
+
monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)
|
|
54
|
+
assert _resolve_pr_number(42) == 42
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_resolve_pr_number_raises_without_event_path(monkeypatch):
|
|
58
|
+
monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)
|
|
59
|
+
with pytest.raises(EnvironmentError, match="GITHUB_EVENT_PATH"):
|
|
60
|
+
_resolve_pr_number(None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_resolve_pr_number_reads_pull_request_event(tmp_path, monkeypatch):
|
|
64
|
+
event_path = tmp_path / "event.json"
|
|
65
|
+
event_path.write_text(json.dumps({"pull_request": {"number": 17}}))
|
|
66
|
+
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path))
|
|
67
|
+
assert _resolve_pr_number(None) == 17
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_resolve_pr_number_raises_on_non_pr_event(tmp_path, monkeypatch):
|
|
71
|
+
event_path = tmp_path / "event.json"
|
|
72
|
+
event_path.write_text(json.dumps({"ref": "refs/heads/main"}))
|
|
73
|
+
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path))
|
|
74
|
+
with pytest.raises(ValueError, match="pull request number"):
|
|
75
|
+
_resolve_pr_number(None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# post_challenger_comment
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
@patch("proxyml.ci.get_challenger_summary")
|
|
83
|
+
def test_post_challenger_comment_returns_none_without_summary(mock_summary):
|
|
84
|
+
mock_summary.return_value = None
|
|
85
|
+
assert post_challenger_comment(github_token="t", repo="acme/widgets", pr_number=1) is None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@patch("proxyml.ci.requests.post")
|
|
89
|
+
@patch("proxyml.ci.requests.get")
|
|
90
|
+
@patch("proxyml.ci.get_challenger_summary")
|
|
91
|
+
def test_post_challenger_comment_creates_new_comment_when_none_exists(mock_summary, mock_get, mock_post):
|
|
92
|
+
mock_summary.return_value = {"markdown_summary": "**ProxyML challenger beats champion** by 0.19 r2"}
|
|
93
|
+
mock_get.return_value = _mock_response(200, []) # no existing comments
|
|
94
|
+
mock_post.return_value = _mock_response(201, {"id": 99, "body": "posted"})
|
|
95
|
+
|
|
96
|
+
result = post_challenger_comment(github_token="gh-token", repo="acme/widgets", pr_number=7)
|
|
97
|
+
|
|
98
|
+
assert result == {"id": 99, "body": "posted"}
|
|
99
|
+
mock_post.assert_called_once()
|
|
100
|
+
url = mock_post.call_args.args[0]
|
|
101
|
+
assert url == "https://api.github.com/repos/acme/widgets/issues/7/comments"
|
|
102
|
+
body = mock_post.call_args.kwargs["json"]["body"]
|
|
103
|
+
assert _COMMENT_MARKER in body
|
|
104
|
+
assert "0.19 r2" in body
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@patch("proxyml.ci.requests.patch")
|
|
108
|
+
@patch("proxyml.ci.requests.get")
|
|
109
|
+
@patch("proxyml.ci.get_challenger_summary")
|
|
110
|
+
def test_post_challenger_comment_updates_existing_sticky_comment(mock_summary, mock_get, mock_patch):
|
|
111
|
+
mock_summary.return_value = {"markdown_summary": "updated result"}
|
|
112
|
+
mock_get.return_value = _mock_response(
|
|
113
|
+
200, [{"id": 55, "body": f"{_COMMENT_MARKER}\nold result"}, {"id": 56, "body": "unrelated comment"}],
|
|
114
|
+
)
|
|
115
|
+
mock_patch.return_value = _mock_response(200, {"id": 55, "body": "updated"})
|
|
116
|
+
|
|
117
|
+
result = post_challenger_comment(github_token="gh-token", repo="acme/widgets", pr_number=7)
|
|
118
|
+
|
|
119
|
+
assert result == {"id": 55, "body": "updated"}
|
|
120
|
+
mock_patch.assert_called_once()
|
|
121
|
+
url = mock_patch.call_args.args[0]
|
|
122
|
+
assert url == "https://api.github.com/repos/acme/widgets/issues/comments/55"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@patch("proxyml.ci.requests.post")
|
|
126
|
+
@patch("proxyml.ci.requests.get")
|
|
127
|
+
@patch("proxyml.ci.get_challenger_summary")
|
|
128
|
+
def test_post_challenger_comment_returns_none_on_github_error(mock_summary, mock_get, mock_post):
|
|
129
|
+
mock_summary.return_value = {"markdown_summary": "result"}
|
|
130
|
+
mock_get.return_value = _mock_response(200, [])
|
|
131
|
+
mock_post.return_value = _mock_response(403, {"message": "forbidden"})
|
|
132
|
+
|
|
133
|
+
assert post_challenger_comment(github_token="gh-token", repo="acme/widgets", pr_number=7) is None
|
|
@@ -11,7 +11,9 @@ from proxyml_core.schema import FeatureSchema
|
|
|
11
11
|
from proxyml.client import (
|
|
12
12
|
_cast_column,
|
|
13
13
|
_base_url,
|
|
14
|
+
_ci_headers,
|
|
14
15
|
_headers,
|
|
16
|
+
get_challenger_summary,
|
|
15
17
|
health_check,
|
|
16
18
|
delete_model,
|
|
17
19
|
delete_schema,
|
|
@@ -37,6 +39,7 @@ from proxyml.client import (
|
|
|
37
39
|
train_surrogate,
|
|
38
40
|
train_auto_surrogate,
|
|
39
41
|
update_model,
|
|
42
|
+
upload_challenger,
|
|
40
43
|
)
|
|
41
44
|
|
|
42
45
|
|
|
@@ -1033,3 +1036,78 @@ def test_update_model_no_fields_raises():
|
|
|
1033
1036
|
def test_update_model_failure_returns_none(mock_patch):
|
|
1034
1037
|
mock_patch.return_value = _mock_response(404, {"detail": "not found"})
|
|
1035
1038
|
assert update_model("abc-123", name="x") is None
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
# ---------------------------------------------------------------------------
|
|
1042
|
+
# _ci_headers
|
|
1043
|
+
# ---------------------------------------------------------------------------
|
|
1044
|
+
|
|
1045
|
+
def test_ci_headers_raises_without_token(monkeypatch):
|
|
1046
|
+
monkeypatch.delenv("PROXYML_CI_TOKEN", raising=False)
|
|
1047
|
+
with pytest.raises(EnvironmentError, match="PROXYML_CI_TOKEN"):
|
|
1048
|
+
_ci_headers()
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def test_ci_headers_reads_env(monkeypatch):
|
|
1052
|
+
monkeypatch.setenv("PROXYML_CI_TOKEN", "test-ci-token")
|
|
1053
|
+
h = _ci_headers()
|
|
1054
|
+
assert h["X-CI-TOKEN"] == "test-ci-token"
|
|
1055
|
+
assert h["Content-Type"] == "application/json"
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def test_ci_headers_explicit_token_overrides_env(monkeypatch):
|
|
1059
|
+
monkeypatch.setenv("PROXYML_CI_TOKEN", "env-token")
|
|
1060
|
+
h = _ci_headers(ci_token="explicit-token")
|
|
1061
|
+
assert h["X-CI-TOKEN"] == "explicit-token"
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
# ---------------------------------------------------------------------------
|
|
1065
|
+
# upload_challenger / get_challenger_summary
|
|
1066
|
+
# ---------------------------------------------------------------------------
|
|
1067
|
+
|
|
1068
|
+
@patch("proxyml.client.post")
|
|
1069
|
+
def test_upload_challenger_success(mock_post, monkeypatch):
|
|
1070
|
+
monkeypatch.setenv("PROXYML_CI_TOKEN", "test-ci-token")
|
|
1071
|
+
mock_post.return_value = _mock_response(202, {"status": "ok", "challenger_version": "v1", "task": "regression"})
|
|
1072
|
+
payload = {"export": {}, "challenger_metrics": {"r2": 0.9}, "champion_metrics": {"r2": 0.8}}
|
|
1073
|
+
|
|
1074
|
+
result = upload_challenger(payload)
|
|
1075
|
+
|
|
1076
|
+
assert result == {"status": "ok", "challenger_version": "v1", "task": "regression"}
|
|
1077
|
+
mock_post.assert_called_once_with(
|
|
1078
|
+
endpoint="/ci/challenger", payload=payload, headers={"Content-Type": "application/json", "X-CI-TOKEN": "test-ci-token"},
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
@patch("proxyml.client.post")
|
|
1083
|
+
def test_upload_challenger_failure_returns_none(mock_post, monkeypatch):
|
|
1084
|
+
monkeypatch.setenv("PROXYML_CI_TOKEN", "test-ci-token")
|
|
1085
|
+
mock_post.return_value = _mock_response(401, {"detail": "invalid token"})
|
|
1086
|
+
assert upload_challenger({}) is None
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def test_upload_challenger_raises_without_ci_token(monkeypatch):
|
|
1090
|
+
monkeypatch.delenv("PROXYML_CI_TOKEN", raising=False)
|
|
1091
|
+
with pytest.raises(EnvironmentError, match="PROXYML_CI_TOKEN"):
|
|
1092
|
+
upload_challenger({})
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
@patch("proxyml.client.get")
|
|
1096
|
+
def test_get_challenger_summary_success(mock_get, monkeypatch):
|
|
1097
|
+
monkeypatch.setenv("PROXYML_CI_TOKEN", "test-ci-token")
|
|
1098
|
+
summary = {"winner": "challenger", "primary_metric": "r2", "markdown_summary": "**ProxyML challenger beats champion**"}
|
|
1099
|
+
mock_get.return_value = _mock_response(200, summary)
|
|
1100
|
+
|
|
1101
|
+
result = get_challenger_summary()
|
|
1102
|
+
|
|
1103
|
+
assert result == summary
|
|
1104
|
+
mock_get.assert_called_once_with(
|
|
1105
|
+
endpoint="/ci/challenger/summary", headers={"Content-Type": "application/json", "X-CI-TOKEN": "test-ci-token"},
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
@patch("proxyml.client.get")
|
|
1110
|
+
def test_get_challenger_summary_failure_returns_none(mock_get, monkeypatch):
|
|
1111
|
+
monkeypatch.setenv("PROXYML_CI_TOKEN", "test-ci-token")
|
|
1112
|
+
mock_get.return_value = _mock_response(404, {"detail": "no challenger uploaded"})
|
|
1113
|
+
assert get_challenger_summary() is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|