Skip to content

Commit ebe9ce3

Browse files
authored
feat: Table multi-delete + refactoring core libs (#34)
1 parent 682658b commit ebe9ce3

File tree

12 files changed

+327
-309
lines changed

12 files changed

+327
-309
lines changed

nisystemlink/clients/core/_api_error.py

+17-167
Original file line numberDiff line numberDiff line change
@@ -2,181 +2,31 @@
22

33
"""Implementation of ApiError."""
44

5-
import typing
6-
from typing import Any, Dict, Iterable, List, Optional
5+
from typing import List, Optional
76

8-
from typing_extensions import final
7+
from ._uplink._json_model import JsonModel
98

109

11-
@final
12-
class ApiError:
10+
class ApiError(JsonModel):
1311
"""Represents the standard error structure for SystemLink API responses."""
1412

15-
def __init_subclass__(cls) -> None:
16-
raise TypeError("type 'ApiError' is not an acceptable base type")
13+
name: Optional[str] = None
14+
"""String error code."""
1715

18-
def __init__(self) -> None:
19-
self._name = None # type: Optional[str]
20-
self._code = None # type: Optional[int]
21-
self._message = None # type: Optional[str]
22-
self._args = [] # type: List[str]
23-
self._resource_type = None # type: Optional[str]
24-
self._resource_id = None # type: Optional[str]
25-
self._inner_errors = [] # type: List[ApiError]
16+
code: Optional[int] = None
17+
"""Numeric error code."""
2618

27-
@property
28-
def name(self) -> Optional[str]: # noqa: D401
29-
"""The name of the error."""
30-
return self._name
19+
message: Optional[str] = None
20+
"""Complete error message."""
3121

32-
@name.setter
33-
def name(self, value: Optional[str]) -> None:
34-
self._name = value
22+
args: List[str] = []
23+
"""Positional arguments for the error code."""
3524

36-
@property
37-
def code(self) -> Optional[int]: # noqa: D401
38-
"""The numeric code associated with the error."""
39-
return self._code
25+
resource_type: Optional[str] = None
26+
"""Type of resource associated with the error."""
4027

41-
@code.setter
42-
def code(self, value: Optional[int]) -> None:
43-
self._code = value
28+
resource_id: Optional[str] = None
29+
"""Identifier of the resource associated with the error."""
4430

45-
@property
46-
def message(self) -> Optional[str]: # noqa: D401
47-
"""The error message."""
48-
return self._message
49-
50-
@message.setter
51-
def message(self, value: Optional[str]) -> None:
52-
self._message = value
53-
54-
@property
55-
def args(self) -> List[str]: # noqa: D401
56-
"""The list of positional arguments formatted into the error."""
57-
return self._args
58-
59-
@args.setter
60-
def args(self, value: Iterable[str]) -> None:
61-
self._args = list(value)
62-
63-
@property
64-
def resource_type(self) -> Optional[str]: # noqa: D401
65-
"""The type of resource associated with the error, if any.
66-
67-
Set this when setting :attr:`resource_id`.
68-
"""
69-
return self._resource_type
70-
71-
@resource_type.setter
72-
def resource_type(self, value: Optional[str]) -> None:
73-
self._resource_type = value
74-
75-
@property
76-
def resource_id(self) -> Optional[str]: # noqa: D401
77-
"""The ID of the resource associated with the error, if any.
78-
79-
Set :attr:`resource_type` when setting this property.
80-
"""
81-
return self._resource_id
82-
83-
@resource_id.setter
84-
def resource_id(self, value: Optional[str]) -> None:
85-
self._resource_id = value
86-
87-
@property
88-
def inner_errors(self) -> List["ApiError"]: # noqa: D401
89-
"""The list of inner errors."""
90-
return self._inner_errors
91-
92-
@inner_errors.setter
93-
def inner_errors(self, value: Iterable["ApiError"]) -> None:
94-
self._inner_errors = list(value)
95-
96-
def copy(self) -> "ApiError":
97-
"""Get a copy of this object."""
98-
new = ApiError()
99-
new.name = self.name
100-
new.code = self.code
101-
new.message = self.message
102-
new.args = self.args
103-
new.resource_type = self.resource_type
104-
new.resource_id = self.resource_id
105-
new.inner_errors = self.inner_errors
106-
return new
107-
108-
def __str__(self) -> str:
109-
txt = ""
110-
if self._name:
111-
txt += "Name: {}\n".format(self._name)
112-
if self._code:
113-
txt += "Code: {}\n".format(self._code)
114-
if self._message:
115-
txt += "Message: {}\n".format(self._message)
116-
if self._args:
117-
args = "\n ".join(self._args)
118-
txt += "Args:\n {}\n".format(args)
119-
if self._resource_type:
120-
txt += "Resource Type: {}\n".format(self._resource_type)
121-
if self._resource_id:
122-
txt += "Resource Id: {}\n".format(self._resource_id)
123-
if self._inner_errors:
124-
inner_errors = "\n ".join(str(e) for e in self._inner_errors)
125-
txt += "Inner Errors:\n {}\n".format(
126-
str(inner_errors).replace("\n", "\n ")
127-
)
128-
return txt[:-1]
129-
130-
def __repr__(self) -> str:
131-
return (
132-
"ApiError(name={!r}, code={!r}, message={!r}, args={!r}, "
133-
"resource_type={!r}, resource_id={!r}, inner_errors={!r})".format(
134-
self.name,
135-
self.code,
136-
self.message,
137-
self.args,
138-
self.resource_type,
139-
self.resource_id,
140-
self.inner_errors,
141-
)
142-
)
143-
144-
def __eq__(self, other: object) -> bool:
145-
other_ = typing.cast(ApiError, other)
146-
return all(
147-
(
148-
isinstance(other, ApiError),
149-
self.name == other_.name,
150-
self.code == other_.code,
151-
self.message == other_.message,
152-
self.args == other_.args,
153-
self.resource_type == other_.resource_type,
154-
self.resource_id == other_.resource_id,
155-
self.inner_errors == other_.inner_errors,
156-
)
157-
)
158-
159-
def __hash__(self) -> int:
160-
return hash(
161-
(
162-
self.name,
163-
self.code,
164-
self.message,
165-
tuple(self.args),
166-
self.resource_type,
167-
self.resource_id,
168-
tuple(self.inner_errors),
169-
)
170-
)
171-
172-
@classmethod
173-
def from_json_dict(cls, data: Dict[str, Any]) -> "ApiError":
174-
err = cls()
175-
err.name = data.get("name")
176-
err.code = data.get("code")
177-
err.message = data.get("message")
178-
err.args = data.get("args", [])
179-
err.resource_type = data.get("resourceType")
180-
err.resource_id = data.get("resourceId")
181-
err.inner_errors = [cls.from_json_dict(e) for e in data.get("innerErrors", [])]
182-
return err
31+
inner_errors: List["ApiError"] = []
32+
"""Inner errors when the top-level error represents more than one error."""

nisystemlink/clients/core/_internal/_http_client.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ def _handle_response(response: HttpResponse, method: str, uri: str) -> Any:
286286

287287
if data:
288288
err_dict = typing.cast(Dict[str, Any], data).get("error", {})
289-
err_obj = core.ApiError.from_json_dict(err_dict) if err_dict else None
289+
err_obj = core.ApiError.parse_obj(err_dict) if err_dict else None
290290
else:
291291
err_obj = None
292292

nisystemlink/clients/core/_uplink/_base_client.py

+33-9
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# mypy: disable-error-code = misc
22

3-
import json
4-
from typing import Dict, Optional, Type
3+
from json import loads
4+
from typing import Any, Callable, Dict, get_origin, Optional, Type, Union
55

66
from nisystemlink.clients import core
7+
from pydantic import parse_obj_as
78
from requests import JSONDecodeError, Response
8-
from uplink import Consumer, dumps, response_handler
9+
from uplink import commands, Consumer, converters, response_handler, utils
910

1011
from ._json_model import JsonModel
1112

@@ -26,7 +27,7 @@ def _handle_http_status(response: Response) -> Optional[Response]:
2627
try:
2728
content = response.json()
2829
if content and "error" in content:
29-
err_obj = core.ApiError.from_json_dict(content["error"])
30+
err_obj = core.ApiError.parse_obj(content["error"])
3031
else:
3132
err_obj = None
3233

@@ -39,10 +40,33 @@ def _handle_http_status(response: Response) -> Optional[Response]:
3940
raise core.ApiException(msg, http_status_code=response.status_code)
4041

4142

42-
@dumps.to_json(JsonModel)
43-
def _deserialize_model(model_cls: Type[JsonModel], model_instance: JsonModel) -> Dict:
44-
"""Turns a :class:`.JsonModel` instance into a dictionary for serialization."""
45-
return json.loads(model_instance.json(by_alias=True, exclude_unset=True))
43+
class _JsonModelConverter(converters.Factory):
44+
def create_request_body_converter(
45+
self, _class: Type, _: commands.RequestDefinition
46+
) -> Optional[Callable[[JsonModel], Dict]]:
47+
def encoder(model: JsonModel) -> Dict:
48+
return loads(model.json(by_alias=True, exclude_unset=True))
49+
50+
if utils.is_subclass(_class, JsonModel):
51+
return encoder
52+
else:
53+
return None
54+
55+
def create_response_body_converter(
56+
self, _class: Type, _: commands.RequestDefinition
57+
) -> Optional[Callable[[Response], Any]]:
58+
def decoder(response: Response) -> Any:
59+
try:
60+
data = response.json()
61+
except AttributeError:
62+
data = response
63+
64+
return parse_obj_as(_class, data)
65+
66+
if get_origin(_class) is Union or utils.is_subclass(_class, JsonModel):
67+
return decoder
68+
else:
69+
return None
4670

4771

4872
class BaseClient(Consumer):
@@ -56,7 +80,7 @@ def __init__(self, configuration: core.HttpConfiguration):
5680
"""
5781
super().__init__(
5882
base_url=configuration.server_uri,
59-
converter=_deserialize_model,
83+
converter=_JsonModelConverter(),
6084
hooks=[_handle_http_status],
6185
)
6286
if configuration.api_keys:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Wrappers around uplink HTTP decorators with proper type annotations."""
2+
3+
from typing import Any, Callable, Optional, Sequence, Tuple, TypeVar, Union
4+
5+
from uplink import Body, commands, json, returns
6+
7+
F = TypeVar("F", bound=Callable[..., Any])
8+
9+
10+
def get(path: str, args: Optional[Sequence[Any]] = None) -> Callable[[F], F]:
11+
"""Annotation for a GET request."""
12+
13+
def decorator(func: F) -> F:
14+
return commands.get(path, args=args)(func) # type: ignore
15+
16+
return decorator
17+
18+
19+
def post(
20+
path: str,
21+
args: Optional[Sequence[Any]] = None,
22+
return_key: Optional[Union[str, Tuple[str, ...]]] = None,
23+
) -> Callable[[F], F]:
24+
"""Annotation for a POST request with a JSON request body. If args is not
25+
specified, defaults to a single argument that represents the request body.
26+
"""
27+
28+
def decorator(func: F) -> F:
29+
result = json(commands.post(path, args=args or (Body,))(func))
30+
if return_key:
31+
result = returns.json(key=return_key)(result)
32+
return result # type: ignore
33+
34+
return decorator
35+
36+
37+
def patch(path: str, args: Optional[Sequence[Any]] = None) -> Callable[[F], F]:
38+
"""Annotation for a PATCH request with a JSON request body."""
39+
40+
def decorator(func: F) -> F:
41+
return json(commands.patch(path, args=args)(func)) # type: ignore
42+
43+
return decorator
44+
45+
46+
def delete(path: str, args: Optional[Sequence[Any]] = None) -> Callable[[F], F]:
47+
"""Annotation for a DELETE request."""
48+
49+
def decorator(func: F) -> F:
50+
return commands.delete(path, args=args)(func) # type: ignore
51+
52+
return decorator

nisystemlink/clients/dataframe/_data_frame_client.py

+16-11
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
# TODO: Wrap Uplink decorators to add typing information
2-
# mypy: disable-error-code = misc
3-
41
"""Implementation of DataFrameClient."""
52

63
from typing import List, Optional
74

85
from nisystemlink.clients import core
96
from nisystemlink.clients.core._uplink._base_client import BaseClient
10-
from uplink import Body, delete, get, json, patch, Path, post, Query, returns
7+
from nisystemlink.clients.core._uplink._methods import delete, get, patch, post
8+
from uplink import Body, Field, Path, Query
119

1210
from . import models
1311

@@ -68,9 +66,7 @@ def list_tables(
6866
"""
6967
...
7068

71-
@json
72-
@returns.json(key="id")
73-
@post(_BASE_PATH + "/tables", args=(Body,))
69+
@post(_BASE_PATH + "/tables", return_key="id")
7470
def create_table(self, table: models.CreateTableRequest) -> str:
7571
"""Create a new table with the provided metadata and column definitions.
7672
@@ -82,8 +78,7 @@ def create_table(self, table: models.CreateTableRequest) -> str:
8278
"""
8379
...
8480

85-
@json
86-
@post(_BASE_PATH + "/query-tables", args=(Body,))
81+
@post(_BASE_PATH + "/query-tables")
8782
def query_tables(self, query: models.QueryTablesRequest) -> models.PagedTables:
8883
"""Queries available tables on the SystemLink DataFrame service and returns their metadata.
8984
@@ -107,8 +102,7 @@ def get_table_metadata(self, id: str) -> models.TableMetadata:
107102
"""
108103
...
109104

110-
@json
111-
@patch(_BASE_PATH + "/tables/{id}", args=(Path, Body))
105+
@patch(_BASE_PATH + "/tables/{id}", args=[Path, Body])
112106
def modify_table(self, id: str, update: models.ModifyTableRequest) -> None:
113107
"""Modify properties of a table or its columns.
114108
@@ -126,3 +120,14 @@ def delete_table(self, id: str) -> None:
126120
id (str): Unique ID of a DataFrame table.
127121
"""
128122
...
123+
124+
@post(_BASE_PATH + "/delete-tables", args=[Field("ids")])
125+
def delete_tables(
126+
self, ids: List[str]
127+
) -> Optional[models.DeleteTablesPartialSuccess]:
128+
"""Deletes multiple tables.
129+
130+
Args:
131+
ids (List[str]): List of unique IDs of DataFrame tables.
132+
"""
133+
...

0 commit comments

Comments
 (0)