Skip to content

Client

Thin REST client for the OpenML API — Python port of the subset of org.openml.apiconnector.io.OpenmlConnector that ProcessDataset uses.

Auth: the API key comes from the environment (Java's connector holds it as a field on the instance; here we read it in the constructor so the client is explicit about what it's using). Resolution order: explicit api_key argument, then OPENML_TEST_API_KEY (only when test=True), then OPENML_API_KEY. The test server's public demo key — Java's client_write_test connector — is no longer hardcoded; export it as OPENML_TEST_API_KEY to run against --test without a personal key.

Endpoints (all under base_url, default https://www.openml.org/api/v1/): * POST /data/features — data_features_upload * POST /data/qualities — data_qualities_upload * POST /data/status/update — data_status_update * GET /data/unprocessed/{engine_id}/{mode} — data_unprocessed * POST /data/qualities/unprocessed/{engine_id}/{mode}[/feature][/{tag}] — data_qualities_unprocessed * GET /data/{did} — data_get * POST /run/evaluate — run_evaluate_upload * GET /evaluation/request/{engine_id}/{mode}/{n}[/{k}/{v}] — evaluation_request

Server errors come back as <oml:api_error><oml:code>…</oml:code> <oml:message>…</oml:message>[<oml:additional_information>…] </oml:api_error> and are raised as OpenmlApiError(code, message) — mirrors Java's ApiException so callers can check err.code against the OpenML status codes (431 = dataset already processed, 441 = features already uploaded, 542 = no unprocessed datasets remaining, …).

PROD_API_KEY_ENV_VAR = 'OPENML_API_KEY' module-attribute

PROD_BASE_URL = 'https://www.openml.org/api/v1/' module-attribute

TEST_API_KEY_ENV_VAR = 'OPENML_TEST_API_KEY' module-attribute

TEST_BASE_URL = 'https://test.openml.org/api/v1/' module-attribute

OpenmlClient

Stateful REST client — port of the OpenmlConnector methods used by ProcessDataset. Construct with an explicit api_key or let it fall back to OPENML_TEST_API_KEY (test server) / OPENML_API_KEY; the key is sent as a multipart form field on POSTs (Java: entity.addPart("api_key", ...)) and as ?api_key= on GETs (Java: doApiGetRequest).

Source code in src/client.py
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
class OpenmlClient:
    """Stateful REST client — port of the ``OpenmlConnector`` methods used by
    ``ProcessDataset``. Construct with an explicit ``api_key`` or let it fall
    back to ``OPENML_TEST_API_KEY`` (test server) / ``OPENML_API_KEY``; the
    key is sent as a multipart form field on POSTs (Java:
    ``entity.addPart("api_key", ...)``) and as ``?api_key=`` on GETs
    (Java: ``doApiGetRequest``)."""

    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: Optional[str] = None,
        *,
        test: bool = False,
    ) -> None:
        # Server resolution: explicit base_url wins; otherwise pick by ``test``.
        if base_url is None:
            base_url = TEST_BASE_URL if test else PROD_BASE_URL

        # Key resolution: explicit api_key > OPENML_TEST_API_KEY (test
        # server only) > OPENML_API_KEY. Neither server has a default key;
        # a missing key fails loud with a hint at the right env vars.
        resolved = api_key
        if resolved is None and test:
            resolved = os.environ.get(TEST_API_KEY_ENV_VAR)
        if resolved is None:
            resolved = os.environ.get(PROD_API_KEY_ENV_VAR)
        if not resolved:
            env_hint = (
                f"{TEST_API_KEY_ENV_VAR} or {PROD_API_KEY_ENV_VAR}"
                if test
                else PROD_API_KEY_ENV_VAR
            )
            raise OpenmlConfigError(
                "OpenML API key not set. Pass api_key=... when constructing "
                f"the client or set the {env_hint} environment variable(s)."
            )
        self.api_key = resolved
        self.base_url = base_url if base_url.endswith("/") else base_url + "/"

    # ----------------------------------------------------------------------
    # Low-level request helpers
    # ----------------------------------------------------------------------

    def _post(
        self,
        path: str,
        *,
        files: Optional[dict] = None,
        fields: Optional[dict[str, str]] = None,
    ) -> dict:
        """POST multipart form data and return the parsed XML envelope.

        ``files`` carries file parts (``"description"`` for uploads); ``fields``
        carries string parts (``"data_id"``, ``"status"`` for status update).
        ``api_key`` is always appended as a string part, matching Java's
        ``HttpConnector.doApiPostRequest``."""
        form_files: dict = dict(files or {})
        form_fields: dict[str, str] = dict(fields or {})
        form_fields["api_key"] = self.api_key

        # requests sends multipart/form-data when ``files=`` is set. String
        # parts are represented as ``(None, value)`` tuples so they attach as
        # form fields rather than file uploads.
        for name, value in form_fields.items():
            form_files[name] = (None, value)

        url = self.base_url + path.lstrip("/")
        response = requests.post(url, files=form_files)
        response.raise_for_status()
        return self._parse(response.content)

    def _get_xml(self, path: str) -> dict:
        """GET ``path`` with ``?api_key=`` appended and return parsed XML."""
        url = self.base_url + path.lstrip("/")
        response = requests.get(url, params={"api_key": self.api_key})
        response.raise_for_status()
        return self._parse(response.content)

    @staticmethod
    def _parse(body: bytes) -> dict:
        """Parse an OpenML XML response, raising ``OpenmlApiError`` for
        ``oml:api_error`` envelopes (Java's ``wrapHttpResponse`` fallthrough)."""
        parsed = xmltodict.parse(body)
        if "oml:api_error" in parsed:
            err = parsed["oml:api_error"]
            code = int(err["oml:code"])
            message = err.get("oml:message") or ""
            additional = err.get("oml:additional_information")
            if additional:
                message = f"{message}: {additional}"
            raise OpenmlApiError(code, message)
        return parsed

    # ----------------------------------------------------------------------
    # ProcessDataset-facing methods
    # ----------------------------------------------------------------------

    def data_get(self, did: int) -> dict:
        """Port of ``OpenmlBasicConnector.dataGet`` — GET ``/data/{did}``,
        return the ``oml:data_set_description`` node. Used by MergeDataset to
        fetch the ARFF URL server-side (the description's ``oml:url`` is
        relative to whatever server this client targets)."""
        parsed = self._get_xml(f"data/{did}")
        return parsed["oml:data_set_description"]

    def data_features_upload(self, features: DataFeature) -> int:
        """Port of ``OpenmlConnector.dataFeaturesUpload`` — POST ``/data/features``
        with the serialized XML as the ``description`` file part. Returns the
        ``did`` the server echoes back (``<oml:data_features_upload><oml:did>``)."""
        xml_bytes = features_to_xml(features, pretty=False).encode("utf-8")
        files = {"description": ("description.xml", xml_bytes, "application/xml")}
        parsed = self._post("data/features", files=files)
        return int(parsed["oml:data_features_upload"]["oml:did"])

    def data_qualities_upload(self, qualities: DataQuality) -> int:
        """Port of ``OpenmlConnector.dataQualitiesUpload`` — POST ``/data/qualities``.
        Returns the ``did`` the server echoes back."""
        xml_bytes = qualities_to_xml(qualities, pretty=False).encode("utf-8")
        files = {"description": ("description.xml", xml_bytes, "application/xml")}
        parsed = self._post("data/qualities", files=files)
        return int(parsed["oml:data_qualities_upload"]["oml:did"])

    def data_status_update(self, did: int, status: str) -> int:
        """Port of ``OpenmlBasicConnector.dataStatusUpdate`` — POST
        ``/data/status/update`` with ``data_id`` + ``status`` string parts.
        Returns the ``data_id`` the server echoes back."""
        fields = {"data_id": str(did), "status": status}
        parsed = self._post("data/status/update", fields=fields)
        return int(parsed["oml:data_status_update"]["oml:data_id"])

    def data_unprocessed(self, engine_id: int, mode: str) -> list[int]:
        """Port of ``OpenmlBasicConnector.dataUnprocessed`` — GET
        ``/data/unprocessed/{engine_id}/{mode}``. Returns the list of dataset
        ids needing processing. An empty page comes back as an ``OpenmlApiError``
        whose message contains "No unprocessed" (Java catches the same string
        in ``ProcessDataset.fetchUnprocessed``)."""
        parsed = self._get_xml(f"data/unprocessed/{engine_id}/{mode}")
        root = parsed.get("oml:data_unprocessed") or {}
        datasets = root.get("oml:dataset", [])
        if isinstance(datasets, dict):  # single-dataset edge case
            datasets = [datasets]
        return [int(d["oml:did"]) for d in datasets]

    def data_qualities_unprocessed(
        self,
        engine_id: int,
        mode: str,
        qualities: list[str],
        *,
        feature_qualities: bool = False,
        priority_tag: Optional[str] = None,
    ) -> list[int]:
        """Port of ``OpenmlBasicConnector.dataqualitiesUnprocessed`` — POST
        ``/data/qualities/unprocessed/{engine_id}/{mode}`` with a comma-joined
        ``qualities`` field, optional ``/feature`` segment and optional
        ``/{priority_tag}`` suffix. Returns dataset ids missing any of the
        listed qualities. As with ``data_unprocessed``, an empty page comes
        back as ``OpenmlApiError`` whose message contains "No unprocessed"."""
        path = f"data/qualities/unprocessed/{engine_id}/{mode}"
        if feature_qualities:
            path += "/feature"
        if priority_tag:
            path += f"/{priority_tag}"
        fields = {"qualities": ",".join(qualities)}
        parsed = self._post(path, fields=fields)
        root = parsed.get("oml:data_unprocessed") or {}
        datasets = root.get("oml:dataset", [])
        if isinstance(datasets, dict):  # single-dataset edge case
            datasets = [datasets]
        return [int(d["oml:did"]) for d in datasets]

    # ----------------------------------------------------------------------
    # EvaluateRun-facing methods
    # ----------------------------------------------------------------------

    def run_evaluate_upload(self, run_eval: RunEvaluation) -> int:
        """Port of ``OpenmlConnector.runEvaluate`` — POST ``/run/evaluate``
        with the serialized XML as the ``description`` file part. Returns the
        ``run_id`` the server echoes back
        (``<oml:run_evaluate><oml:run_id>``)."""
        xml_bytes = run_evaluation_to_xml(run_eval, pretty=False).encode("utf-8")
        files = {"description": ("description.xml", xml_bytes, "application/xml")}
        parsed = self._post("run/evaluate", files=files)
        return int(parsed["oml:run_evaluate"]["oml:run_id"])

    def evaluation_request(
        self,
        engine_id: int,
        mode: str,
        num_requests: int,
        filters: Optional[dict[str, str]] = None,
    ) -> list[int]:
        """Port of ``OpenmlBasicConnector.evaluationRequest`` — GET
        ``/evaluation/request/{engine_id}/{mode}/{num_requests}`` followed by
        ``/{key}/{value}`` for each filter. Returns the list of run ids to
        evaluate.

        When no runs remain the server returns API error 1013
        (``NO_UNEVALUATED_RUNS`` — see ``ApiErrorMapping``), which surfaces here
        as ``OpenmlApiError``; callers should catch it to stop the loop."""
        path = f"evaluation/request/{engine_id}/{mode}/{num_requests}"
        if filters:
            for key, value in filters.items():
                path += f"/{key}/{value}"
        parsed = self._get_xml(path)
        root = parsed.get("oml:evaluation_request", {})
        runs = root.get("oml:run", [])
        if isinstance(runs, dict):  # single-run edge case
            runs = [runs]
        return [int(r["oml:run_id"]) for r in runs]

api_key = resolved instance-attribute

base_url = base_url if base_url.endswith('/') else base_url + '/' instance-attribute

__init__(api_key=None, base_url=None, *, test=False)

Source code in src/client.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def __init__(
    self,
    api_key: Optional[str] = None,
    base_url: Optional[str] = None,
    *,
    test: bool = False,
) -> None:
    # Server resolution: explicit base_url wins; otherwise pick by ``test``.
    if base_url is None:
        base_url = TEST_BASE_URL if test else PROD_BASE_URL

    # Key resolution: explicit api_key > OPENML_TEST_API_KEY (test
    # server only) > OPENML_API_KEY. Neither server has a default key;
    # a missing key fails loud with a hint at the right env vars.
    resolved = api_key
    if resolved is None and test:
        resolved = os.environ.get(TEST_API_KEY_ENV_VAR)
    if resolved is None:
        resolved = os.environ.get(PROD_API_KEY_ENV_VAR)
    if not resolved:
        env_hint = (
            f"{TEST_API_KEY_ENV_VAR} or {PROD_API_KEY_ENV_VAR}"
            if test
            else PROD_API_KEY_ENV_VAR
        )
        raise OpenmlConfigError(
            "OpenML API key not set. Pass api_key=... when constructing "
            f"the client or set the {env_hint} environment variable(s)."
        )
    self.api_key = resolved
    self.base_url = base_url if base_url.endswith("/") else base_url + "/"

data_features_upload(features)

Port of OpenmlConnector.dataFeaturesUpload — POST /data/features with the serialized XML as the description file part. Returns the did the server echoes back (<oml:data_features_upload><oml:did>).

Source code in src/client.py
163
164
165
166
167
168
169
170
def data_features_upload(self, features: DataFeature) -> int:
    """Port of ``OpenmlConnector.dataFeaturesUpload`` — POST ``/data/features``
    with the serialized XML as the ``description`` file part. Returns the
    ``did`` the server echoes back (``<oml:data_features_upload><oml:did>``)."""
    xml_bytes = features_to_xml(features, pretty=False).encode("utf-8")
    files = {"description": ("description.xml", xml_bytes, "application/xml")}
    parsed = self._post("data/features", files=files)
    return int(parsed["oml:data_features_upload"]["oml:did"])

data_get(did)

Port of OpenmlBasicConnector.dataGet — GET /data/{did}, return the oml:data_set_description node. Used by MergeDataset to fetch the ARFF URL server-side (the description's oml:url is relative to whatever server this client targets).

Source code in src/client.py
155
156
157
158
159
160
161
def data_get(self, did: int) -> dict:
    """Port of ``OpenmlBasicConnector.dataGet`` — GET ``/data/{did}``,
    return the ``oml:data_set_description`` node. Used by MergeDataset to
    fetch the ARFF URL server-side (the description's ``oml:url`` is
    relative to whatever server this client targets)."""
    parsed = self._get_xml(f"data/{did}")
    return parsed["oml:data_set_description"]

data_qualities_unprocessed(engine_id, mode, qualities, *, feature_qualities=False, priority_tag=None)

Port of OpenmlBasicConnector.dataqualitiesUnprocessed — POST /data/qualities/unprocessed/{engine_id}/{mode} with a comma-joined qualities field, optional /feature segment and optional /{priority_tag} suffix. Returns dataset ids missing any of the listed qualities. As with data_unprocessed, an empty page comes back as OpenmlApiError whose message contains "No unprocessed".

Source code in src/client.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def data_qualities_unprocessed(
    self,
    engine_id: int,
    mode: str,
    qualities: list[str],
    *,
    feature_qualities: bool = False,
    priority_tag: Optional[str] = None,
) -> list[int]:
    """Port of ``OpenmlBasicConnector.dataqualitiesUnprocessed`` — POST
    ``/data/qualities/unprocessed/{engine_id}/{mode}`` with a comma-joined
    ``qualities`` field, optional ``/feature`` segment and optional
    ``/{priority_tag}`` suffix. Returns dataset ids missing any of the
    listed qualities. As with ``data_unprocessed``, an empty page comes
    back as ``OpenmlApiError`` whose message contains "No unprocessed"."""
    path = f"data/qualities/unprocessed/{engine_id}/{mode}"
    if feature_qualities:
        path += "/feature"
    if priority_tag:
        path += f"/{priority_tag}"
    fields = {"qualities": ",".join(qualities)}
    parsed = self._post(path, fields=fields)
    root = parsed.get("oml:data_unprocessed") or {}
    datasets = root.get("oml:dataset", [])
    if isinstance(datasets, dict):  # single-dataset edge case
        datasets = [datasets]
    return [int(d["oml:did"]) for d in datasets]

data_qualities_upload(qualities)

Port of OpenmlConnector.dataQualitiesUpload — POST /data/qualities. Returns the did the server echoes back.

Source code in src/client.py
172
173
174
175
176
177
178
def data_qualities_upload(self, qualities: DataQuality) -> int:
    """Port of ``OpenmlConnector.dataQualitiesUpload`` — POST ``/data/qualities``.
    Returns the ``did`` the server echoes back."""
    xml_bytes = qualities_to_xml(qualities, pretty=False).encode("utf-8")
    files = {"description": ("description.xml", xml_bytes, "application/xml")}
    parsed = self._post("data/qualities", files=files)
    return int(parsed["oml:data_qualities_upload"]["oml:did"])

data_status_update(did, status)

Port of OpenmlBasicConnector.dataStatusUpdate — POST /data/status/update with data_id + status string parts. Returns the data_id the server echoes back.

Source code in src/client.py
180
181
182
183
184
185
186
def data_status_update(self, did: int, status: str) -> int:
    """Port of ``OpenmlBasicConnector.dataStatusUpdate`` — POST
    ``/data/status/update`` with ``data_id`` + ``status`` string parts.
    Returns the ``data_id`` the server echoes back."""
    fields = {"data_id": str(did), "status": status}
    parsed = self._post("data/status/update", fields=fields)
    return int(parsed["oml:data_status_update"]["oml:data_id"])

data_unprocessed(engine_id, mode)

Port of OpenmlBasicConnector.dataUnprocessed — GET /data/unprocessed/{engine_id}/{mode}. Returns the list of dataset ids needing processing. An empty page comes back as an OpenmlApiError whose message contains "No unprocessed" (Java catches the same string in ProcessDataset.fetchUnprocessed).

Source code in src/client.py
188
189
190
191
192
193
194
195
196
197
198
199
def data_unprocessed(self, engine_id: int, mode: str) -> list[int]:
    """Port of ``OpenmlBasicConnector.dataUnprocessed`` — GET
    ``/data/unprocessed/{engine_id}/{mode}``. Returns the list of dataset
    ids needing processing. An empty page comes back as an ``OpenmlApiError``
    whose message contains "No unprocessed" (Java catches the same string
    in ``ProcessDataset.fetchUnprocessed``)."""
    parsed = self._get_xml(f"data/unprocessed/{engine_id}/{mode}")
    root = parsed.get("oml:data_unprocessed") or {}
    datasets = root.get("oml:dataset", [])
    if isinstance(datasets, dict):  # single-dataset edge case
        datasets = [datasets]
    return [int(d["oml:did"]) for d in datasets]

evaluation_request(engine_id, mode, num_requests, filters=None)

Port of OpenmlBasicConnector.evaluationRequest — GET /evaluation/request/{engine_id}/{mode}/{num_requests} followed by /{key}/{value} for each filter. Returns the list of run ids to evaluate.

When no runs remain the server returns API error 1013 (NO_UNEVALUATED_RUNS — see ApiErrorMapping), which surfaces here as OpenmlApiError; callers should catch it to stop the loop.

Source code in src/client.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def evaluation_request(
    self,
    engine_id: int,
    mode: str,
    num_requests: int,
    filters: Optional[dict[str, str]] = None,
) -> list[int]:
    """Port of ``OpenmlBasicConnector.evaluationRequest`` — GET
    ``/evaluation/request/{engine_id}/{mode}/{num_requests}`` followed by
    ``/{key}/{value}`` for each filter. Returns the list of run ids to
    evaluate.

    When no runs remain the server returns API error 1013
    (``NO_UNEVALUATED_RUNS`` — see ``ApiErrorMapping``), which surfaces here
    as ``OpenmlApiError``; callers should catch it to stop the loop."""
    path = f"evaluation/request/{engine_id}/{mode}/{num_requests}"
    if filters:
        for key, value in filters.items():
            path += f"/{key}/{value}"
    parsed = self._get_xml(path)
    root = parsed.get("oml:evaluation_request", {})
    runs = root.get("oml:run", [])
    if isinstance(runs, dict):  # single-run edge case
        runs = [runs]
    return [int(r["oml:run_id"]) for r in runs]

run_evaluate_upload(run_eval)

Port of OpenmlConnector.runEvaluate — POST /run/evaluate with the serialized XML as the description file part. Returns the run_id the server echoes back (<oml:run_evaluate><oml:run_id>).

Source code in src/client.py
233
234
235
236
237
238
239
240
241
def run_evaluate_upload(self, run_eval: RunEvaluation) -> int:
    """Port of ``OpenmlConnector.runEvaluate`` — POST ``/run/evaluate``
    with the serialized XML as the ``description`` file part. Returns the
    ``run_id`` the server echoes back
    (``<oml:run_evaluate><oml:run_id>``)."""
    xml_bytes = run_evaluation_to_xml(run_eval, pretty=False).encode("utf-8")
    files = {"description": ("description.xml", xml_bytes, "application/xml")}
    parsed = self._post("run/evaluate", files=files)
    return int(parsed["oml:run_evaluate"]["oml:run_id"])