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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.9.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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxyml"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "Python SDK for calling the ProxyML API"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -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 post(endpoint: str, payload: dict) -> requests.models.Response | _ErrorResponse:
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(endpoint: str, params: dict | None = None) -> requests.models.Response | _ErrorResponse:
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.9.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