Skip to content

Commit 920ff21

Browse files
committed
Merge branch 'migration' of https://github.com/geetu040/openml-python into flow-migration-stacked
# Conflicts: # openml/_api/resources/__init__.py # openml/_api/resources/base/__init__.py # openml/_api/runtime/core.py
2 parents 685c19a + 2b6fe65 commit 920ff21

11 files changed

Lines changed: 227 additions & 81 deletions

File tree

openml/_api/clients/http.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ def _request( # noqa: PLR0913
284284
method: str,
285285
url: str,
286286
params: Mapping[str, Any],
287+
data: Mapping[str, Any],
287288
headers: Mapping[str, str],
288289
timeout: float | int,
289290
files: Mapping[str, Any] | None,
@@ -297,6 +298,7 @@ def _request( # noqa: PLR0913
297298
method=method,
298299
url=url,
299300
params=params,
301+
data=data,
300302
headers=headers,
301303
timeout=timeout,
302304
files=files,
@@ -331,20 +333,23 @@ def request(
331333
url = urljoin(self.server, urljoin(self.base_url, path))
332334
retries = max(1, self.retries)
333335

334-
# prepare params
335336
params = request_kwargs.pop("params", {}).copy()
337+
data = request_kwargs.pop("data", {}).copy()
338+
336339
if use_api_key:
337340
params["api_key"] = self.api_key
338341

342+
if method.upper() in {"POST", "PUT", "PATCH"}:
343+
data = {**params, **data}
344+
params = {}
345+
339346
# prepare headers
340347
headers = request_kwargs.pop("headers", {}).copy()
341348
headers.update(self.headers)
342349

343350
timeout = request_kwargs.pop("timeout", self.timeout)
344351
files = request_kwargs.pop("files", None)
345352

346-
use_cache = False
347-
348353
if use_cache and self.cache is not None:
349354
cache_key = self.cache.get_key(url, params)
350355
try:
@@ -359,6 +364,7 @@ def request(
359364
method=method,
360365
url=url,
361366
params=params,
367+
data=data,
362368
headers=headers,
363369
timeout=timeout,
364370
files=files,

openml/_api/resources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
from openml._api.resources.base.fallback import FallbackProxy
12
from openml._api.resources.datasets import DatasetsV1, DatasetsV2
23
from openml._api.resources.flows import FlowsV1, FlowsV2
34
from openml._api.resources.tasks import TasksV1, TasksV2
45

56
__all__ = [
67
"DatasetsV1",
78
"DatasetsV2",
9+
"FallbackProxy",
810
"FlowsV1",
911
"FlowsV2",
1012
"TasksV1",

openml/_api/resources/base/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from openml._api.resources.base.base import APIVersion, ResourceAPI, ResourceType
2+
from openml._api.resources.base.fallback import FallbackProxy
23
from openml._api.resources.base.resources import DatasetsAPI, FlowsAPI, TasksAPI
34
from openml._api.resources.base.versions import ResourceV1, ResourceV2
45

56
__all__ = [
67
"APIVersion",
78
"DatasetsAPI",
9+
"FallbackProxy",
810
"FlowsAPI",
911
"ResourceAPI",
1012
"ResourceType",

openml/_api/resources/base/base.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
from typing import TYPE_CHECKING
66

77
if TYPE_CHECKING:
8+
from collections.abc import Mapping
9+
from typing import Any
10+
811
from openml._api.clients import HTTPClient
912

1013

@@ -34,6 +37,18 @@ class ResourceAPI(ABC):
3437
def __init__(self, http: HTTPClient):
3538
self._http = http
3639

40+
@abstractmethod
41+
def delete(self, resource_id: int) -> bool: ...
42+
43+
@abstractmethod
44+
def publish(self, path: str, files: Mapping[str, Any] | None) -> int: ...
45+
46+
@abstractmethod
47+
def tag(self, resource_id: int, tag: str) -> list[str]: ...
48+
49+
@abstractmethod
50+
def untag(self, resource_id: int, tag: str) -> list[str]: ...
51+
3752
def _get_not_implemented_message(self, method_name: str | None = None) -> str:
3853
version = getattr(self.api_version, "name", "Unknown version")
3954
resource = getattr(self.resource_type, "name", "Unknown resource")
@@ -42,9 +57,3 @@ def _get_not_implemented_message(self, method_name: str | None = None) -> str:
4257
f"{self.__class__.__name__}: {version} API does not support this "
4358
f"functionality for resource: {resource}.{method_info}"
4459
)
45-
46-
@abstractmethod
47-
def delete(self, resource_id: int) -> bool: ...
48-
49-
@abstractmethod
50-
def publish(self) -> None: ...
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable
4+
from typing import Any
5+
6+
7+
class FallbackProxy:
8+
def __init__(self, *api_versions: Any):
9+
if not api_versions:
10+
raise ValueError("At least one API version must be provided")
11+
self._apis = api_versions
12+
13+
def __getattr__(self, name: str) -> Any:
14+
api, attr = self._find_attr(name)
15+
if callable(attr):
16+
return self._wrap_callable(name, api, attr)
17+
return attr
18+
19+
def _find_attr(self, name: str) -> tuple[Any, Any]:
20+
for api in self._apis:
21+
attr = getattr(api, name, None)
22+
if attr is not None:
23+
return api, attr
24+
raise AttributeError(f"{self.__class__.__name__} has no attribute {name}")
25+
26+
def _wrap_callable(
27+
self,
28+
name: str,
29+
primary_api: Any,
30+
primary_attr: Callable[..., Any],
31+
) -> Callable[..., Any]:
32+
def wrapper(*args: Any, **kwargs: Any) -> Any:
33+
try:
34+
return primary_attr(*args, **kwargs)
35+
except NotImplementedError:
36+
return self._call_fallbacks(name, primary_api, *args, **kwargs)
37+
38+
return wrapper
39+
40+
def _call_fallbacks(
41+
self,
42+
name: str,
43+
skip_api: Any,
44+
*args: Any,
45+
**kwargs: Any,
46+
) -> Any:
47+
for api in self._apis:
48+
if api is skip_api:
49+
continue
50+
attr = getattr(api, name, None)
51+
if callable(attr):
52+
try:
53+
return attr(*args, **kwargs)
54+
except NotImplementedError:
55+
continue
56+
raise NotImplementedError(f"Could not fallback to any API for method: {name}")

openml/_api/resources/base/resources.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def get(
3434

3535

3636
class FlowsAPI(ResourceAPI, ABC):
37+
resource_type: ResourceType = ResourceType.FLOW
38+
3739
@abstractmethod
3840
def get(
3941
self,
Lines changed: 123 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import annotations
22

3+
from collections.abc import Mapping
4+
from typing import Any
5+
36
import xmltodict
47

58
from openml._api.resources.base import APIVersion, ResourceAPI, ResourceType
@@ -13,71 +16,138 @@
1316
class ResourceV1(ResourceAPI):
1417
api_version: APIVersion = APIVersion.V1
1518

19+
def publish(self, path: str, files: Mapping[str, Any] | None) -> int:
20+
response = self._http.post(path, files=files)
21+
parsed_response = xmltodict.parse(response.content)
22+
return self._extract_id_from_upload(parsed_response)
23+
1624
def delete(self, resource_id: int) -> bool:
17-
if self.resource_type == ResourceType.DATASET:
18-
resource_type = "data"
19-
else:
20-
resource_type = self.resource_type.name
21-
22-
legal_resources = {
23-
"data",
24-
"flow",
25-
"task",
26-
"run",
27-
"study",
28-
"user",
29-
}
25+
resource_type = self._get_endpoint_name()
26+
27+
legal_resources = {"data", "flow", "task", "run", "study", "user"}
3028
if resource_type not in legal_resources:
3129
raise ValueError(f"Can't delete a {resource_type}")
3230

33-
url_suffix = f"{resource_type}/{resource_id}"
31+
path = f"{resource_type}/{resource_id}"
3432
try:
35-
response = self._http.delete(url_suffix)
33+
response = self._http.delete(path)
3634
result = xmltodict.parse(response.content)
3735
return f"oml:{resource_type}_delete" in result
3836
except OpenMLServerException as e:
39-
# https://github.com/openml/OpenML/blob/21f6188d08ac24fcd2df06ab94cf421c946971b0/openml_OS/views/pages/api_new/v1/xml/pre.php
40-
# Most exceptions are descriptive enough to be raised as their standard
41-
# OpenMLServerException, however there are two cases where we add information:
42-
# - a generic "failed" message, we direct them to the right issue board
43-
# - when the user successfully authenticates with the server,
44-
# but user is not allowed to take the requested action,
45-
# in which case we specify a OpenMLNotAuthorizedError.
46-
by_other_user = [323, 353, 393, 453, 594]
47-
has_dependent_entities = [324, 326, 327, 328, 354, 454, 464, 595]
48-
unknown_reason = [325, 355, 394, 455, 593]
49-
if e.code in by_other_user:
50-
raise OpenMLNotAuthorizedError(
51-
message=(
52-
f"The {resource_type} can not be deleted "
53-
"because it was not uploaded by you."
54-
),
55-
) from e
56-
if e.code in has_dependent_entities:
57-
raise OpenMLNotAuthorizedError(
58-
message=(
59-
f"The {resource_type} can not be deleted because "
60-
f"it still has associated entities: {e.message}"
61-
),
62-
) from e
63-
if e.code in unknown_reason:
64-
raise OpenMLServerError(
65-
message=(
66-
f"The {resource_type} can not be deleted for unknown reason,"
67-
" please open an issue at: https://github.com/openml/openml/issues/new"
68-
),
69-
) from e
70-
raise e
71-
72-
def publish(self) -> None:
73-
pass
37+
self._handle_delete_exception(resource_type, e)
38+
raise
39+
40+
def tag(self, resource_id: int, tag: str) -> list[str]:
41+
resource_type = self._get_endpoint_name()
42+
43+
legal_resources = {"data", "task", "flow", "setup", "run"}
44+
if resource_type not in legal_resources:
45+
raise ValueError(f"Can't tag a {resource_type}")
46+
47+
path = f"{resource_type}/tag"
48+
data = {f"{resource_type}_id": resource_id, "tag": tag}
49+
response = self._http.post(path, data=data)
50+
51+
main_tag = f"oml:{resource_type}_tag"
52+
parsed_response = xmltodict.parse(response.content, force_list={"oml:tag"})
53+
result = parsed_response[main_tag]
54+
tags: list[str] = result.get("oml:tag", [])
55+
56+
return tags
57+
58+
def untag(self, resource_id: int, tag: str) -> list[str]:
59+
resource_type = self._get_endpoint_name()
60+
61+
legal_resources = {"data", "task", "flow", "setup", "run"}
62+
if resource_type not in legal_resources:
63+
raise ValueError(f"Can't tag a {resource_type}")
64+
65+
path = f"{resource_type}/untag"
66+
data = {f"{resource_type}_id": resource_id, "tag": tag}
67+
response = self._http.post(path, data=data)
68+
69+
main_tag = f"oml:{resource_type}_untag"
70+
parsed_response = xmltodict.parse(response.content, force_list={"oml:tag"})
71+
result = parsed_response[main_tag]
72+
tags: list[str] = result.get("oml:tag", [])
73+
74+
return tags
75+
76+
def _get_endpoint_name(self) -> str:
77+
if self.resource_type == ResourceType.DATASET:
78+
return "data"
79+
return self.resource_type.name
80+
81+
def _handle_delete_exception(
82+
self, resource_type: str, exception: OpenMLServerException
83+
) -> None:
84+
# https://github.com/openml/OpenML/blob/21f6188d08ac24fcd2df06ab94cf421c946971b0/openml_OS/views/pages/api_new/v1/xml/pre.php
85+
# Most exceptions are descriptive enough to be raised as their standard
86+
# OpenMLServerException, however there are two cases where we add information:
87+
# - a generic "failed" message, we direct them to the right issue board
88+
# - when the user successfully authenticates with the server,
89+
# but user is not allowed to take the requested action,
90+
# in which case we specify a OpenMLNotAuthorizedError.
91+
by_other_user = [323, 353, 393, 453, 594]
92+
has_dependent_entities = [324, 326, 327, 328, 354, 454, 464, 595]
93+
unknown_reason = [325, 355, 394, 455, 593]
94+
if exception.code in by_other_user:
95+
raise OpenMLNotAuthorizedError(
96+
message=(
97+
f"The {resource_type} can not be deleted because it was not uploaded by you."
98+
),
99+
) from exception
100+
if exception.code in has_dependent_entities:
101+
raise OpenMLNotAuthorizedError(
102+
message=(
103+
f"The {resource_type} can not be deleted because "
104+
f"it still has associated entities: {exception.message}"
105+
),
106+
) from exception
107+
if exception.code in unknown_reason:
108+
raise OpenMLServerError(
109+
message=(
110+
f"The {resource_type} can not be deleted for unknown reason,"
111+
" please open an issue at: https://github.com/openml/openml/issues/new"
112+
),
113+
) from exception
114+
raise exception
115+
116+
def _extract_id_from_upload(self, parsed: Mapping[str, Any]) -> int:
117+
# reads id from
118+
# sample parsed dict: {"oml:openml": {"oml:upload_flow": {"oml:id": "42"}}}
119+
120+
# xmltodict always gives exactly one root key
121+
((_, root_value),) = parsed.items()
122+
123+
if not isinstance(root_value, Mapping):
124+
raise ValueError("Unexpected XML structure")
125+
126+
# upload node (e.g. oml:upload_task, oml:study_upload, ...)
127+
((_, upload_value),) = root_value.items()
128+
129+
if not isinstance(upload_value, Mapping):
130+
raise ValueError("Unexpected upload node structure")
131+
132+
# ID is the only leaf value
133+
for v in upload_value.values():
134+
if isinstance(v, (str, int)):
135+
return int(v)
136+
137+
raise ValueError("No ID found in upload response")
74138

75139

76140
class ResourceV2(ResourceAPI):
77141
api_version: APIVersion = APIVersion.V2
78142

79-
def delete(self, resource_id: int) -> bool:
143+
def publish(self, path: str, files: Mapping[str, Any] | None) -> int:
80144
raise NotImplementedError(self._get_not_implemented_message("publish"))
81145

82-
def publish(self) -> None:
83-
raise NotImplementedError(self._get_not_implemented_message("publish"))
146+
def delete(self, resource_id: int) -> bool:
147+
raise NotImplementedError(self._get_not_implemented_message("delete"))
148+
149+
def tag(self, resource_id: int, tag: str) -> list[str]:
150+
raise NotImplementedError(self._get_not_implemented_message("untag"))
151+
152+
def untag(self, resource_id: int, tag: str) -> list[str]:
153+
raise NotImplementedError(self._get_not_implemented_message("untag"))

0 commit comments

Comments
 (0)