Skip to content

Commit 4b06b3c

Browse files
feat: Implement openml.flows.edit_flow for metadata editing (Closes #896)
1 parent e4d42f7 commit 4b06b3c

File tree

3 files changed

+196
-0
lines changed

3 files changed

+196
-0
lines changed

openml/flows/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .functions import (
55
assert_flows_equal,
66
delete_flow,
7+
edit_flow,
78
flow_exists,
89
get_flow,
910
get_flow_id,
@@ -18,4 +19,5 @@
1819
"flow_exists",
1920
"assert_flows_equal",
2021
"delete_flow",
22+
"edit_flow",
2123
]

openml/flows/functions.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,113 @@ def delete_flow(flow_id: int) -> bool:
552552
True if the deletion was successful. False otherwise.
553553
"""
554554
return openml.utils._delete_entity("flow", flow_id)
555+
556+
557+
def edit_flow(
558+
flow_id: int,
559+
custom_name: str | None = None,
560+
tags: list[str] | None = None,
561+
language: str | None = None,
562+
description: str | None = None,
563+
) -> int:
564+
"""Edits an OpenMLFlow.
565+
566+
In addition to providing the flow id of the flow to edit (through flow_id),
567+
you must specify a value for at least one of the optional function arguments,
568+
i.e. one value for a field to edit.
569+
570+
This function allows editing of non-critical fields only.
571+
Editable fields are: custom_name, tags, language, description.
572+
573+
Editing is allowed only for the owner of the flow.
574+
575+
Parameters
576+
----------
577+
flow_id : int
578+
ID of the flow.
579+
custom_name : str, optional
580+
Custom name for the flow.
581+
tags : list[str], optional
582+
Tags to associate with the flow.
583+
language : str, optional
584+
Language in which the flow is described.
585+
Starts with 1 upper case letter, rest lower case, e.g. 'English'.
586+
description : str, optional
587+
Human-readable description of the flow.
588+
589+
Returns
590+
-------
591+
flow_id : int
592+
The ID of the edited flow.
593+
594+
Raises
595+
------
596+
TypeError
597+
If flow_id is not an integer.
598+
ValueError
599+
If no fields are provided for editing.
600+
OpenMLServerException
601+
If the user is not authorized to edit the flow or if the flow doesn't exist.
602+
603+
Examples
604+
--------
605+
>>> import openml
606+
>>> # Edit the custom name of a flow
607+
>>> edited_flow_id = openml.flows.edit_flow(123, custom_name="My Custom Flow Name")
608+
>>>
609+
>>> # Edit multiple fields at once
610+
>>> edited_flow_id = openml.flows.edit_flow(
611+
... 456,
612+
... custom_name="Updated Flow",
613+
... language="English",
614+
... description="An updated description for this flow",
615+
... tags=["machine-learning", "classification"]
616+
... )
617+
"""
618+
if not isinstance(flow_id, int):
619+
raise TypeError(f"`flow_id` must be of type `int`, not {type(flow_id)}.")
620+
621+
# Check if at least one field is provided for editing
622+
fields_to_edit = [custom_name, tags, language, description]
623+
if all(field is None for field in fields_to_edit):
624+
raise ValueError(
625+
"At least one field must be provided for editing. "
626+
"Available fields: custom_name, tags, language, description"
627+
)
628+
629+
# Compose flow edit parameters as XML
630+
form_data = {"flow_id": flow_id} # type: openml._api_calls.DATA_TYPE
631+
xml = OrderedDict() # type: 'OrderedDict[str, OrderedDict]'
632+
xml["oml:flow_edit_parameters"] = OrderedDict()
633+
xml["oml:flow_edit_parameters"]["@xmlns:oml"] = "http://openml.org/openml"
634+
xml["oml:flow_edit_parameters"]["oml:custom_name"] = custom_name
635+
xml["oml:flow_edit_parameters"]["oml:language"] = language
636+
xml["oml:flow_edit_parameters"]["oml:description"] = description
637+
638+
# Handle tags - convert list to comma-separated string if provided
639+
if tags is not None:
640+
if isinstance(tags, list):
641+
xml["oml:flow_edit_parameters"]["oml:tag"] = ",".join(tags)
642+
else:
643+
xml["oml:flow_edit_parameters"]["oml:tag"] = str(tags)
644+
else:
645+
xml["oml:flow_edit_parameters"]["oml:tag"] = None
646+
647+
# Remove None values from XML
648+
for key in list(xml["oml:flow_edit_parameters"]):
649+
if not xml["oml:flow_edit_parameters"][key]:
650+
del xml["oml:flow_edit_parameters"][key]
651+
652+
file_elements = {
653+
"edit_parameters": ("description.xml", xmltodict.unparse(xml)),
654+
} # type: openml._api_calls.FILE_ELEMENTS_TYPE
655+
656+
result_xml = openml._api_calls._perform_api_call(
657+
"flow/edit",
658+
"post",
659+
data=form_data,
660+
file_elements=file_elements,
661+
)
662+
result = xmltodict.parse(result_xml)
663+
edited_flow_id = result["oml:flow_edit"]["oml:id"]
664+
return int(edited_flow_id)

tests/test_flows/test_flow_functions.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,87 @@ def test_delete_unknown_flow(mock_delete, test_files_directory, test_api_key):
537537
flow_url = "https://test.openml.org/api/v1/xml/flow/9999999"
538538
assert flow_url == mock_delete.call_args.args[0]
539539
assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key")
540+
541+
542+
@mock.patch.object(openml._api_calls, "_perform_api_call")
543+
def test_edit_flow_custom_name(mock_api_call):
544+
"""Test edit_flow with custom_name field."""
545+
# Mock the API response
546+
mock_api_call.return_value = '<?xml version="1.0"?><oml:flow_edit><oml:id>123</oml:id></oml:flow_edit>'
547+
548+
result = openml.flows.edit_flow(123, custom_name="New Custom Name")
549+
550+
# Check that the function returns the correct flow ID
551+
assert result == 123
552+
553+
# Verify the API call was made with correct parameters
554+
mock_api_call.assert_called_once()
555+
call_args = mock_api_call.call_args
556+
assert call_args[0][0] == "flow/edit" # endpoint
557+
assert call_args[0][1] == "post" # method
558+
assert call_args[1]["data"]["flow_id"] == 123
559+
560+
561+
@mock.patch.object(openml._api_calls, "_perform_api_call")
562+
def test_edit_flow_multiple_fields(mock_api_call):
563+
"""Test edit_flow with multiple fields."""
564+
# Mock the API response
565+
mock_api_call.return_value = '<?xml version="1.0"?><oml:flow_edit><oml:id>456</oml:id></oml:flow_edit>'
566+
567+
result = openml.flows.edit_flow(
568+
456,
569+
custom_name="Updated Name",
570+
language="English",
571+
description="Updated description",
572+
tags=["tag1", "tag2"]
573+
)
574+
575+
# Check that the function returns the correct flow ID
576+
assert result == 456
577+
578+
# Verify the API call was made
579+
mock_api_call.assert_called_once()
580+
call_args = mock_api_call.call_args
581+
assert call_args[0][0] == "flow/edit"
582+
assert call_args[0][1] == "post"
583+
assert call_args[1]["data"]["flow_id"] == 456
584+
585+
586+
@mock.patch.object(openml._api_calls, "_perform_api_call")
587+
def test_edit_flow_tags_as_list(mock_api_call):
588+
"""Test edit_flow with tags provided as a list."""
589+
# Mock the API response
590+
mock_api_call.return_value = '<?xml version="1.0"?><oml:flow_edit><oml:id>789</oml:id></oml:flow_edit>'
591+
592+
result = openml.flows.edit_flow(789, tags=["machine-learning", "sklearn"])
593+
594+
# Check that the function returns the correct flow ID
595+
assert result == 789
596+
597+
# Verify the API call was made
598+
mock_api_call.assert_called_once()
599+
600+
601+
@mock.patch.object(openml._api_calls, "_perform_api_call")
602+
def test_edit_flow_server_error(mock_api_call):
603+
"""Test edit_flow when server returns an error."""
604+
from openml.exceptions import OpenMLServerException
605+
606+
# Mock a server error
607+
mock_api_call.side_effect = OpenMLServerException("Flow not found")
608+
609+
with pytest.raises(OpenMLServerException, match="Flow not found"):
610+
openml.flows.edit_flow(999, custom_name="Test")
611+
612+
def test_edit_flow_invalid_flow_id(self):
613+
"""Test that edit_flow raises TypeError for non-integer flow_id."""
614+
with pytest.raises(TypeError, match="`flow_id` must be of type `int`"):
615+
openml.flows.edit_flow("not_an_int", custom_name="test")
616+
617+
def test_edit_flow_no_fields(self):
618+
"""Test that edit_flow raises ValueError when no fields are provided."""
619+
with pytest.raises(
620+
ValueError,
621+
match="At least one field must be provided for editing"
622+
):
623+
openml.flows.edit_flow(1)

0 commit comments

Comments
 (0)