proxyml 0.1.9__tar.gz → 0.2.1__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.1.9
3
+ Version: 0.2.1
4
4
  Summary: Python SDK for calling the ProxyML API
5
5
  Author-email: ProxyML <contact@proxyml.ai>
6
6
  License: Apache License
@@ -221,9 +221,6 @@ Dynamic: license-file
221
221
 
222
222
  Python SDK for the [ProxyML API](https://proxyml.ai).
223
223
 
224
- > **Status:** Early access — server endpoints coming soon.
225
- > [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
226
-
227
224
  <img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
228
225
 
229
226
  ## Why ProxyML?
@@ -316,7 +313,7 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
316
313
  | `put_schema(schema)` | Upload a schema to the API |
317
314
  | `synthesize_data(num_points, sample, as_df)` | Generate synthetic data points |
318
315
  | `train_surrogate(samples, predictions, feature_names, task, test_size)` | Train a surrogate model |
319
- | `predict(samples, version)` | Score samples with the surrogate model |
316
+ | `predict(sample, version)` | Score a single sample with the surrogate model |
320
317
  | `find_counterfactual(sample, target, ...)` | Find a counterfactual for a given sample |
321
318
  | `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
322
319
 
@@ -2,9 +2,6 @@
2
2
 
3
3
  Python SDK for the [ProxyML API](https://proxyml.ai).
4
4
 
5
- > **Status:** Early access — server endpoints coming soon.
6
- > [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
7
-
8
5
  <img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
9
6
 
10
7
  ## Why ProxyML?
@@ -97,7 +94,7 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
97
94
  | `put_schema(schema)` | Upload a schema to the API |
98
95
  | `synthesize_data(num_points, sample, as_df)` | Generate synthetic data points |
99
96
  | `train_surrogate(samples, predictions, feature_names, task, test_size)` | Train a surrogate model |
100
- | `predict(samples, version)` | Score samples with the surrogate model |
97
+ | `predict(sample, version)` | Score a single sample with the surrogate model |
101
98
  | `find_counterfactual(sample, target, ...)` | Find a counterfactual for a given sample |
102
99
  | `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
103
100
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxyml"
7
- version = "0.1.9"
7
+ version = "0.2.1"
8
8
  description = "Python SDK for calling the ProxyML API"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -1,4 +1,5 @@
1
1
  from proxyml.client import (
2
+ health_check,
2
3
  put_schema,
3
4
  fetch_schema,
4
5
  list_schemas,
@@ -20,6 +21,8 @@ from proxyml.client import (
20
21
  get_usage,
21
22
  rotate_key,
22
23
  explain_local,
24
+ explain_local_batch,
25
+ update_model,
23
26
  )
24
27
  from proxyml.schema import (
25
28
  get_schema,
@@ -29,6 +32,7 @@ from proxyml.schema import (
29
32
  )
30
33
 
31
34
  __all__ = [
35
+ "health_check",
32
36
  "put_schema",
33
37
  "fetch_schema",
34
38
  "list_schemas",
@@ -50,6 +54,8 @@ __all__ = [
50
54
  "get_usage",
51
55
  "rotate_key",
52
56
  "explain_local",
57
+ "explain_local_batch",
58
+ "update_model",
53
59
  "get_schema",
54
60
  "gen_continuous_schema",
55
61
  "gen_categorical_schema",
@@ -1,16 +1,26 @@
1
1
  import logging
2
- logger = logging.getLogger(__name__)
2
+ import os
3
3
  from typing import Any
4
4
 
5
5
  import orjson
6
- import requests
7
- import os
8
6
  import numpy as np
9
7
  import pandas as pd
8
+ import requests
10
9
  from pandas.api.types import is_float_dtype, is_integer_dtype
11
10
 
11
+ logger = logging.getLogger(__name__)
12
12
 
13
13
  _BOOL_STRINGS = {"true", "false"}
14
+ _TIMEOUT = 120
15
+
16
+
17
+ class _ErrorResponse:
18
+ """Returned by HTTP helpers when a network-level error occurs."""
19
+ status_code = 0
20
+ text = ""
21
+
22
+ def json(self) -> dict:
23
+ return {}
14
24
 
15
25
 
16
26
  def _base_url() -> str:
@@ -33,7 +43,7 @@ def _headers() -> dict:
33
43
  }
34
44
 
35
45
 
36
- def post(endpoint: str, payload: dict) -> requests.models.Response:
46
+ def post(endpoint: str, payload: dict) -> requests.models.Response | _ErrorResponse:
37
47
  """
38
48
  POSTs a request to a ProxyML API endpoint.
39
49
 
@@ -44,53 +54,88 @@ def post(endpoint: str, payload: dict) -> requests.models.Response:
44
54
  Returns:
45
55
  requests Response object.
46
56
  """
47
- r = requests.post(
48
- url=f'{_base_url()}{endpoint}',
49
- data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
50
- headers=_headers()
51
- )
52
- return r
57
+ try:
58
+ return requests.post(
59
+ url=f'{_base_url()}{endpoint}',
60
+ data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
61
+ headers=_headers(),
62
+ timeout=_TIMEOUT,
63
+ )
64
+ except requests.exceptions.RequestException as exc:
65
+ logger.error("Network error POSTing to %s: %s", endpoint, exc)
66
+ return _ErrorResponse()
53
67
 
54
68
 
55
- def put(endpoint: str, payload: dict) -> requests.models.Response:
69
+ def put(endpoint: str, payload: dict) -> requests.models.Response | _ErrorResponse:
56
70
  """
57
71
  PUTs a request to a ProxyML API endpoint.
58
72
 
59
73
  Args:
60
74
  endpoint (str): ProxyML API endpoint. PROXYML_BASE_URL is prepended e.g. use endpoint='/schemas' not 'https://api.proxyml.ai/api/v1/schemas'.
61
- payload (dict): JSON payload to POST.
75
+ payload (dict): JSON payload to PUT.
62
76
 
63
77
  Returns:
64
78
  requests Response object.
65
- """
66
- r = requests.put(
67
- url=f'{_base_url()}{endpoint}',
68
- data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
69
- headers=_headers()
70
- )
71
- return r
79
+ """
80
+ try:
81
+ return requests.put(
82
+ url=f'{_base_url()}{endpoint}',
83
+ data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
84
+ headers=_headers(),
85
+ timeout=_TIMEOUT,
86
+ )
87
+ except requests.exceptions.RequestException as exc:
88
+ logger.error("Network error PUTting to %s: %s", endpoint, exc)
89
+ return _ErrorResponse()
72
90
 
73
91
 
74
- def get(endpoint: str, params: dict) -> requests.models.Response:
92
+ def get(endpoint: str, params: dict | None = None) -> requests.models.Response | _ErrorResponse:
75
93
  """
76
94
  GETs a request to a ProxyML API endpoint.
77
95
 
78
96
  Args:
79
97
  endpoint (str): ProxyML API endpoint. PROXYML_BASE_URL is prepended e.g. use endpoint='/schemas' not 'https://api.proxyml.ai/api/v1/schemas'.
80
- params (dict): JSON payload to POST.
98
+ params (dict): query parameters.
81
99
 
82
100
  Returns:
83
101
  requests Response object.
84
102
  """
85
- r = requests.get(
86
- url=f'{_base_url()}{endpoint}',
87
- headers=_headers(),
88
- params=params
89
- )
90
- return r
103
+ try:
104
+ return requests.get(
105
+ url=f'{_base_url()}{endpoint}',
106
+ headers=_headers(),
107
+ params=params,
108
+ timeout=_TIMEOUT,
109
+ )
110
+ except requests.exceptions.RequestException as exc:
111
+ logger.error("Network error GETting %s: %s", endpoint, exc)
112
+ return _ErrorResponse()
91
113
 
92
114
 
93
- def delete(endpoint: str) -> requests.models.Response:
115
+ def patch(endpoint: str, payload: dict) -> requests.models.Response | _ErrorResponse:
116
+ """
117
+ PATCHes a request to a ProxyML API endpoint.
118
+
119
+ Args:
120
+ endpoint (str): ProxyML API endpoint.
121
+ payload (dict): JSON payload to PATCH.
122
+
123
+ Returns:
124
+ requests Response object.
125
+ """
126
+ try:
127
+ return requests.patch(
128
+ url=f'{_base_url()}{endpoint}',
129
+ data=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY),
130
+ headers=_headers(),
131
+ timeout=_TIMEOUT,
132
+ )
133
+ except requests.exceptions.RequestException as exc:
134
+ logger.error("Network error PATCHing %s: %s", endpoint, exc)
135
+ return _ErrorResponse()
136
+
137
+
138
+ def delete(endpoint: str) -> requests.models.Response | _ErrorResponse:
94
139
  """
95
140
  DELETE request to a ProxyML API endpoint.
96
141
 
@@ -100,11 +145,15 @@ def delete(endpoint: str) -> requests.models.Response:
100
145
  Returns:
101
146
  requests Response object.
102
147
  """
103
- r = requests.delete(
104
- url=f'{_base_url()}{endpoint}',
105
- headers=_headers()
106
- )
107
- return r
148
+ try:
149
+ return requests.delete(
150
+ url=f'{_base_url()}{endpoint}',
151
+ headers=_headers(),
152
+ timeout=_TIMEOUT,
153
+ )
154
+ except requests.exceptions.RequestException as exc:
155
+ logger.error("Network error DELETEing %s: %s", endpoint, exc)
156
+ return _ErrorResponse()
108
157
 
109
158
 
110
159
  def put_schema(schema: dict, name: str = "default") -> Any:
@@ -326,7 +375,7 @@ def find_counterfactual(sample, target, n_neighbors: int = 10000, perturbation_s
326
375
  sample (list): point around which neighbors will be found.
327
376
  target (Any): the desired prediction outcome, can be a string or number for classification or a target predicted value for regression.
328
377
  n_neighbors (int): the number of neighbors to search, defaults to 10000.
329
- pertubation_scale (float): controls the "wiggle" in the features around the provided sample. Defaults to 0.1.
378
+ perturbation_scale (float): controls the "wiggle" in the features around the provided sample. Defaults to 0.1.
330
379
  version (str): name of the surrogate model to run, defaults to "default."
331
380
  as_df (bool): if True, return the counterfactual as a dataframe. If False, returns JSON object.
332
381
  Returns:
@@ -448,7 +497,7 @@ def find_counterfactuals(
448
497
  samples (list): list of points around which neighbors will be found.
449
498
  target (Any): the desired prediction outcome, can be a string or number for classification or a target predicted value for regression.
450
499
  n_neighbors (int): the number of neighbors to search, defaults to 10000.
451
- pertubation_scale (float): controls the "wiggle" in the features around the provided sample. Defaults to 0.1.
500
+ perturbation_scale (float): controls the "wiggle" in the features around the provided sample. Defaults to 0.1.
452
501
  version (str): name of the surrogate model to run, defaults to "default."
453
502
  as_df (bool): if True, return the counterfactual as a dataframe. If False, returns JSON object.
454
503
  Returns:
@@ -573,6 +622,23 @@ def get_feature_importances(version: str | None = None) -> Any:
573
622
  return None
574
623
 
575
624
 
625
+ def health_check() -> dict | None:
626
+ """Check API connectivity and version. Does not require authentication and does not count against usage quota.
627
+
628
+ Returns:
629
+ Dict with ``status``, ``model_loaded``, and ``version``, or None on failure.
630
+ """
631
+ try:
632
+ r = requests.get(url=f'{_base_url()}/health', timeout=_TIMEOUT)
633
+ except requests.exceptions.RequestException as exc:
634
+ logger.error("Network error calling /health: %s", exc)
635
+ return None
636
+ if r.status_code == 200:
637
+ return r.json()
638
+ logger.error("Health check failed with status %s: %s", r.status_code, r.text)
639
+ return None
640
+
641
+
576
642
  def get_usage() -> dict | None:
577
643
  """Return current tier, usage counts, and quota for the authenticated user."""
578
644
  r = get(endpoint='/account/usage', params={})
@@ -599,11 +665,19 @@ def rotate_key() -> str | None:
599
665
  return None
600
666
 
601
667
 
602
- def list_models() -> list[dict] | None:
603
- """Return metadata for all trained surrogate models, newest first."""
604
- r = get(endpoint='/surrogate/models', params={})
668
+ def list_models(limit: int = 50, offset: int = 0) -> dict | None:
669
+ """Return a page of trained surrogate models, newest first.
670
+
671
+ Args:
672
+ limit (int): maximum number of models to return (1–200, default 50).
673
+ offset (int): number of models to skip for pagination (default 0).
674
+ Returns:
675
+ Dict with ``models`` (list of metadata dicts) and ``total`` (int,
676
+ total number of surrogates regardless of pagination), or None on failure.
677
+ """
678
+ r = get(endpoint='/surrogate/models', params={'limit': limit, 'offset': offset})
605
679
  if r.status_code == 200:
606
- return r.json()['models']
680
+ return r.json()
607
681
  logger.error(
608
682
  "List models failed with status %s: %s",
609
683
  r.status_code,
@@ -612,6 +686,30 @@ def list_models() -> list[dict] | None:
612
686
  return None
613
687
 
614
688
 
689
+ def explain_local_batch(instances: list[list], version: str | None = None) -> dict | None:
690
+ """Per-feature contribution breakdown for multiple instances in one call.
691
+
692
+ Args:
693
+ instances (list[list]): list of feature vectors, each in schema order.
694
+ version (str): surrogate version UUID. None uses the latest version.
695
+ Returns:
696
+ Dict with ``results`` (one contribution entry per instance), ``model_version``,
697
+ ``task``, and ``schema_warning``, or None on failure.
698
+ """
699
+ payload: dict = {"instances": instances}
700
+ if version is not None:
701
+ payload["version"] = version
702
+ r = post(endpoint="/explain/local/batch", payload=payload)
703
+ if r.status_code == 200:
704
+ return r.json()
705
+ logger.error(
706
+ "Batch local explanation failed with status %s: %s",
707
+ r.status_code,
708
+ r.text,
709
+ )
710
+ return None
711
+
712
+
615
713
  def explain_local(instance: list, version: str | None = None) -> dict | None:
616
714
  """Per-feature contribution breakdown for a single instance.
617
715
 
@@ -633,6 +731,37 @@ def explain_local(instance: list, version: str | None = None) -> dict | None:
633
731
  return None
634
732
 
635
733
 
734
+ def update_model(version: str, name: str | None = ..., comments: str | None = ...) -> dict | None:
735
+ """Update the name and/or comments of a surrogate without retraining.
736
+
737
+ Pass a string to set the field, or ``None`` to clear it. Omit a parameter
738
+ entirely to leave that field unchanged.
739
+
740
+ Args:
741
+ version (str): UUID of the surrogate to update.
742
+ name (str | None): new name, None to clear, or omit to leave unchanged.
743
+ comments (str | None): new comments, None to clear, or omit to leave unchanged.
744
+ Returns:
745
+ Updated model metadata dict, or None on failure.
746
+ """
747
+ payload: dict = {}
748
+ if name is not ...:
749
+ payload["name"] = name
750
+ if comments is not ...:
751
+ payload["comments"] = comments
752
+ if not payload:
753
+ raise ValueError("Provide at least one of: name, comments")
754
+ r = patch(endpoint=f'/surrogate/models/{version}', payload=payload)
755
+ if r.status_code == 200:
756
+ return r.json()
757
+ logger.error(
758
+ "Update model failed with status %s: %s",
759
+ r.status_code,
760
+ r.text,
761
+ )
762
+ return None
763
+
764
+
636
765
  def delete_model(model_id: str) -> bool:
637
766
  """Delete a surrogate model by its UUID. Returns True on success, False if not found."""
638
767
  r = delete(endpoint=f'/surrogate/models/{model_id}')
@@ -1,11 +1,11 @@
1
1
  import logging
2
- logger = logging.getLogger(__name__)
3
2
 
4
- import os
5
3
  import numpy as np
6
4
  import pandas as pd
7
5
  from pandas.api.types import is_float_dtype, is_integer_dtype
8
6
 
7
+ logger = logging.getLogger(__name__)
8
+
9
9
 
10
10
  def gen_continuous_schema(s: pd.Series, name: str | None = None) -> dict:
11
11
  """
@@ -73,6 +73,11 @@ def get_schema(df: pd.DataFrame, immutable_cols: list[str] | None) -> dict:
73
73
  Returns:
74
74
  data schema as dict. Review and adjust as needed.
75
75
  """
76
+ if immutable_cols:
77
+ unknown = set(immutable_cols) - set(df.columns)
78
+ if unknown:
79
+ logger.warning("immutable_cols not found in DataFrame and will be ignored: %s", sorted(unknown))
80
+
76
81
  schema = {
77
82
  'features': list(),
78
83
  '_note': (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyml
3
- Version: 0.1.9
3
+ Version: 0.2.1
4
4
  Summary: Python SDK for calling the ProxyML API
5
5
  Author-email: ProxyML <contact@proxyml.ai>
6
6
  License: Apache License
@@ -221,9 +221,6 @@ Dynamic: license-file
221
221
 
222
222
  Python SDK for the [ProxyML API](https://proxyml.ai).
223
223
 
224
- > **Status:** Early access — server endpoints coming soon.
225
- > [Request early access](mailto:contact@proxyml.ai) or star this repo to follow progress.
226
-
227
224
  <img width="577" height="115" alt="creditg_car_counterfactual" src="https://github.com/user-attachments/assets/8f913c95-1190-4b3c-ba7d-6690e9ebb3e0" />
228
225
 
229
226
  ## Why ProxyML?
@@ -316,7 +313,7 @@ See [`docs/quickstart.md`](docs/quickstart.md) for a full walkthrough and [`docs
316
313
  | `put_schema(schema)` | Upload a schema to the API |
317
314
  | `synthesize_data(num_points, sample, as_df)` | Generate synthetic data points |
318
315
  | `train_surrogate(samples, predictions, feature_names, task, test_size)` | Train a surrogate model |
319
- | `predict(samples, version)` | Score samples with the surrogate model |
316
+ | `predict(sample, version)` | Score a single sample with the surrogate model |
320
317
  | `find_counterfactual(sample, target, ...)` | Find a counterfactual for a given sample |
321
318
  | `interpret_counterfactual(sample, counterfactual, ...)` | Generate a human-readable explanation |
322
319
 
@@ -3,15 +3,18 @@ from unittest.mock import MagicMock, patch
3
3
 
4
4
  import pandas as pd
5
5
  import pytest
6
+ import requests
6
7
 
7
8
  from proxyml.client import (
8
9
  _cast_column,
9
10
  _base_url,
10
11
  _headers,
12
+ health_check,
11
13
  delete_model,
12
14
  delete_schema,
13
15
  diff_models,
14
16
  explain_local,
17
+ explain_local_batch,
15
18
  export_surrogate,
16
19
  fetch_schema,
17
20
  find_counterfactual,
@@ -29,9 +32,35 @@ from proxyml.client import (
29
32
  rotate_key,
30
33
  synthesize_data,
31
34
  train_surrogate,
35
+ update_model,
32
36
  )
33
37
 
34
38
 
39
+ # ---------------------------------------------------------------------------
40
+ # health_check
41
+ # ---------------------------------------------------------------------------
42
+
43
+ @patch("proxyml.client.requests.get")
44
+ def test_health_check_success(mock_get):
45
+ mock_get.return_value = _mock_response(200, {"status": "ok", "model_loaded": True, "version": "0.1.0"})
46
+ result = health_check()
47
+ assert result == {"status": "ok", "model_loaded": True, "version": "0.1.0"}
48
+ mock_get.assert_called_once()
49
+ assert mock_get.call_args.kwargs["url"].endswith("/health")
50
+ assert "headers" not in mock_get.call_args.kwargs # no auth header
51
+
52
+
53
+ @patch("proxyml.client.requests.get")
54
+ def test_health_check_failure_returns_none(mock_get):
55
+ mock_get.return_value = _mock_response(503, {"detail": "unavailable"})
56
+ assert health_check() is None
57
+
58
+
59
+ @patch("proxyml.client.requests.get", side_effect=requests.exceptions.ConnectionError("connection refused"))
60
+ def test_health_check_network_error_returns_none(mock_get):
61
+ assert health_check() is None
62
+
63
+
35
64
  # ---------------------------------------------------------------------------
36
65
  # _headers
37
66
  # ---------------------------------------------------------------------------
@@ -310,7 +339,7 @@ def test_train_surrogate_with_metadata(mock_post):
310
339
  mock_post.return_value = _mock_response(200, {
311
340
  "version": "abc-123", "trained_at": "2026-04-19T12:00:00",
312
341
  "task": "regression", "name": "v1", "comments": "test run",
313
- "feature_names": None, "metrics": {"r2": 0.95}, "warning": None,
342
+ "feature_names": None, "metrics": {"r2": 0.95}, "warnings": [],
314
343
  })
315
344
  result = train_surrogate(
316
345
  samples=[[1.0, 2.0]], predictions=[3.0],
@@ -345,11 +374,24 @@ def test_train_surrogate_omits_none_metadata(mock_post):
345
374
  def test_list_models_success(mock_get):
346
375
  models = [{"version": "abc-123", "task": "regression", "name": "v1",
347
376
  "comments": None, "feature_names": None, "metrics": {"r2": 0.9},
348
- "trained_at": "2026-04-19T12:00:00"}]
349
- mock_get.return_value = _mock_response(200, {"models": models})
377
+ "trained_at": "2026-04-19T12:00:00", "mlflow_run_id": None}]
378
+ mock_get.return_value = _mock_response(200, {"models": models, "total": 1})
350
379
  result = list_models()
351
- assert result == models
352
- mock_get.assert_called_once_with(endpoint="/surrogate/models", params={})
380
+ assert result == {"models": models, "total": 1}
381
+ mock_get.assert_called_once_with(endpoint="/surrogate/models", params={"limit": 50, "offset": 0})
382
+
383
+
384
+ @patch("proxyml.client.get")
385
+ def test_list_models_pagination(mock_get):
386
+ models = [{"version": f"v{i}", "task": "regression", "name": None,
387
+ "comments": None, "feature_names": None, "metrics": None,
388
+ "trained_at": "2026-05-01T00:00:00", "mlflow_run_id": None}
389
+ for i in range(10)]
390
+ mock_get.return_value = _mock_response(200, {"models": models, "total": 42})
391
+ result = list_models(limit=10, offset=20)
392
+ assert result["total"] == 42
393
+ assert len(result["models"]) == 10
394
+ mock_get.assert_called_once_with(endpoint="/surrogate/models", params={"limit": 10, "offset": 20})
353
395
 
354
396
 
355
397
  @patch("proxyml.client.get")
@@ -798,3 +840,92 @@ def test_explain_local_with_version(mock_post):
798
840
  def test_explain_local_failure_returns_none(mock_post):
799
841
  mock_post.return_value = _mock_response(422, {"detail": "bad input"})
800
842
  assert explain_local(instance=[1.0, 2.0]) is None
843
+
844
+
845
+ # ---------------------------------------------------------------------------
846
+ # explain_local_batch
847
+ # ---------------------------------------------------------------------------
848
+
849
+ _EXPLAIN_LOCAL_BATCH_RESPONSE = {
850
+ "results": [_EXPLAIN_LOCAL_RESPONSE, _EXPLAIN_LOCAL_RESPONSE],
851
+ "model_version": "surrogate-abc-regression",
852
+ "task": "regression",
853
+ "schema_warning": None,
854
+ }
855
+
856
+
857
+ @patch("proxyml.client.post")
858
+ def test_explain_local_batch_success(mock_post):
859
+ mock_post.return_value = _mock_response(200, _EXPLAIN_LOCAL_BATCH_RESPONSE)
860
+ instances = [[1.0, 2.0], [3.0, 4.0]]
861
+ result = explain_local_batch(instances=instances)
862
+ assert result == _EXPLAIN_LOCAL_BATCH_RESPONSE
863
+ payload = mock_post.call_args.kwargs["payload"]
864
+ assert payload["instances"] == instances
865
+ assert "version" not in payload
866
+
867
+
868
+ @patch("proxyml.client.post")
869
+ def test_explain_local_batch_with_version(mock_post):
870
+ mock_post.return_value = _mock_response(200, _EXPLAIN_LOCAL_BATCH_RESPONSE)
871
+ uid = "550e8400-e29b-41d4-a716-446655440000"
872
+ explain_local_batch(instances=[[1.0, 2.0]], version=uid)
873
+ payload = mock_post.call_args.kwargs["payload"]
874
+ assert payload["version"] == uid
875
+
876
+
877
+ @patch("proxyml.client.post")
878
+ def test_explain_local_batch_failure_returns_none(mock_post):
879
+ mock_post.return_value = _mock_response(422, {"detail": "bad input"})
880
+ assert explain_local_batch(instances=[[1.0, 2.0]]) is None
881
+
882
+
883
+ # ---------------------------------------------------------------------------
884
+ # update_model
885
+ # ---------------------------------------------------------------------------
886
+
887
+ @patch("proxyml.client.patch")
888
+ def test_update_model_name(mock_patch):
889
+ meta = {"version": "abc-123", "task": "regression", "name": "new name",
890
+ "comments": None, "feature_names": None, "metrics": None,
891
+ "trained_at": "2026-05-07T00:00:00", "mlflow_run_id": None}
892
+ mock_patch.return_value = _mock_response(200, meta)
893
+ result = update_model("abc-123", name="new name")
894
+ assert result == meta
895
+ payload = mock_patch.call_args.kwargs["payload"]
896
+ assert payload == {"name": "new name"}
897
+
898
+
899
+ @patch("proxyml.client.patch")
900
+ def test_update_model_comments(mock_patch):
901
+ mock_patch.return_value = _mock_response(200, {})
902
+ update_model("abc-123", comments="some notes")
903
+ payload = mock_patch.call_args.kwargs["payload"]
904
+ assert payload == {"comments": "some notes"}
905
+
906
+
907
+ @patch("proxyml.client.patch")
908
+ def test_update_model_both_fields(mock_patch):
909
+ mock_patch.return_value = _mock_response(200, {})
910
+ update_model("abc-123", name="prod", comments="v2 data")
911
+ payload = mock_patch.call_args.kwargs["payload"]
912
+ assert payload == {"name": "prod", "comments": "v2 data"}
913
+
914
+
915
+ @patch("proxyml.client.patch")
916
+ def test_update_model_clear_field(mock_patch):
917
+ mock_patch.return_value = _mock_response(200, {})
918
+ update_model("abc-123", comments=None)
919
+ payload = mock_patch.call_args.kwargs["payload"]
920
+ assert payload == {"comments": None}
921
+
922
+
923
+ def test_update_model_no_fields_raises():
924
+ with pytest.raises(ValueError):
925
+ update_model("abc-123")
926
+
927
+
928
+ @patch("proxyml.client.patch")
929
+ def test_update_model_failure_returns_none(mock_patch):
930
+ mock_patch.return_value = _mock_response(404, {"detail": "not found"})
931
+ assert update_model("abc-123", name="x") is None
File without changes
File without changes
File without changes