Skip to content

Commit fabbc28

Browse files
Omswastik-11geetu040SimonBlankepre-commit-ci[bot]satvshr
authored
[ENH] V1 -> V2 Migration - Flows (module) (#1609)
Fixes #1601 --------- Signed-off-by: Omswastik-11 <omswastikpanda11@gmail.com> Co-authored-by: geetu040 <raoarmaghanshakir040@gmail.com> Co-authored-by: Simon Blanke <simon.blanke@yahoo.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Satvik Mishra <112589278+satvshr@users.noreply.github.com> Co-authored-by: Matthias Feurer <lists@matthiasfeurer.de> Co-authored-by: Pieter Gijsbers <p.gijsbers@tue.nl> Co-authored-by: Franz Király <fkiraly@gcos.ai> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent c631b28 commit fabbc28

8 files changed

Lines changed: 685 additions & 220 deletions

File tree

openml/_api/resources/base/resources.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ class FlowAPI(ResourceAPI):
7878

7979
resource_type: ResourceType = ResourceType.FLOW
8080

81+
@abstractmethod
82+
def get(self, flow_id: int, *, reset_cache: bool = False) -> OpenMLFlow: ...
83+
84+
@abstractmethod
85+
def list(
86+
self,
87+
limit: int | None = None,
88+
offset: int | None = None,
89+
tag: str | None = None,
90+
uploader: str | None = None,
91+
) -> pd.DataFrame: ...
92+
93+
@abstractmethod
94+
def exists(self, name: str, external_version: str) -> int | bool: ...
95+
8196

8297
class StudyAPI(ResourceAPI):
8398
"""Abstract API interface for study resources."""

openml/_api/resources/flow.py

Lines changed: 283 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,292 @@
11
from __future__ import annotations
22

3+
from typing import Any, NoReturn
4+
from urllib.parse import quote
5+
6+
import pandas as pd
7+
import xmltodict
8+
9+
from openml.exceptions import OpenMLServerException, OpenMLServerNoResult
10+
from openml.flows.flow import OpenMLFlow
11+
312
from .base import FlowAPI, ResourceV1API, ResourceV2API
413

514

615
class FlowV1API(ResourceV1API, FlowAPI):
7-
"""Version 1 API implementation for flow resources."""
16+
def get(
17+
self,
18+
flow_id: int,
19+
*,
20+
reset_cache: bool = False,
21+
) -> OpenMLFlow:
22+
"""Get a flow from the OpenML server.
23+
24+
Parameters
25+
----------
26+
flow_id : int
27+
The ID of the flow to retrieve.
28+
reset_cache : bool, optional (default=False)
29+
Whether to reset the cache for this request.
30+
31+
Returns
32+
-------
33+
OpenMLFlow
34+
The retrieved flow object.
35+
"""
36+
response = self._http.get(
37+
f"flow/{flow_id}",
38+
enable_cache=True,
39+
refresh_cache=reset_cache,
40+
)
41+
flow_xml = response.text
42+
return OpenMLFlow._from_dict(xmltodict.parse(flow_xml))
43+
44+
def exists(self, name: str, external_version: str) -> int | bool:
45+
"""Check if a flow exists on the OpenML server.
46+
47+
Parameters
48+
----------
49+
name : str
50+
The name of the flow.
51+
external_version : str
52+
The external version of the flow.
53+
54+
Returns
55+
-------
56+
int | bool
57+
The flow ID if the flow exists, False otherwise.
58+
"""
59+
if not (isinstance(name, str) and len(name) > 0):
60+
raise ValueError("Argument 'name' should be a non-empty string")
61+
if not (isinstance(external_version, str) and len(external_version) > 0):
62+
raise ValueError("Argument 'version' should be a non-empty string")
63+
64+
data: dict[str, str] = {"name": name, "external_version": external_version}
65+
if self._http.api_key:
66+
data["api_key"] = self._http.api_key
67+
68+
xml_response = self._http.post("flow/exists", data=data, use_api_key=False).text
69+
result_dict = xmltodict.parse(xml_response)
70+
# Detect error payloads and raise
71+
if "oml:error" in result_dict:
72+
err = result_dict["oml:error"]
73+
code = int(err.get("oml:code", 0)) if "oml:code" in err else None
74+
message = err.get("oml:message", "Server returned an error")
75+
raise OpenMLServerException(message=message, code=code)
76+
77+
flow_id = int(result_dict["oml:flow_exists"]["oml:id"])
78+
return flow_id if flow_id > 0 else False
79+
80+
def list(
81+
self,
82+
limit: int | None = None,
83+
offset: int | None = None,
84+
tag: str | None = None,
85+
uploader: str | None = None,
86+
) -> pd.DataFrame:
87+
"""List flows on the OpenML server.
88+
89+
Parameters
90+
----------
91+
limit : int, optional
92+
The maximum number of flows to return.
93+
By default, all flows are returned.
94+
offset : int, optional
95+
The number of flows to skip before starting to collect the result set.
96+
By default, no flows are skipped.
97+
tag : str, optional
98+
The tag to filter flows by.
99+
By default, no tag filtering is applied.
100+
uploader : str, optional
101+
The user to filter flows by.
102+
By default, no user filtering is applied.
103+
104+
Returns
105+
-------
106+
pd.DataFrame
107+
A DataFrame containing the list of flows.
108+
"""
109+
api_call = "flow/list"
110+
if limit is not None:
111+
api_call += f"/limit/{limit}"
112+
if offset is not None:
113+
api_call += f"/offset/{offset}"
114+
if tag is not None:
115+
api_call += f"/tag/{quote(str(tag), safe='')}"
116+
if uploader is not None:
117+
api_call += f"/uploader/{quote(str(uploader), safe='')}"
118+
119+
response = self._http.get(api_call)
120+
xml_string = response.text
121+
flows_dict = xmltodict.parse(xml_string, force_list=("oml:flow",))
122+
123+
if "oml:error" in flows_dict:
124+
err = flows_dict["oml:error"]
125+
code = int(err.get("oml:code", 0)) if "oml:code" in err else None
126+
message = err.get("oml:message", "Server returned an error")
127+
raise OpenMLServerException(message=message, code=code)
128+
129+
assert isinstance(flows_dict["oml:flows"]["oml:flow"], list), type(flows_dict["oml:flows"])
130+
assert flows_dict["oml:flows"]["@xmlns:oml"] == "http://openml.org/openml", flows_dict[
131+
"oml:flows"
132+
]["@xmlns:oml"]
133+
134+
flows: dict[int, dict[str, Any]] = {}
135+
for flow_ in flows_dict["oml:flows"]["oml:flow"]:
136+
fid = int(flow_["oml:id"])
137+
flow_row = {
138+
"id": fid,
139+
"full_name": flow_["oml:full_name"],
140+
"name": flow_["oml:name"],
141+
"version": flow_["oml:version"],
142+
"external_version": flow_["oml:external_version"],
143+
"uploader": flow_["oml:uploader"],
144+
}
145+
flows[fid] = flow_row
146+
147+
return pd.DataFrame.from_dict(flows, orient="index")
8148

9149

10150
class FlowV2API(ResourceV2API, FlowAPI):
11-
"""Version 2 API implementation for flow resources."""
151+
def get(
152+
self,
153+
flow_id: int,
154+
*,
155+
reset_cache: bool = False,
156+
) -> OpenMLFlow:
157+
"""Get a flow from the OpenML v2 server.
158+
159+
Parameters
160+
----------
161+
flow_id : int
162+
The ID of the flow to retrieve.
163+
reset_cache : bool, optional (default=False)
164+
Whether to reset the cache for this request.
165+
166+
Returns
167+
-------
168+
OpenMLFlow
169+
The retrieved flow object.
170+
"""
171+
response = self._http.get(
172+
f"flows/{flow_id}/",
173+
enable_cache=True,
174+
refresh_cache=reset_cache,
175+
)
176+
flow_json = response.json()
177+
178+
# Convert v2 JSON to v1-compatible dict for OpenMLFlow._from_dict()
179+
flow_dict = self._convert_v2_to_v1_format(flow_json)
180+
return OpenMLFlow._from_dict(flow_dict)
181+
182+
def exists(self, name: str, external_version: str) -> int | bool:
183+
"""Check if a flow exists on the OpenML v2 server.
184+
185+
Parameters
186+
----------
187+
name : str
188+
The name of the flow.
189+
external_version : str
190+
The external version of the flow.
191+
192+
Returns
193+
-------
194+
int | bool
195+
The flow ID if the flow exists, False otherwise.
196+
"""
197+
if not (isinstance(name, str) and len(name) > 0):
198+
raise ValueError("Argument 'name' should be a non-empty string")
199+
if not (isinstance(external_version, str) and len(external_version) > 0):
200+
raise ValueError("Argument 'version' should be a non-empty string")
201+
202+
name_path = quote(name, safe="")
203+
version_path = quote(external_version, safe="")
204+
205+
try:
206+
response = self._http.get(f"flows/exists/{name_path}/{version_path}/")
207+
result = response.json()
208+
flow_id: int | bool = result.get("flow_id", False)
209+
return flow_id
210+
except OpenMLServerNoResult:
211+
return False
212+
except OpenMLServerException as err:
213+
if err.code == 404:
214+
return False
215+
raise
216+
217+
def list(
218+
self,
219+
limit: int | None = None, # noqa: ARG002
220+
offset: int | None = None, # noqa: ARG002
221+
tag: str | None = None, # noqa: ARG002
222+
uploader: str | None = None, # noqa: ARG002
223+
) -> NoReturn:
224+
self._not_supported(method="list")
225+
226+
@staticmethod
227+
def _convert_v2_to_v1_format(v2_json: dict[str, Any]) -> dict[str, Any]:
228+
"""Convert v2 JSON response to v1 XML-dict format for OpenMLFlow._from_dict().
229+
230+
Parameters
231+
----------
232+
v2_json : dict
233+
The v2 JSON response from the server.
234+
235+
Returns
236+
-------
237+
dict
238+
A dictionary matching the v1 XML structure expected by OpenMLFlow._from_dict().
239+
"""
240+
# Map v2 JSON fields to v1 XML structure with oml: namespace
241+
flow_dict = {
242+
"oml:flow": {
243+
"@xmlns:oml": "http://openml.org/openml",
244+
"oml:id": str(v2_json.get("id", "0")),
245+
"oml:uploader": str(v2_json.get("uploader", "")),
246+
"oml:name": v2_json.get("name", ""),
247+
"oml:version": str(v2_json.get("version", "")),
248+
"oml:external_version": v2_json.get("external_version", ""),
249+
"oml:description": v2_json.get("description", ""),
250+
"oml:upload_date": (
251+
v2_json.get("upload_date", "").replace("T", " ")
252+
if v2_json.get("upload_date")
253+
else ""
254+
),
255+
"oml:language": v2_json.get("language", ""),
256+
"oml:dependencies": v2_json.get("dependencies", ""),
257+
}
258+
}
259+
260+
# Add optional fields
261+
if "class_name" in v2_json:
262+
flow_dict["oml:flow"]["oml:class_name"] = v2_json["class_name"]
263+
if "custom_name" in v2_json:
264+
flow_dict["oml:flow"]["oml:custom_name"] = v2_json["custom_name"]
265+
266+
# Convert parameters from v2 array to v1 format
267+
if v2_json.get("parameter"):
268+
flow_dict["oml:flow"]["oml:parameter"] = [
269+
{
270+
"oml:name": param.get("name", ""),
271+
"oml:data_type": param.get("data_type", ""),
272+
"oml:default_value": str(param.get("default_value", "")),
273+
"oml:description": param.get("description", ""),
274+
}
275+
for param in v2_json["parameter"]
276+
]
277+
278+
# Convert subflows from v2 to v1 components format
279+
if v2_json.get("subflows"):
280+
flow_dict["oml:flow"]["oml:component"] = [
281+
{
282+
"oml:identifier": subflow.get("identifier", ""),
283+
"oml:flow": FlowV2API._convert_v2_to_v1_format(subflow["flow"])["oml:flow"],
284+
}
285+
for subflow in v2_json["subflows"]
286+
]
287+
288+
# Convert tags from v2 array to v1 format
289+
if v2_json.get("tag"):
290+
flow_dict["oml:flow"]["oml:tag"] = v2_json["tag"]
291+
292+
return flow_dict

openml/flows/flow.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
import xmltodict
1111

12+
import openml
1213
from openml.base import OpenMLBase
14+
from openml.exceptions import ObjectNotPublishedError
1315
from openml.extensions import Extension, get_extension_by_flow
1416
from openml.utils import extract_xml_tags
1517

@@ -438,9 +440,14 @@ def publish(self, raise_error_if_exists: bool = False) -> OpenMLFlow: # noqa: F
438440
raise openml.exceptions.PyOpenMLError(
439441
"Flow does not exist on the server, but 'flow.flow_id' is not None.",
440442
)
441-
super().publish()
442-
assert self.flow_id is not None # for mypy
443-
flow_id = self.flow_id
443+
444+
file_elements = self._get_file_elements()
445+
if "description" not in file_elements:
446+
file_elements["description"] = self._to_xml()
447+
448+
# Use openml._backend.flow.publish which internally calls ResourceV1.publish
449+
flow_id = openml._backend.flow.publish(path="flow", files=file_elements)
450+
self.flow_id = flow_id
444451
elif raise_error_if_exists:
445452
error_message = f"This OpenMLFlow already exists with id: {flow_id}."
446453
raise openml.exceptions.PyOpenMLError(error_message)
@@ -468,6 +475,38 @@ def publish(self, raise_error_if_exists: bool = False) -> OpenMLFlow: # noqa: F
468475
) from e
469476
return self
470477

478+
def push_tag(self, tag: str) -> None:
479+
"""Annotates this flow with a tag on the server.
480+
481+
Parameters
482+
----------
483+
tag : str
484+
Tag to attach to the flow.
485+
"""
486+
if self.flow_id is None:
487+
raise ObjectNotPublishedError(
488+
"Cannot tag a flow that has not been published yet. "
489+
"Please publish the object first before being able to tag it."
490+
f"\n{self}",
491+
)
492+
openml._backend.flow.tag(self.flow_id, tag)
493+
494+
def remove_tag(self, tag: str) -> None:
495+
"""Removes a tag from this flow on the server.
496+
497+
Parameters
498+
----------
499+
tag : str
500+
Tag to remove from the flow.
501+
"""
502+
if self.flow_id is None:
503+
raise ObjectNotPublishedError(
504+
"Cannot untag a flow that has not been published yet. "
505+
"Please publish the object first before being able to untag it."
506+
f"\n{self}",
507+
)
508+
openml._backend.flow.untag(self.flow_id, tag)
509+
471510
def get_structure(self, key_item: str) -> dict[str, list[str]]:
472511
"""
473512
Returns for each sub-component of the flow the path of identifiers

0 commit comments

Comments
 (0)