From d3224f07a03bb3f720877a3ed86fb9083b45a3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Thu, 21 Dec 2023 23:21:38 +0300 Subject: [PATCH 01/29] implement #29 --- dataclass_rest/boundmethod.py | 18 +++++++++++++++--- dataclass_rest/methodspec.py | 4 ++-- dataclass_rest/parse_func.py | 17 ++++++++++++----- dataclass_rest/rest.py | 4 ++-- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/dataclass_rest/boundmethod.py b/dataclass_rest/boundmethod.py index f69021a..e4e62bd 100644 --- a/dataclass_rest/boundmethod.py +++ b/dataclass_rest/boundmethod.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from inspect import getcallargs +from inspect import getcallargs, getfullargspec from logging import getLogger from typing import Dict, Any, Callable, Optional, NoReturn, Type @@ -7,6 +7,7 @@ from .exceptions import MalformedResponse from .http_request import HttpRequest, File from .methodspec import MethodSpec +from .parse_func import get_url_params, create_query_params_type logger = getLogger(__name__) @@ -30,7 +31,11 @@ def _apply_args(self, *args, **kwargs) -> Dict: ) def _get_url(self, args) -> str: - return self.method_spec.url_template.format(**args) + if isinstance(self.method_spec.url_template, str): + return self.method_spec.url_template.format(**args) + + args.pop("self") + return self.method_spec.url_template(**args) def _get_body(self, args) -> Any: python_body = args.get(self.method_spec.body_param_name) @@ -39,8 +44,15 @@ def _get_body(self, args) -> Any: ) def _get_query_params(self, args) -> Any: + query_params_type = self.method_spec.query_params_type + + if not isinstance(self.method_spec.url_template, str): + url_params = get_url_params(self.method_spec.url_template, args) + skipped_params = url_params + self.method_spec.file_param_names + [self.method_spec.body_param_name] + query_params_type = create_query_params_type(getfullargspec(self.method_spec.func), self.method_spec.func, skipped_params) + return self.client.request_args_factory.dump( - args, self.method_spec.query_params_type, + args, query_params_type, ) def _get_files(self, args) -> Dict[str, File]: diff --git a/dataclass_rest/methodspec.py b/dataclass_rest/methodspec.py index bf42c61..032f0c0 100644 --- a/dataclass_rest/methodspec.py +++ b/dataclass_rest/methodspec.py @@ -1,11 +1,11 @@ -from typing import Any, Dict, Type, Callable, List +from typing import Any, Dict, Type, Callable, List, Union class MethodSpec: def __init__( self, func: Callable, - url_template: str, + url_template: Union[str | Callable[..., str]], http_method: str, response_type: Type, body_param_name: str, diff --git a/dataclass_rest/parse_func.py b/dataclass_rest/parse_func.py index 3c02e8f..1377a53 100644 --- a/dataclass_rest/parse_func.py +++ b/dataclass_rest/parse_func.py @@ -1,6 +1,6 @@ import string -from inspect import getfullargspec, FullArgSpec, isclass -from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict +from inspect import getfullargspec, FullArgSpec, isclass, getcallargs +from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict, Union, Optional from .http_request import File from .methodspec import MethodSpec @@ -8,7 +8,14 @@ DEFAULT_BODY_PARAM = "body" -def get_url_params(url_template: str) -> List[str]: +def get_url_params(url_template: Union[str | Callable[..., str]], callback_kwargs: Optional[dict[str, Any]] = None) -> List[str]: + if not isinstance(url_template, str) and not callback_kwargs: + return [] + + if not isinstance(url_template, str) and callback_kwargs: + parsed_format = string.Formatter().parse(url_template(callback_kwargs)) + return [x[1] for x in parsed_format] + parsed_format = string.Formatter().parse(url_template) return [x[1] for x in parsed_format] @@ -54,7 +61,7 @@ def get_file_params(spec): def parse_func( func: Callable, method: str, - url_template: str, + url_template: Union[str | Callable[..., str]], additional_params: Dict[str, Any], is_json_request: bool, body_param_name: str, @@ -62,7 +69,7 @@ def parse_func( spec = getfullargspec(func) url_params = get_url_params(url_template) file_params = get_file_params(spec) - skipped_params = url_params + file_params + [body_param_name] + skipped_params = url_params + file_params + [body_param_name] if url_params else [] return MethodSpec( func=func, http_method=method, diff --git a/dataclass_rest/rest.py b/dataclass_rest/rest.py index 9394811..686ebcc 100644 --- a/dataclass_rest/rest.py +++ b/dataclass_rest/rest.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Any, Dict, Optional, Callable +from typing import Any, Dict, Optional, Callable, Union from .boundmethod import BoundMethod from .method import Method @@ -7,7 +7,7 @@ def rest( - url_template: str, + url_template: Union[str | Callable[..., str]], *, method: str, body_name: str = DEFAULT_BODY_PARAM, From 0f9ad40a209f9cdf0e64629ac5bb126313d21db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Fri, 22 Dec 2023 00:44:37 +0300 Subject: [PATCH 02/29] fixes --- dataclass_rest/parse_func.py | 21 +++++++-------------- dataclass_rest/rest.py | 4 +++- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/dataclass_rest/parse_func.py b/dataclass_rest/parse_func.py index 1377a53..f690af4 100644 --- a/dataclass_rest/parse_func.py +++ b/dataclass_rest/parse_func.py @@ -1,6 +1,6 @@ import string -from inspect import getfullargspec, FullArgSpec, isclass, getcallargs -from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict, Union, Optional +from inspect import getfullargspec, FullArgSpec, isclass +from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict from .http_request import File from .methodspec import MethodSpec @@ -8,15 +8,8 @@ DEFAULT_BODY_PARAM = "body" -def get_url_params(url_template: Union[str | Callable[..., str]], callback_kwargs: Optional[dict[str, Any]] = None) -> List[str]: - if not isinstance(url_template, str) and not callback_kwargs: - return [] - - if not isinstance(url_template, str) and callback_kwargs: - parsed_format = string.Formatter().parse(url_template(callback_kwargs)) - return [x[1] for x in parsed_format] - - parsed_format = string.Formatter().parse(url_template) +def get_url_params(url_template: Callable[..., str], callback_kwargs: dict[str, Any]) -> List[str]: + parsed_format = string.Formatter().parse(url_template(callback_kwargs)) return [x[1] for x in parsed_format] @@ -61,15 +54,15 @@ def get_file_params(spec): def parse_func( func: Callable, method: str, - url_template: Union[str | Callable[..., str]], + url_template: Callable[..., str], additional_params: Dict[str, Any], is_json_request: bool, body_param_name: str, ) -> MethodSpec: spec = getfullargspec(func) - url_params = get_url_params(url_template) file_params = get_file_params(spec) - skipped_params = url_params + file_params + [body_param_name] if url_params else [] + skipped_params = [] + return MethodSpec( func=func, http_method=method, diff --git a/dataclass_rest/rest.py b/dataclass_rest/rest.py index 686ebcc..243c65f 100644 --- a/dataclass_rest/rest.py +++ b/dataclass_rest/rest.py @@ -19,10 +19,12 @@ def rest( additional_params = {} def dec(func: Callable) -> Method: + new_url_template = (lambda *args, **kwargs: url_template) if isinstance(url_template, str) else url_template + method_spec = parse_func( func=func, body_param_name=body_name, - url_template=url_template, + url_template=new_url_template, method=method, additional_params=additional_params, is_json_request=send_json, From a4bc4a4b497e0eb6177c088eecfb40177df99d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Fri, 22 Dec 2023 00:47:36 +0300 Subject: [PATCH 03/29] remove isinstance str checking --- dataclass_rest/boundmethod.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dataclass_rest/boundmethod.py b/dataclass_rest/boundmethod.py index e4e62bd..824d206 100644 --- a/dataclass_rest/boundmethod.py +++ b/dataclass_rest/boundmethod.py @@ -31,9 +31,6 @@ def _apply_args(self, *args, **kwargs) -> Dict: ) def _get_url(self, args) -> str: - if isinstance(self.method_spec.url_template, str): - return self.method_spec.url_template.format(**args) - args.pop("self") return self.method_spec.url_template(**args) From a8c097d9b9d9ed77aa9bd126eee1cbba0cc6f466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Fri, 22 Dec 2023 00:49:12 +0300 Subject: [PATCH 04/29] remove isinstance str checking again --- dataclass_rest/boundmethod.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/dataclass_rest/boundmethod.py b/dataclass_rest/boundmethod.py index 824d206..199fcf4 100644 --- a/dataclass_rest/boundmethod.py +++ b/dataclass_rest/boundmethod.py @@ -41,12 +41,9 @@ def _get_body(self, args) -> Any: ) def _get_query_params(self, args) -> Any: - query_params_type = self.method_spec.query_params_type - - if not isinstance(self.method_spec.url_template, str): - url_params = get_url_params(self.method_spec.url_template, args) - skipped_params = url_params + self.method_spec.file_param_names + [self.method_spec.body_param_name] - query_params_type = create_query_params_type(getfullargspec(self.method_spec.func), self.method_spec.func, skipped_params) + url_params = get_url_params(self.method_spec.url_template, args) + skipped_params = url_params + self.method_spec.file_param_names + [self.method_spec.body_param_name] + query_params_type = create_query_params_type(getfullargspec(self.method_spec.func), self.method_spec.func, skipped_params) return self.client.request_args_factory.dump( args, query_params_type, From b589ed135b0ef21424c45cfa2b746331824a83bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Fri, 22 Dec 2023 13:28:34 +0300 Subject: [PATCH 05/29] split parsing and execution logic, add support for kwonly args, fix support for older versions of python --- dataclass_rest/boundmethod.py | 22 +++++++++++-------- dataclass_rest/methodspec.py | 13 ++++++++++-- dataclass_rest/parse_func.py | 40 +++++++++++++++++++++++++++++------ dataclass_rest/rest.py | 6 ++---- 4 files changed, 59 insertions(+), 22 deletions(-) diff --git a/dataclass_rest/boundmethod.py b/dataclass_rest/boundmethod.py index 199fcf4..c27acf8 100644 --- a/dataclass_rest/boundmethod.py +++ b/dataclass_rest/boundmethod.py @@ -1,5 +1,7 @@ +import copy + from abc import ABC, abstractmethod -from inspect import getcallargs, getfullargspec +from inspect import getcallargs from logging import getLogger from typing import Dict, Any, Callable, Optional, NoReturn, Type @@ -7,7 +9,6 @@ from .exceptions import MalformedResponse from .http_request import HttpRequest, File from .methodspec import MethodSpec -from .parse_func import get_url_params, create_query_params_type logger = getLogger(__name__) @@ -31,8 +32,15 @@ def _apply_args(self, *args, **kwargs) -> Dict: ) def _get_url(self, args) -> str: - args.pop("self") - return self.method_spec.url_template(**args) + args = copy.copy(args) + + if not self.method_spec.url_template_func_arg_spec: + return self.method_spec.url_template_func(**args) + + for arg in self.method_spec.url_template_func_pop_args: + args.pop(arg) + + return self.method_spec.url_template_func(**args) def _get_body(self, args) -> Any: python_body = args.get(self.method_spec.body_param_name) @@ -41,12 +49,8 @@ def _get_body(self, args) -> Any: ) def _get_query_params(self, args) -> Any: - url_params = get_url_params(self.method_spec.url_template, args) - skipped_params = url_params + self.method_spec.file_param_names + [self.method_spec.body_param_name] - query_params_type = create_query_params_type(getfullargspec(self.method_spec.func), self.method_spec.func, skipped_params) - return self.client.request_args_factory.dump( - args, query_params_type, + args, self.method_spec.query_params_type, ) def _get_files(self, args) -> Dict[str, File]: diff --git a/dataclass_rest/methodspec.py b/dataclass_rest/methodspec.py index 032f0c0..593d938 100644 --- a/dataclass_rest/methodspec.py +++ b/dataclass_rest/methodspec.py @@ -1,11 +1,16 @@ -from typing import Any, Dict, Type, Callable, List, Union +from inspect import FullArgSpec +from typing import Any, Dict, Type, Callable, List, Optional class MethodSpec: def __init__( self, func: Callable, - url_template: Union[str | Callable[..., str]], + func_arg_spec: FullArgSpec, + url_template: Optional[str], + url_template_func: Optional[Callable[..., str]], + url_template_func_arg_spec: Optional[FullArgSpec], + url_template_func_pop_args: Optional[List[str]], http_method: str, response_type: Type, body_param_name: str, @@ -17,6 +22,8 @@ def __init__( ): self.func = func self.url_template = url_template + self.url_template_func = url_template_func + self.url_template_func_pop_args = url_template_func_pop_args self.http_method = http_method self.response_type = response_type self.body_param_name = body_param_name @@ -25,3 +32,5 @@ def __init__( self.additional_params = additional_params self.is_json_request = is_json_request self.file_param_names = file_param_names + self.func_arg_spec = func_arg_spec + self.url_template_func_arg_spec = url_template_func_arg_spec diff --git a/dataclass_rest/parse_func.py b/dataclass_rest/parse_func.py index f690af4..4529e9e 100644 --- a/dataclass_rest/parse_func.py +++ b/dataclass_rest/parse_func.py @@ -1,6 +1,6 @@ import string from inspect import getfullargspec, FullArgSpec, isclass -from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict +from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict, Union from .http_request import File from .methodspec import MethodSpec @@ -8,9 +8,14 @@ DEFAULT_BODY_PARAM = "body" -def get_url_params(url_template: Callable[..., str], callback_kwargs: dict[str, Any]) -> List[str]: - parsed_format = string.Formatter().parse(url_template(callback_kwargs)) - return [x[1] for x in parsed_format] +def get_url_params(url_template: Union[str, Callable[..., str]]) -> List[str]: + is_string = isinstance(url_template, str) + + if is_string: + parsed_format = string.Formatter().parse(url_template) + return [x[1] for x in parsed_format] + else: + return getfullargspec(url_template).args def create_query_params_type( @@ -54,19 +59,40 @@ def get_file_params(spec): def parse_func( func: Callable, method: str, - url_template: Callable[..., str], + url_template: Union[str, Callable[..., str]], additional_params: Dict[str, Any], is_json_request: bool, body_param_name: str, ) -> MethodSpec: spec = getfullargspec(func) file_params = get_file_params(spec) - skipped_params = [] + + is_string_url_template = isinstance(url_template, str) + url_template_func = url_template.format if is_string_url_template else url_template + + try: + url_template_func_arg_spec = getfullargspec(url_template_func) + + url_template_func_args = set(url_template_func_arg_spec.args) + diff_kwargs = set(spec.kwonlyargs).difference(url_template_func_args) + diff_args = set(spec.args).difference(url_template_func_args) + + url_template_func_pop_args = diff_args.union(diff_kwargs) + except TypeError as _exc: + url_template_func_arg_spec = None + url_template_func_pop_args = None + + url_params = get_url_params(url_template if is_string_url_template else url_template_func) + skipped_params = url_params + file_params + [body_param_name] return MethodSpec( func=func, + func_arg_spec=spec, http_method=method, - url_template=url_template, + url_template=url_template if is_string_url_template else None, + url_template_func=url_template_func, + url_template_func_arg_spec=url_template_func_arg_spec, + url_template_func_pop_args=url_template_func_pop_args, query_params_type=create_query_params_type(spec, func, skipped_params), body_type=create_body_type(spec, body_param_name), response_type=create_response_type(spec), diff --git a/dataclass_rest/rest.py b/dataclass_rest/rest.py index 243c65f..663bf6d 100644 --- a/dataclass_rest/rest.py +++ b/dataclass_rest/rest.py @@ -7,7 +7,7 @@ def rest( - url_template: Union[str | Callable[..., str]], + url_template: Union[str, Callable[..., str]], *, method: str, body_name: str = DEFAULT_BODY_PARAM, @@ -19,12 +19,10 @@ def rest( additional_params = {} def dec(func: Callable) -> Method: - new_url_template = (lambda *args, **kwargs: url_template) if isinstance(url_template, str) else url_template - method_spec = parse_func( func=func, body_param_name=body_name, - url_template=new_url_template, + url_template=url_template, method=method, additional_params=additional_params, is_json_request=send_json, From cf8822697213929ab814a735bde96d2bb08cc2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Fri, 22 Dec 2023 13:34:31 +0300 Subject: [PATCH 06/29] remove unused getfullargspec call --- dataclass_rest/parse_func.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dataclass_rest/parse_func.py b/dataclass_rest/parse_func.py index 4529e9e..8caf27e 100644 --- a/dataclass_rest/parse_func.py +++ b/dataclass_rest/parse_func.py @@ -1,6 +1,6 @@ import string from inspect import getfullargspec, FullArgSpec, isclass -from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict, Union +from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict, Union, Optional from .http_request import File from .methodspec import MethodSpec @@ -8,14 +8,14 @@ DEFAULT_BODY_PARAM = "body" -def get_url_params(url_template: Union[str, Callable[..., str]]) -> List[str]: +def get_url_params(url_template: Union[str, Callable[..., str]], arg_spec: Optional[FullArgSpec] = None) -> List[str]: is_string = isinstance(url_template, str) if is_string: parsed_format = string.Formatter().parse(url_template) return [x[1] for x in parsed_format] else: - return getfullargspec(url_template).args + return arg_spec.args def create_query_params_type( @@ -82,7 +82,11 @@ def parse_func( url_template_func_arg_spec = None url_template_func_pop_args = None - url_params = get_url_params(url_template if is_string_url_template else url_template_func) + if is_string_url_template: + url_params = get_url_params(url_template) + else: + url_params = get_url_params(url_template_func, url_template_func_arg_spec) + skipped_params = url_params + file_params + [body_param_name] return MethodSpec( From 6c68a08a890e0d8812e01da6348a88509ee11af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Fri, 22 Dec 2023 23:02:29 +0300 Subject: [PATCH 07/29] remove unused method spec attrs --- dataclass_rest/boundmethod.py | 2 +- dataclass_rest/methodspec.py | 5 ----- dataclass_rest/parse_func.py | 2 -- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/dataclass_rest/boundmethod.py b/dataclass_rest/boundmethod.py index c27acf8..367f0aa 100644 --- a/dataclass_rest/boundmethod.py +++ b/dataclass_rest/boundmethod.py @@ -34,7 +34,7 @@ def _apply_args(self, *args, **kwargs) -> Dict: def _get_url(self, args) -> str: args = copy.copy(args) - if not self.method_spec.url_template_func_arg_spec: + if not self.method_spec.url_template_func_pop_args: return self.method_spec.url_template_func(**args) for arg in self.method_spec.url_template_func_pop_args: diff --git a/dataclass_rest/methodspec.py b/dataclass_rest/methodspec.py index 593d938..0101213 100644 --- a/dataclass_rest/methodspec.py +++ b/dataclass_rest/methodspec.py @@ -1,4 +1,3 @@ -from inspect import FullArgSpec from typing import Any, Dict, Type, Callable, List, Optional @@ -6,10 +5,8 @@ class MethodSpec: def __init__( self, func: Callable, - func_arg_spec: FullArgSpec, url_template: Optional[str], url_template_func: Optional[Callable[..., str]], - url_template_func_arg_spec: Optional[FullArgSpec], url_template_func_pop_args: Optional[List[str]], http_method: str, response_type: Type, @@ -32,5 +29,3 @@ def __init__( self.additional_params = additional_params self.is_json_request = is_json_request self.file_param_names = file_param_names - self.func_arg_spec = func_arg_spec - self.url_template_func_arg_spec = url_template_func_arg_spec diff --git a/dataclass_rest/parse_func.py b/dataclass_rest/parse_func.py index 8caf27e..f114ed3 100644 --- a/dataclass_rest/parse_func.py +++ b/dataclass_rest/parse_func.py @@ -91,11 +91,9 @@ def parse_func( return MethodSpec( func=func, - func_arg_spec=spec, http_method=method, url_template=url_template if is_string_url_template else None, url_template_func=url_template_func, - url_template_func_arg_spec=url_template_func_arg_spec, url_template_func_pop_args=url_template_func_pop_args, query_params_type=create_query_params_type(spec, func, skipped_params), body_type=create_body_type(spec, body_param_name), From 6f2b4b3df20dda0c4b1514fcd3b2e92c807c01e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Sat, 6 Jan 2024 17:24:24 +0300 Subject: [PATCH 08/29] remove unused get_url_params func, add get_url_params_from_string function, remove unused try except --- dataclass_rest/parse_func.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/dataclass_rest/parse_func.py b/dataclass_rest/parse_func.py index f114ed3..0fbc483 100644 --- a/dataclass_rest/parse_func.py +++ b/dataclass_rest/parse_func.py @@ -1,6 +1,6 @@ import string from inspect import getfullargspec, FullArgSpec, isclass -from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict, Union, Optional +from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict, Union from .http_request import File from .methodspec import MethodSpec @@ -8,14 +8,9 @@ DEFAULT_BODY_PARAM = "body" -def get_url_params(url_template: Union[str, Callable[..., str]], arg_spec: Optional[FullArgSpec] = None) -> List[str]: - is_string = isinstance(url_template, str) - - if is_string: - parsed_format = string.Formatter().parse(url_template) - return [x[1] for x in parsed_format] - else: - return arg_spec.args +def get_url_params_from_string(url_template: str) -> List[str]: + parsed_format = string.Formatter().parse(url_template) + return [x[1] for x in parsed_format] def create_query_params_type( @@ -70,7 +65,9 @@ def parse_func( is_string_url_template = isinstance(url_template, str) url_template_func = url_template.format if is_string_url_template else url_template - try: + url_template_func_pop_args = None + + if not is_string_url_template: url_template_func_arg_spec = getfullargspec(url_template_func) url_template_func_args = set(url_template_func_arg_spec.args) @@ -78,14 +75,9 @@ def parse_func( diff_args = set(spec.args).difference(url_template_func_args) url_template_func_pop_args = diff_args.union(diff_kwargs) - except TypeError as _exc: - url_template_func_arg_spec = None - url_template_func_pop_args = None - - if is_string_url_template: - url_params = get_url_params(url_template) + url_params = url_template_func_arg_spec.args else: - url_params = get_url_params(url_template_func, url_template_func_arg_spec) + url_params = get_url_params_from_string(url_template) skipped_params = url_params + file_params + [body_param_name] From 5f4fcd728568809ff86ccaee6cf0ef2565bca861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8E=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= <100635212+lubaskinc0de@users.noreply.github.com> Date: Sat, 6 Jan 2024 17:27:58 +0300 Subject: [PATCH 09/29] some refactoring --- dataclass_rest/parse_func.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dataclass_rest/parse_func.py b/dataclass_rest/parse_func.py index 0fbc483..fa5c826 100644 --- a/dataclass_rest/parse_func.py +++ b/dataclass_rest/parse_func.py @@ -69,13 +69,14 @@ def parse_func( if not is_string_url_template: url_template_func_arg_spec = getfullargspec(url_template_func) + url_template_func_args = url_template_func_arg_spec.args - url_template_func_args = set(url_template_func_arg_spec.args) - diff_kwargs = set(spec.kwonlyargs).difference(url_template_func_args) - diff_args = set(spec.args).difference(url_template_func_args) + url_template_func_args_set = set(url_template_func_args) + diff_kwargs = set(spec.kwonlyargs).difference(url_template_func_args_set) + diff_args = set(spec.args).difference(url_template_func_args_set) url_template_func_pop_args = diff_args.union(diff_kwargs) - url_params = url_template_func_arg_spec.args + url_params = url_template_func_args else: url_params = get_url_params_from_string(url_template) From bcb3da4e615f87ce62310c74e78fbc0be601d8a1 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Fri, 17 May 2024 13:00:49 +0200 Subject: [PATCH 10/29] fix test --- requirements.txt | 4 +++- tests/test_init.py | 32 ++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/requirements.txt b/requirements.txt index a387c1e..70d165d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,6 @@ typing_extensions aiohttp requests nose2 -mypy \ No newline at end of file +mypy +pytest +pytest-asyncio \ No newline at end of file diff --git a/tests/test_init.py b/tests/test_init.py index ba52acf..42eedeb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,11 +1,13 @@ from dataclasses import dataclass -from dataclass_factory import Factory, NameStyle, Schema +import pytest +from adaptix import Retort, NameStyle, name_mapping from requests import Session from dataclass_rest import get -from dataclass_rest.async_base import AsyncClient -from dataclass_rest.sync_base import Client +from dataclass_rest.http.aiohttp import AiohttpClient +from dataclass_rest.http.requests import RequestsClient + @dataclass class Todo: @@ -13,12 +15,14 @@ class Todo: def test_sync(): - class RealClient(Client): + class RealClient(RequestsClient): def __init__(self): super().__init__("https://jsonplaceholder.typicode.com/", Session()) - def _init_factory(self): - return Factory(default_schema=Schema(name_style=NameStyle.camel_lower)) + def _init_request_body_factory(self) -> Retort: + return Retort(recipe=[ + name_mapping(name_style=NameStyle.CAMEL), + ]) @get("todos/{id}") def get_todo(self, id: str) -> Todo: @@ -27,16 +31,20 @@ def get_todo(self, id: str) -> Todo: assert RealClient() -def test_async(): - class RealClient(AsyncClient): +@pytest.mark.asyncio +async def test_async(): + class RealClient(AiohttpClient): def __init__(self): - super().__init__("https://jsonplaceholder.typicode.com/", Session()) + super().__init__("https://jsonplaceholder.typicode.com/") - def _init_factory(self): - return Factory(default_schema=Schema(name_style=NameStyle.camel_lower)) + def _init_request_body_factory(self) -> Retort: + return Retort(recipe=[ + name_mapping(name_style=NameStyle.CAMEL), + ]) @get("todos/{id}") async def get_todo(self, id: str) -> Todo: pass - assert RealClient() + client = RealClient() + await client.session.close() From f951c3ede94021d9a34d7cd11da5aa1934b0f168 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Fri, 17 May 2024 13:02:03 +0200 Subject: [PATCH 11/29] github actions --- .github/workflows/setup.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/setup.yml diff --git a/.github/workflows/setup.yml b/.github/workflows/setup.yml new file mode 100644 index 0000000..21b3e9b --- /dev/null +++ b/.github/workflows/setup.yml @@ -0,0 +1,38 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: CI + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + +jobs: + cpython: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + python-version: + - "3.10" + - "3.11" + - "3.12" + + steps: + - uses: actions/checkout@v4 + - name: Set up ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install '.' -r requirements_dev.txt + + - name: Run tests + run: | + pytest From 5540d770e9f3aad24de819cabed6838358c3ca82 Mon Sep 17 00:00:00 2001 From: KuroAngel <145038102+KuroKoka551@users.noreply.github.com> Date: Fri, 17 May 2024 18:00:55 +0500 Subject: [PATCH 12/29] Fix typo --- examples/async_example.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/async_example.py b/examples/async_example.py index 0f8870b..fe927f9 100644 --- a/examples/async_example.py +++ b/examples/async_example.py @@ -45,11 +45,11 @@ async def delete_todo(self, id: int): @post("todos") async def create_todo(self, body: Todo) -> Todo: - """Созадем Todo""" + """Создаем Todo""" @get("https://httpbin.org/get") def get_httpbin(self) -> Any: - """Используемый другой base_url""" + """Используем другой base_url""" @post("https://httpbin.org/post") def upload_image(self, file: File): From 7441163d7992a60ebc69ded9e1feafae18c87e18 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Fri, 17 May 2024 16:22:55 +0200 Subject: [PATCH 13/29] test params and factory --- requirements.txt | 1 + tests/__init__.py | 0 tests/requests/__init__.py | 0 tests/requests/conftest.py | 14 +++++++ tests/requests/test_factory.py | 59 +++++++++++++++++++++++++++++ tests/requests/test_params.py | 69 ++++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/requests/__init__.py create mode 100644 tests/requests/conftest.py create mode 100644 tests/requests/test_factory.py create mode 100644 tests/requests/test_params.py diff --git a/requirements.txt b/requirements.txt index 70d165d..48476ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ adaptix typing_extensions aiohttp requests +requests-mock nose2 mypy pytest diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/requests/__init__.py b/tests/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/requests/conftest.py b/tests/requests/conftest.py new file mode 100644 index 0000000..9ee4254 --- /dev/null +++ b/tests/requests/conftest.py @@ -0,0 +1,14 @@ +import pytest +import requests_mock + +from dataclass_rest.http import requests + +@pytest.fixture +def session(): + return requests.Session() + + +@pytest.fixture +def mocker(session): + with requests_mock.Mocker(session=session, case_sensitive=True) as session_mock: + yield session_mock diff --git a/tests/requests/test_factory.py b/tests/requests/test_factory.py new file mode 100644 index 0000000..05bf77a --- /dev/null +++ b/tests/requests/test_factory.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass +from enum import Enum + +from adaptix import Retort, NameStyle, name_mapping + +from dataclass_rest import patch +from dataclass_rest.http.requests import RequestsClient + + +class Selection(Enum): + ONE = "ONE" + TWO = "TWO" + + +@dataclass +class RequestBody: + int_param: int + selection: Selection + + +@dataclass +class ResponseBody: + int_param: int + selection: Selection + + +def test_body(session, mocker): + class Api(RequestsClient): + def _init_request_body_factory(self) -> Retort: + return Retort(recipe=[ + name_mapping(name_style=NameStyle.CAMEL), + ]) + + def _init_request_args_factory(self) -> Retort: + return Retort(recipe=[ + name_mapping(name_style=NameStyle.UPPER_DOT), + ]) + + def _init_response_body_factory(self) -> Retort: + return Retort(recipe=[ + name_mapping(name_style=NameStyle.LOWER_KEBAB), + ]) + + @patch("/post/") + def post_x(self, long_param: str, body: RequestBody) -> ResponseBody: + raise NotImplementedError() + + mocker.patch( + url="http://example.com/post/", + text="""{"int-param": 1, "selection": "TWO"}""", + ) + client = Api(base_url="http://example.com", session=session) + result = client.post_x( + long_param="hello", body=RequestBody(int_param=42, selection=Selection.ONE), + ) + assert result == ResponseBody(int_param=1, selection=Selection.TWO) + assert mocker.called_once + assert mocker.request_history[0].json() == {"intParam": 42, "selection": "ONE"} + assert mocker.request_history[0].query == "LONG.PARAM=hello" diff --git a/tests/requests/test_params.py b/tests/requests/test_params.py new file mode 100644 index 0000000..6b0b958 --- /dev/null +++ b/tests/requests/test_params.py @@ -0,0 +1,69 @@ +from dataclasses import dataclass +from typing import Optional + +from dataclass_rest import get, post +from dataclass_rest.http.requests import RequestsClient + + +def test_methods(session, mocker): + class Api(RequestsClient): + @get("/get") + def get_x(self) -> list[int]: + raise NotImplementedError() + + @post("/post") + def post_x(self) -> list[int]: + raise NotImplementedError() + + mocker.get("http://example.com/get", text="[1,2]") + mocker.post("http://example.com/post", text="[1,2,3]") + client = Api(base_url="http://example.com", session=session) + assert client.get_x() == [1, 2] + assert client.post_x() == [1, 2, 3] + + +def test_path_params(session, mocker): + class Api(RequestsClient): + @post("/post/{id}") + def post_x(self, id) -> list[int]: + raise NotImplementedError() + + mocker.post("http://example.com/post/1", text="[1]") + mocker.post("http://example.com/post/2", text="[1,2]") + client = Api(base_url="http://example.com", session=session) + assert client.post_x(1) == [1] + assert client.post_x(2) == [1, 2] + + +def test_query_params(session, mocker): + class Api(RequestsClient): + @post("/post/{id}") + def post_x(self, id: str, param: Optional[int]) -> list[int]: + raise NotImplementedError() + + mocker.post("http://example.com/post/x?", text="[0]") + mocker.post("http://example.com/post/x?param=1", text="[1]") + mocker.post("http://example.com/post/x?param=2", text="[1,2]") + client = Api(base_url="http://example.com", session=session) + assert client.post_x("x", None) == [0] + assert client.post_x("x", 1) == [1] + assert client.post_x("x", 2) == [1, 2] + + +@dataclass +class RequestBody: + x: int + y: str + + +def test_body(session, mocker): + class Api(RequestsClient): + @post("/post/") + def post_x(self, body: RequestBody) -> None: + raise NotImplementedError() + + mocker.post("http://example.com/post/", text="null") + client = Api(base_url="http://example.com", session=session) + assert client.post_x(RequestBody(x=1, y="test")) is None + assert mocker.called_once + assert mocker.request_history[0].json() == {"x": 1, "y": "test"} From baf55c96e3319b26d22650f73126f0a4e65fd680 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Fri, 17 May 2024 16:52:52 +0200 Subject: [PATCH 14/29] fix reqs file name, remove travis --- .travis.yml | 10 ---------- requirements.txt => requirements_dev.txt | 0 2 files changed, 10 deletions(-) delete mode 100644 .travis.yml rename requirements.txt => requirements_dev.txt (100%) diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 07741a6..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -dist: bionic -language: python -python: - - "3.8" - - "3.7" - - "3.6" -script: - nose2 -v && mypy tests dataclass_rest -install: - - pip3 install -r requirements.txt diff --git a/requirements.txt b/requirements_dev.txt similarity index 100% rename from requirements.txt rename to requirements_dev.txt From 39b4851deff351d2d606891eaaa883ac9100a9eb Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Fri, 17 May 2024 17:11:30 +0200 Subject: [PATCH 15/29] more strict matching --- requirements_dev.txt | 1 - tests/requests/test_factory.py | 4 ++-- tests/requests/test_params.py | 16 ++++++++-------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index 48476ff..23604f7 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,7 +3,6 @@ typing_extensions aiohttp requests requests-mock -nose2 mypy pytest pytest-asyncio \ No newline at end of file diff --git a/tests/requests/test_factory.py b/tests/requests/test_factory.py index 05bf77a..4a5d762 100644 --- a/tests/requests/test_factory.py +++ b/tests/requests/test_factory.py @@ -46,8 +46,9 @@ def post_x(self, long_param: str, body: RequestBody) -> ResponseBody: raise NotImplementedError() mocker.patch( - url="http://example.com/post/", + url="http://example.com/post/?LONG.PARAM=hello", text="""{"int-param": 1, "selection": "TWO"}""", + complete_qs=True, ) client = Api(base_url="http://example.com", session=session) result = client.post_x( @@ -56,4 +57,3 @@ def post_x(self, long_param: str, body: RequestBody) -> ResponseBody: assert result == ResponseBody(int_param=1, selection=Selection.TWO) assert mocker.called_once assert mocker.request_history[0].json() == {"intParam": 42, "selection": "ONE"} - assert mocker.request_history[0].query == "LONG.PARAM=hello" diff --git a/tests/requests/test_params.py b/tests/requests/test_params.py index 6b0b958..50a8573 100644 --- a/tests/requests/test_params.py +++ b/tests/requests/test_params.py @@ -15,8 +15,8 @@ def get_x(self) -> list[int]: def post_x(self) -> list[int]: raise NotImplementedError() - mocker.get("http://example.com/get", text="[1,2]") - mocker.post("http://example.com/post", text="[1,2,3]") + mocker.get("http://example.com/get", text="[1,2]", complete_qs=True) + mocker.post("http://example.com/post", text="[1,2,3]", complete_qs=True) client = Api(base_url="http://example.com", session=session) assert client.get_x() == [1, 2] assert client.post_x() == [1, 2, 3] @@ -28,8 +28,8 @@ class Api(RequestsClient): def post_x(self, id) -> list[int]: raise NotImplementedError() - mocker.post("http://example.com/post/1", text="[1]") - mocker.post("http://example.com/post/2", text="[1,2]") + mocker.post("http://example.com/post/1", text="[1]", complete_qs=True) + mocker.post("http://example.com/post/2", text="[1,2]", complete_qs=True) client = Api(base_url="http://example.com", session=session) assert client.post_x(1) == [1] assert client.post_x(2) == [1, 2] @@ -41,9 +41,9 @@ class Api(RequestsClient): def post_x(self, id: str, param: Optional[int]) -> list[int]: raise NotImplementedError() - mocker.post("http://example.com/post/x?", text="[0]") - mocker.post("http://example.com/post/x?param=1", text="[1]") - mocker.post("http://example.com/post/x?param=2", text="[1,2]") + mocker.post("http://example.com/post/x?", text="[0]", complete_qs=True) + mocker.post("http://example.com/post/x?param=1", text="[1]", complete_qs=True) + mocker.post("http://example.com/post/x?param=2", text="[1,2]", complete_qs=True) client = Api(base_url="http://example.com", session=session) assert client.post_x("x", None) == [0] assert client.post_x("x", 1) == [1] @@ -62,7 +62,7 @@ class Api(RequestsClient): def post_x(self, body: RequestBody) -> None: raise NotImplementedError() - mocker.post("http://example.com/post/", text="null") + mocker.post("http://example.com/post/", text="null", complete_qs=True) client = Api(base_url="http://example.com", session=session) assert client.post_x(RequestBody(x=1, y="test")) is None assert mocker.called_once From 87a6f0fc68e7c12340a86ae89d01942254dc9c89 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Tue, 21 May 2024 18:13:46 +0200 Subject: [PATCH 16/29] hints in readme --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index 1b8b830..dc1e039 100644 --- a/README.md +++ b/README.md @@ -102,3 +102,40 @@ To set same behavior for all methods inherit from BoundMethod class, override `_ ### Other params You can use different body argument name if you want. Just pass `body_name` to the decorator. + + +### Special cases + +#### `None` in query params + +By default, AioHTTP doesn't skip query params, you can customize that overriding `_pre_process_request` in Method class + +```python +class NoneAwareAiohttpMethod(AiohttpMethod): + async def _pre_process_request(self, request: HttpRequest) -> HttpRequest: + request.query_params = { + k: v for k, v in request.query_params.items() if v is not None + } + return request + + +class Client(AiohttpClient): + method_class = NoneAwareAiohttpMethod +``` + +#### Handling `No content` + +By default, en each method json response is expected. Sometime you expect no content from server. Especially for 204. +You can handle it by overriding `_response_body` method, e.g.: + +```python +class NoneAwareRequestsMethod(RequestsMethod): + def _response_body(self, response: Response) -> Any: + if response.status_code == http.HTTPStatus.NO_CONTENT: + return None + return super()._response_body(response) + + +class Client(RequestsClient): + method_class = NoneAwareRequestsMethod +``` From 59626280eba8d3841adb01212e7c37f0ca05377c Mon Sep 17 00:00:00 2001 From: Dark04072006 Date: Sun, 7 Jul 2024 00:54:55 +0300 Subject: [PATCH 17/29] fix type hints --- dataclass_rest/rest.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/dataclass_rest/rest.py b/dataclass_rest/rest.py index 9394811..9481b87 100644 --- a/dataclass_rest/rest.py +++ b/dataclass_rest/rest.py @@ -1,10 +1,12 @@ -from functools import partial -from typing import Any, Dict, Optional, Callable +from typing import Any, Dict, Optional, Callable, ParamSpec, TypeVar from .boundmethod import BoundMethod from .method import Method from .parse_func import parse_func, DEFAULT_BODY_PARAM +_P = ParamSpec("_P") +_RT = TypeVar("_RT") + def rest( url_template: str, @@ -32,8 +34,15 @@ def dec(func: Callable) -> Method: return dec -get = partial(rest, method="GET") -post = partial(rest, method="POST") -put = partial(rest, method="PUT") -patch = partial(rest, method="PATCH") -delete = partial(rest, method="DELETE") +def _rest_method(func: Callable[_P, _RT], method: str) -> Callable[_P, _RT]: + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _RT: + return func(*args, **kwargs, method=method) + + return wrapper + + +get = _rest_method(rest, method="GET") +post = _rest_method(rest, method="POST") +put = _rest_method(rest, method="PUT") +patch = _rest_method(rest, method="PATCH") +delete = _rest_method(rest, method="DELETE") From e571d3ee6366c09001e7129642e3aab7af2686d5 Mon Sep 17 00:00:00 2001 From: Dark04072006 Date: Fri, 26 Jul 2024 00:38:18 +0300 Subject: [PATCH 18/29] 3.6+ version support --- dataclass_rest/rest.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/dataclass_rest/rest.py b/dataclass_rest/rest.py index 9481b87..21e03b0 100644 --- a/dataclass_rest/rest.py +++ b/dataclass_rest/rest.py @@ -1,21 +1,20 @@ -from typing import Any, Dict, Optional, Callable, ParamSpec, TypeVar +from typing import Any, Callable, Dict, Optional, TypeVar, cast from .boundmethod import BoundMethod from .method import Method -from .parse_func import parse_func, DEFAULT_BODY_PARAM +from .parse_func import DEFAULT_BODY_PARAM, parse_func -_P = ParamSpec("_P") -_RT = TypeVar("_RT") +_Func = TypeVar("_Func", bound=Callable[..., Any]) def rest( - url_template: str, - *, - method: str, - body_name: str = DEFAULT_BODY_PARAM, - additional_params: Optional[Dict[str, Any]] = None, - method_class: Optional[Callable[..., BoundMethod]] = None, - send_json: bool = True, + url_template: str, + *, + method: str, + body_name: str = DEFAULT_BODY_PARAM, + additional_params: Optional[Dict[str, Any]] = None, + method_class: Optional[Callable[..., BoundMethod]] = None, + send_json: bool = True, ) -> Callable[[Callable], Method]: if additional_params is None: additional_params = {} @@ -34,12 +33,12 @@ def dec(func: Callable) -> Method: return dec -def _rest_method(func: Callable[_P, _RT], method: str) -> Callable[_P, _RT]: - def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _RT: +def _rest_method(func: _Func, method: str) -> _Func: + def wrapper(*args, **kwargs): return func(*args, **kwargs, method=method) - return wrapper - + return cast(_Func, wrapper) + get = _rest_method(rest, method="GET") post = _rest_method(rest, method="POST") From f18df29ebb9591f29fa230273ad72612712a1482 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Sat, 27 Jul 2024 20:57:23 +0200 Subject: [PATCH 19/29] pyproject.toml, ruff --- .github/workflows/setup.yml | 6 ++- pyproject.toml | 38 +++++++++++++++++++ requirements_dev.txt | 4 +- setup.py | 37 ------------------ .../dataclass_rest}/__init__.py | 0 .../dataclass_rest}/base_client.py | 0 .../dataclass_rest}/boundmethod.py | 0 .../dataclass_rest}/client_protocol.py | 0 .../dataclass_rest}/exceptions.py | 0 .../dataclass_rest}/http/__init__.py | 0 .../dataclass_rest}/http/aiohttp.py | 0 .../dataclass_rest}/http/requests.py | 0 .../dataclass_rest}/http_request.py | 0 .../dataclass_rest}/method.py | 0 .../dataclass_rest}/methodspec.py | 0 .../dataclass_rest}/parse_func.py | 0 .../dataclass_rest}/rest.py | 0 17 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py rename {dataclass_rest => src/dataclass_rest}/__init__.py (100%) rename {dataclass_rest => src/dataclass_rest}/base_client.py (100%) rename {dataclass_rest => src/dataclass_rest}/boundmethod.py (100%) rename {dataclass_rest => src/dataclass_rest}/client_protocol.py (100%) rename {dataclass_rest => src/dataclass_rest}/exceptions.py (100%) rename {dataclass_rest => src/dataclass_rest}/http/__init__.py (100%) rename {dataclass_rest => src/dataclass_rest}/http/aiohttp.py (100%) rename {dataclass_rest => src/dataclass_rest}/http/requests.py (100%) rename {dataclass_rest => src/dataclass_rest}/http_request.py (100%) rename {dataclass_rest => src/dataclass_rest}/method.py (100%) rename {dataclass_rest => src/dataclass_rest}/methodspec.py (100%) rename {dataclass_rest => src/dataclass_rest}/parse_func.py (100%) rename {dataclass_rest => src/dataclass_rest}/rest.py (100%) diff --git a/.github/workflows/setup.yml b/.github/workflows/setup.yml index 21b3e9b..2eef0c1 100644 --- a/.github/workflows/setup.yml +++ b/.github/workflows/setup.yml @@ -32,7 +32,11 @@ jobs: run: | python -m pip install --upgrade pip pip install '.' -r requirements_dev.txt - + + - name: Run ruff + run: | + ruff check . + - name: Run tests run: | pytest diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..27cdab0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=66.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + +[project] +name = "dataclass_rest" +version = "0.4" +readme = "README.md" +authors = [ + { name = "Andrey Tikhonov", email = "17@itishka.org" }, +] +license = { text = "Apache-2.0" } +description = "An utility for writing simple clients for REST like APIs" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] +dependencies = [ + "adaptix", +] + +[project.urls] +"Source" = "https://github.com/reagento/dataclass-rest" +"Homepage" = "https://github.com/reagento/dataclass-rest" +"Bug Tracker" = "https://github.com/reagento/dataclass-rest/issues" + + diff --git a/requirements_dev.txt b/requirements_dev.txt index 23604f7..9b3bb7d 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -5,4 +5,6 @@ requests requests-mock mypy pytest -pytest-asyncio \ No newline at end of file +pytest-asyncio + +ruff==0.5.* \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 3894bfc..0000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -from os import path - -from setuptools import setup - -this_directory = path.abspath(path.dirname(__file__)) -with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - -setup( - name='dataclass_rest', - description='An utility for writing simple clients for REST like APIs', - long_description=long_description, - long_description_content_type='text/markdown', - version='0.4', - url='https://github.com/reagento/dataclass-rest', - author='A. Tikhonov', - author_email='17@itishka.org', - license='Apache2', - classifiers=[ - 'Operating System :: OS Independent', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 3' - ], - packages=['dataclass_rest', 'dataclass_rest.http'], - install_requires=[ - 'dataclasses;python_version<"3.7"', - 'adaptix', - 'typing_extensions;python_version<"3.8"', - ], - package_data={ - 'dataclass_rest': ['py.typed'], - }, - python_requires=">=3.6", -) diff --git a/dataclass_rest/__init__.py b/src/dataclass_rest/__init__.py similarity index 100% rename from dataclass_rest/__init__.py rename to src/dataclass_rest/__init__.py diff --git a/dataclass_rest/base_client.py b/src/dataclass_rest/base_client.py similarity index 100% rename from dataclass_rest/base_client.py rename to src/dataclass_rest/base_client.py diff --git a/dataclass_rest/boundmethod.py b/src/dataclass_rest/boundmethod.py similarity index 100% rename from dataclass_rest/boundmethod.py rename to src/dataclass_rest/boundmethod.py diff --git a/dataclass_rest/client_protocol.py b/src/dataclass_rest/client_protocol.py similarity index 100% rename from dataclass_rest/client_protocol.py rename to src/dataclass_rest/client_protocol.py diff --git a/dataclass_rest/exceptions.py b/src/dataclass_rest/exceptions.py similarity index 100% rename from dataclass_rest/exceptions.py rename to src/dataclass_rest/exceptions.py diff --git a/dataclass_rest/http/__init__.py b/src/dataclass_rest/http/__init__.py similarity index 100% rename from dataclass_rest/http/__init__.py rename to src/dataclass_rest/http/__init__.py diff --git a/dataclass_rest/http/aiohttp.py b/src/dataclass_rest/http/aiohttp.py similarity index 100% rename from dataclass_rest/http/aiohttp.py rename to src/dataclass_rest/http/aiohttp.py diff --git a/dataclass_rest/http/requests.py b/src/dataclass_rest/http/requests.py similarity index 100% rename from dataclass_rest/http/requests.py rename to src/dataclass_rest/http/requests.py diff --git a/dataclass_rest/http_request.py b/src/dataclass_rest/http_request.py similarity index 100% rename from dataclass_rest/http_request.py rename to src/dataclass_rest/http_request.py diff --git a/dataclass_rest/method.py b/src/dataclass_rest/method.py similarity index 100% rename from dataclass_rest/method.py rename to src/dataclass_rest/method.py diff --git a/dataclass_rest/methodspec.py b/src/dataclass_rest/methodspec.py similarity index 100% rename from dataclass_rest/methodspec.py rename to src/dataclass_rest/methodspec.py diff --git a/dataclass_rest/parse_func.py b/src/dataclass_rest/parse_func.py similarity index 100% rename from dataclass_rest/parse_func.py rename to src/dataclass_rest/parse_func.py diff --git a/dataclass_rest/rest.py b/src/dataclass_rest/rest.py similarity index 100% rename from dataclass_rest/rest.py rename to src/dataclass_rest/rest.py From ee5d2770a7cd5e2616114c6f7b8daa26f7c44b6e Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Sat, 27 Jul 2024 20:59:10 +0200 Subject: [PATCH 20/29] py.typed --- src/dataclass_rest/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/dataclass_rest/py.typed diff --git a/src/dataclass_rest/py.typed b/src/dataclass_rest/py.typed new file mode 100644 index 0000000..e69de29 From 508ccf1c6ab89c4132708cc9bde796b442e3cd7c Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Sat, 27 Jul 2024 21:14:59 +0200 Subject: [PATCH 21/29] more strict ruff rules --- .ruff.toml | 42 +++++++++++++++++++++ src/dataclass_rest/__init__.py | 2 +- src/dataclass_rest/base_client.py | 3 +- src/dataclass_rest/boundmethod.py | 18 ++++----- src/dataclass_rest/client_protocol.py | 12 +++++- src/dataclass_rest/http/aiohttp.py | 19 +++++++--- src/dataclass_rest/http/requests.py | 15 +++++--- src/dataclass_rest/http_request.py | 2 +- src/dataclass_rest/method.py | 2 +- src/dataclass_rest/methodspec.py | 3 +- src/dataclass_rest/parse_func.py | 5 ++- tests/requests/conftest.py | 5 ++- tests/requests/test_factory.py | 11 ++++-- tests/requests/test_params.py | 53 +++++++++++++++++---------- tests/test_init.py | 7 +++- 15 files changed, 142 insertions(+), 57 deletions(-) create mode 100644 .ruff.toml diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..7fe0663 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,42 @@ +line-length = 79 +target-version="py38" +src = ["src"] + +include = ["src/**.py", "tests/**.py"] +exclude = ["src/dishka/_adaptix/**"] + +lint.select = [ +"ALL" +] +lint.ignore = [ + "ARG", + "ANN", + "D", + "EM101", + "EM102", + "PT001", + "PT023", + "SIM108", + "SIM114", + "TRY003", + "PLW2901", + "RET505", + "RET506", + "PLR0913", + "UP038", + "TCH001", + "FA100", + # tempraty disabled + "PGH005", + "PLR2004", + "N818", # compatibility issue +] + +[lint.per-file-ignores] +"tests/**" = ["TID252", "PLR2004", "S101", "A002"] + +[lint.isort] +no-lines-before = ["local-folder"] + +[lint.flake8-tidy-imports] +ban-relative-imports = "parents" diff --git a/src/dataclass_rest/__init__.py b/src/dataclass_rest/__init__.py index 6f444a2..064320e 100644 --- a/src/dataclass_rest/__init__.py +++ b/src/dataclass_rest/__init__.py @@ -5,4 +5,4 @@ ] from .http_request import File -from .rest import rest, get, put, post, patch, delete +from .rest import delete, get, patch, post, put, rest diff --git a/src/dataclass_rest/base_client.py b/src/dataclass_rest/base_client.py index 0de8b4f..8272430 100644 --- a/src/dataclass_rest/base_client.py +++ b/src/dataclass_rest/base_client.py @@ -1,7 +1,8 @@ from adaptix import Retort from .client_protocol import ( - ClientProtocol, FactoryProtocol, + ClientProtocol, + FactoryProtocol, ) diff --git a/src/dataclass_rest/boundmethod.py b/src/dataclass_rest/boundmethod.py index f69021a..866f670 100644 --- a/src/dataclass_rest/boundmethod.py +++ b/src/dataclass_rest/boundmethod.py @@ -1,11 +1,11 @@ from abc import ABC, abstractmethod from inspect import getcallargs from logging import getLogger -from typing import Dict, Any, Callable, Optional, NoReturn, Type +from typing import Any, Callable, Dict, NoReturn, Optional, Type -from .client_protocol import ClientProtocol, ClientMethodProtocol -from .exceptions import MalformedResponse -from .http_request import HttpRequest, File +from .client_protocol import ClientMethodProtocol, ClientProtocol +from .exceptions import ClientLibraryError, MalformedResponse +from .http_request import File, HttpRequest from .methodspec import MethodSpec logger = getLogger(__name__) @@ -74,7 +74,7 @@ def __call__(self, *args, **kwargs): raise NotImplementedError def _on_error_default(self, response: Any) -> Any: - raise RuntimeError # TODO exceptions + raise ClientLibraryError class SyncMethod(BoundMethod): @@ -90,8 +90,7 @@ def __call__(self, *args, **kwargs): request = self._pre_process_request(request) raw_response = self.client.do_request(request) response = self._pre_process_response(raw_response) - response = self._post_process_response(response) - return response + return self._post_process_response(response) def _pre_process_request(self, request: HttpRequest) -> HttpRequest: return request @@ -135,8 +134,7 @@ async def __call__(self, *args, **kwargs): raw_response = await self.client.do_request(request) response = await self._pre_process_response(raw_response) await self._release_raw_response(raw_response) - response = await self._post_process_response(response) - return response + return await self._post_process_response(response) async def _pre_process_request(self, request: HttpRequest) -> HttpRequest: return request @@ -162,7 +160,7 @@ async def _pre_process_response(self, response: Any) -> Any: raise MalformedResponse from e async def _on_error_default(self, response: Any) -> NoReturn: - raise RuntimeError # TODO exceptions + raise ClientLibraryError @abstractmethod async def _response_body(self, response: Any) -> Any: diff --git a/src/dataclass_rest/client_protocol.py b/src/dataclass_rest/client_protocol.py index eb8e11f..1ed30d5 100644 --- a/src/dataclass_rest/client_protocol.py +++ b/src/dataclass_rest/client_protocol.py @@ -1,5 +1,11 @@ from typing import ( - Protocol, Any, Optional, Callable, Type, runtime_checkable, TypeVar, + Any, + Callable, + Optional, + Protocol, + Type, + TypeVar, + runtime_checkable, ) from .http_request import HttpRequest @@ -18,7 +24,9 @@ class FactoryProtocol(Protocol): def load(self, data: Any, class_: Type[TypeT]) -> TypeT: raise NotImplementedError - def dump(self, data: TypeT, class_: Type[TypeT] = None) -> Any: + def dump( + self, data: TypeT, class_: Optional[Type[TypeT]] = None, + ) -> Any: raise NotImplementedError diff --git a/src/dataclass_rest/http/aiohttp.py b/src/dataclass_rest/http/aiohttp.py index 6d64e38..e8e400f 100644 --- a/src/dataclass_rest/http/aiohttp.py +++ b/src/dataclass_rest/http/aiohttp.py @@ -4,15 +4,22 @@ from aiohttp import FormData from aiohttp.client import ( - ClientResponse, ClientSession, ClientError as AioHttpClientError, + ClientError as AioHttpClientError, +) +from aiohttp.client import ( + ClientResponse, + ClientSession, ) -from ..base_client import BaseClient -from ..boundmethod import AsyncMethod -from ..exceptions import ( - ClientError, ClientLibraryError, ServerError, MalformedResponse, +from dataclass_rest.base_client import BaseClient +from dataclass_rest.boundmethod import AsyncMethod +from dataclass_rest.exceptions import ( + ClientError, + ClientLibraryError, + MalformedResponse, + ServerError, ) -from ..http_request import HttpRequest +from dataclass_rest.http_request import HttpRequest class AiohttpMethod(AsyncMethod): diff --git a/src/dataclass_rest/http/requests.py b/src/dataclass_rest/http/requests.py index 2082d51..6b17484 100644 --- a/src/dataclass_rest/http/requests.py +++ b/src/dataclass_rest/http/requests.py @@ -2,14 +2,17 @@ from json import JSONDecodeError from typing import Any, Optional, Tuple -from requests import Session, Response, RequestException +from requests import RequestException, Response, Session -from ..base_client import BaseClient -from ..boundmethod import SyncMethod -from ..exceptions import ( - ClientLibraryError, ClientError, ServerError, MalformedResponse, +from dataclass_rest.base_client import BaseClient +from dataclass_rest.boundmethod import SyncMethod +from dataclass_rest.exceptions import ( + ClientError, + ClientLibraryError, + MalformedResponse, + ServerError, ) -from ..http_request import HttpRequest, File +from dataclass_rest.http_request import File, HttpRequest class RequestsMethod(SyncMethod): diff --git a/src/dataclass_rest/http_request.py b/src/dataclass_rest/http_request.py index c5bc073..b7ba4e0 100644 --- a/src/dataclass_rest/http_request.py +++ b/src/dataclass_rest/http_request.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Dict, Union, IO, Optional +from typing import IO, Any, Dict, Optional, Union @dataclass diff --git a/src/dataclass_rest/method.py b/src/dataclass_rest/method.py index 66612e7..23c11fa 100644 --- a/src/dataclass_rest/method.py +++ b/src/dataclass_rest/method.py @@ -25,7 +25,7 @@ def __set_name__(self, owner, name): f"No type for bound method is specified. " f"Provide either `{owner.__name__}.method_class` attribute or " f"`method_class=` argument for decorator " - f"on your `{name}` method" + f"on your `{name}` method", ) def __get__( diff --git a/src/dataclass_rest/methodspec.py b/src/dataclass_rest/methodspec.py index bf42c61..0cfbd04 100644 --- a/src/dataclass_rest/methodspec.py +++ b/src/dataclass_rest/methodspec.py @@ -1,10 +1,11 @@ -from typing import Any, Dict, Type, Callable, List +from typing import Any, Callable, Dict, List, Type class MethodSpec: def __init__( self, func: Callable, + *, url_template: str, http_method: str, response_type: Type, diff --git a/src/dataclass_rest/parse_func.py b/src/dataclass_rest/parse_func.py index 3c02e8f..0bd54c6 100644 --- a/src/dataclass_rest/parse_func.py +++ b/src/dataclass_rest/parse_func.py @@ -1,6 +1,6 @@ import string -from inspect import getfullargspec, FullArgSpec, isclass -from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict +from inspect import FullArgSpec, getfullargspec, isclass +from typing import Any, Callable, Dict, List, Sequence, Type, TypedDict from .http_request import File from .methodspec import MethodSpec @@ -53,6 +53,7 @@ def get_file_params(spec): def parse_func( func: Callable, + *, method: str, url_template: str, additional_params: Dict[str, Any], diff --git a/tests/requests/conftest.py b/tests/requests/conftest.py index 9ee4254..849f945 100644 --- a/tests/requests/conftest.py +++ b/tests/requests/conftest.py @@ -3,6 +3,7 @@ from dataclass_rest.http import requests + @pytest.fixture def session(): return requests.Session() @@ -10,5 +11,7 @@ def session(): @pytest.fixture def mocker(session): - with requests_mock.Mocker(session=session, case_sensitive=True) as session_mock: + with requests_mock.Mocker( + session=session, case_sensitive=True, + ) as session_mock: yield session_mock diff --git a/tests/requests/test_factory.py b/tests/requests/test_factory.py index 4a5d762..d6a5ebc 100644 --- a/tests/requests/test_factory.py +++ b/tests/requests/test_factory.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from enum import Enum -from adaptix import Retort, NameStyle, name_mapping +from adaptix import NameStyle, Retort, name_mapping from dataclass_rest import patch from dataclass_rest.http.requests import RequestsClient @@ -43,7 +43,7 @@ def _init_response_body_factory(self) -> Retort: @patch("/post/") def post_x(self, long_param: str, body: RequestBody) -> ResponseBody: - raise NotImplementedError() + raise NotImplementedError mocker.patch( url="http://example.com/post/?LONG.PARAM=hello", @@ -52,8 +52,11 @@ def post_x(self, long_param: str, body: RequestBody) -> ResponseBody: ) client = Api(base_url="http://example.com", session=session) result = client.post_x( - long_param="hello", body=RequestBody(int_param=42, selection=Selection.ONE), + long_param="hello", + body=RequestBody(int_param=42, selection=Selection.ONE), ) assert result == ResponseBody(int_param=1, selection=Selection.TWO) assert mocker.called_once - assert mocker.request_history[0].json() == {"intParam": 42, "selection": "ONE"} + + resp = mocker.request_history[0].json() + assert resp == {"intParam": 42, "selection": "ONE"} diff --git a/tests/requests/test_params.py b/tests/requests/test_params.py index 50a8573..bd8405b 100644 --- a/tests/requests/test_params.py +++ b/tests/requests/test_params.py @@ -1,19 +1,22 @@ from dataclasses import dataclass -from typing import Optional +from typing import List, Optional + +import requests +import requests_mock from dataclass_rest import get, post from dataclass_rest.http.requests import RequestsClient -def test_methods(session, mocker): +def test_methods(session: requests.Session, mocker: requests_mock.Mocker): class Api(RequestsClient): @get("/get") - def get_x(self) -> list[int]: - raise NotImplementedError() + def get_x(self) -> List[int]: + raise NotImplementedError @post("/post") - def post_x(self) -> list[int]: - raise NotImplementedError() + def post_x(self) -> List[int]: + raise NotImplementedError mocker.get("http://example.com/get", text="[1,2]", complete_qs=True) mocker.post("http://example.com/post", text="[1,2,3]", complete_qs=True) @@ -22,11 +25,11 @@ def post_x(self) -> list[int]: assert client.post_x() == [1, 2, 3] -def test_path_params(session, mocker): +def test_path_params(session: requests.Session, mocker: requests_mock.Mocker): class Api(RequestsClient): @post("/post/{id}") - def post_x(self, id) -> list[int]: - raise NotImplementedError() + def post_x(self, id) -> List[int]: + raise NotImplementedError mocker.post("http://example.com/post/1", text="[1]", complete_qs=True) mocker.post("http://example.com/post/2", text="[1,2]", complete_qs=True) @@ -35,15 +38,24 @@ def post_x(self, id) -> list[int]: assert client.post_x(2) == [1, 2] -def test_query_params(session, mocker): +def test_query_params(session: requests.Session, mocker: requests_mock.Mocker): class Api(RequestsClient): @post("/post/{id}") - def post_x(self, id: str, param: Optional[int]) -> list[int]: - raise NotImplementedError() - - mocker.post("http://example.com/post/x?", text="[0]", complete_qs=True) - mocker.post("http://example.com/post/x?param=1", text="[1]", complete_qs=True) - mocker.post("http://example.com/post/x?param=2", text="[1,2]", complete_qs=True) + def post_x(self, id: str, param: Optional[int]) -> List[int]: + raise NotImplementedError + + mocker.post( + url="http://example.com/post/x?", + text="[0]", complete_qs=True, + ) + mocker.post( + url="http://example.com/post/x?param=1", + text="[1]", complete_qs=True, + ) + mocker.post( + url="http://example.com/post/x?param=2", + text="[1,2]", complete_qs=True, + ) client = Api(base_url="http://example.com", session=session) assert client.post_x("x", None) == [0] assert client.post_x("x", 1) == [1] @@ -56,13 +68,16 @@ class RequestBody: y: str -def test_body(session, mocker): +def test_body(session: requests.Session, mocker: requests_mock.Mocker): class Api(RequestsClient): @post("/post/") def post_x(self, body: RequestBody) -> None: - raise NotImplementedError() + raise NotImplementedError - mocker.post("http://example.com/post/", text="null", complete_qs=True) + mocker.post( + url="http://example.com/post/", + text="null", complete_qs=True, + ) client = Api(base_url="http://example.com", session=session) assert client.post_x(RequestBody(x=1, y="test")) is None assert mocker.called_once diff --git a/tests/test_init.py b/tests/test_init.py index 42eedeb..e2226e8 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,7 +1,7 @@ from dataclasses import dataclass import pytest -from adaptix import Retort, NameStyle, name_mapping +from adaptix import NameStyle, Retort, name_mapping from requests import Session from dataclass_rest import get @@ -17,7 +17,10 @@ class Todo: def test_sync(): class RealClient(RequestsClient): def __init__(self): - super().__init__("https://jsonplaceholder.typicode.com/", Session()) + super().__init__( + "https://jsonplaceholder.typicode.com/", + Session(), + ) def _init_request_body_factory(self) -> Retort: return Retort(recipe=[ From 98b254025effb58790c519e0fbd5ba216230ad07 Mon Sep 17 00:00:00 2001 From: lubaskincode Date: Sun, 28 Jul 2024 20:25:20 +0300 Subject: [PATCH 22/29] move to src --- {dataclass_rest => src/dataclass_rest}/__init__.py | 0 {dataclass_rest => src/dataclass_rest}/base_client.py | 0 {dataclass_rest => src/dataclass_rest}/boundmethod.py | 0 {dataclass_rest => src/dataclass_rest}/client_protocol.py | 0 {dataclass_rest => src/dataclass_rest}/exceptions.py | 0 {dataclass_rest => src/dataclass_rest}/http/__init__.py | 0 {dataclass_rest => src/dataclass_rest}/http/aiohttp.py | 0 {dataclass_rest => src/dataclass_rest}/http/requests.py | 0 {dataclass_rest => src/dataclass_rest}/http_request.py | 0 {dataclass_rest => src/dataclass_rest}/method.py | 0 {dataclass_rest => src/dataclass_rest}/methodspec.py | 0 {dataclass_rest => src/dataclass_rest}/parse_func.py | 0 {dataclass_rest => src/dataclass_rest}/rest.py | 0 13 files changed, 0 insertions(+), 0 deletions(-) rename {dataclass_rest => src/dataclass_rest}/__init__.py (100%) rename {dataclass_rest => src/dataclass_rest}/base_client.py (100%) rename {dataclass_rest => src/dataclass_rest}/boundmethod.py (100%) rename {dataclass_rest => src/dataclass_rest}/client_protocol.py (100%) rename {dataclass_rest => src/dataclass_rest}/exceptions.py (100%) rename {dataclass_rest => src/dataclass_rest}/http/__init__.py (100%) rename {dataclass_rest => src/dataclass_rest}/http/aiohttp.py (100%) rename {dataclass_rest => src/dataclass_rest}/http/requests.py (100%) rename {dataclass_rest => src/dataclass_rest}/http_request.py (100%) rename {dataclass_rest => src/dataclass_rest}/method.py (100%) rename {dataclass_rest => src/dataclass_rest}/methodspec.py (100%) rename {dataclass_rest => src/dataclass_rest}/parse_func.py (100%) rename {dataclass_rest => src/dataclass_rest}/rest.py (100%) diff --git a/dataclass_rest/__init__.py b/src/dataclass_rest/__init__.py similarity index 100% rename from dataclass_rest/__init__.py rename to src/dataclass_rest/__init__.py diff --git a/dataclass_rest/base_client.py b/src/dataclass_rest/base_client.py similarity index 100% rename from dataclass_rest/base_client.py rename to src/dataclass_rest/base_client.py diff --git a/dataclass_rest/boundmethod.py b/src/dataclass_rest/boundmethod.py similarity index 100% rename from dataclass_rest/boundmethod.py rename to src/dataclass_rest/boundmethod.py diff --git a/dataclass_rest/client_protocol.py b/src/dataclass_rest/client_protocol.py similarity index 100% rename from dataclass_rest/client_protocol.py rename to src/dataclass_rest/client_protocol.py diff --git a/dataclass_rest/exceptions.py b/src/dataclass_rest/exceptions.py similarity index 100% rename from dataclass_rest/exceptions.py rename to src/dataclass_rest/exceptions.py diff --git a/dataclass_rest/http/__init__.py b/src/dataclass_rest/http/__init__.py similarity index 100% rename from dataclass_rest/http/__init__.py rename to src/dataclass_rest/http/__init__.py diff --git a/dataclass_rest/http/aiohttp.py b/src/dataclass_rest/http/aiohttp.py similarity index 100% rename from dataclass_rest/http/aiohttp.py rename to src/dataclass_rest/http/aiohttp.py diff --git a/dataclass_rest/http/requests.py b/src/dataclass_rest/http/requests.py similarity index 100% rename from dataclass_rest/http/requests.py rename to src/dataclass_rest/http/requests.py diff --git a/dataclass_rest/http_request.py b/src/dataclass_rest/http_request.py similarity index 100% rename from dataclass_rest/http_request.py rename to src/dataclass_rest/http_request.py diff --git a/dataclass_rest/method.py b/src/dataclass_rest/method.py similarity index 100% rename from dataclass_rest/method.py rename to src/dataclass_rest/method.py diff --git a/dataclass_rest/methodspec.py b/src/dataclass_rest/methodspec.py similarity index 100% rename from dataclass_rest/methodspec.py rename to src/dataclass_rest/methodspec.py diff --git a/dataclass_rest/parse_func.py b/src/dataclass_rest/parse_func.py similarity index 100% rename from dataclass_rest/parse_func.py rename to src/dataclass_rest/parse_func.py diff --git a/dataclass_rest/rest.py b/src/dataclass_rest/rest.py similarity index 100% rename from dataclass_rest/rest.py rename to src/dataclass_rest/rest.py From d8ca305d4bc9802a50fe8c9885183c38fe4a350f Mon Sep 17 00:00:00 2001 From: lubaskincode Date: Mon, 29 Jul 2024 15:32:00 +0300 Subject: [PATCH 23/29] refactoring --- src/dataclass_rest/__init__.py | 6 ++- src/dataclass_rest/boundmethod.py | 47 ++++++++++---------- src/dataclass_rest/client_protocol.py | 7 ++- src/dataclass_rest/http/aiohttp.py | 9 ++-- src/dataclass_rest/http/requests.py | 7 ++- src/dataclass_rest/method.py | 10 +++-- src/dataclass_rest/methodspec.py | 30 ++++++------- src/dataclass_rest/parse_func.py | 63 ++++++++++++++------------- src/dataclass_rest/rest.py | 4 +- tests/requests/conftest.py | 3 +- tests/requests/test_factory.py | 24 ++++++---- tests/requests/test_params.py | 12 +++-- tests/test_init.py | 16 ++++--- 13 files changed, 132 insertions(+), 106 deletions(-) diff --git a/src/dataclass_rest/__init__.py b/src/dataclass_rest/__init__.py index 064320e..e472fa8 100644 --- a/src/dataclass_rest/__init__.py +++ b/src/dataclass_rest/__init__.py @@ -1,7 +1,11 @@ __all__ = [ "File", "rest", - "get", "put", "post", "patch", "delete", + "get", + "put", + "post", + "patch", + "delete", ] from .http_request import File diff --git a/src/dataclass_rest/boundmethod.py b/src/dataclass_rest/boundmethod.py index 3123577..e74c560 100644 --- a/src/dataclass_rest/boundmethod.py +++ b/src/dataclass_rest/boundmethod.py @@ -1,4 +1,3 @@ -import copy from abc import ABC, abstractmethod from inspect import getcallargs @@ -15,11 +14,11 @@ class BoundMethod(ClientMethodProtocol, ABC): def __init__( - self, - name: str, - method_spec: MethodSpec, - client: ClientProtocol, - on_error: Optional[Callable[[Any], Any]], + self, + name: str, + method_spec: MethodSpec, + client: ClientProtocol, + on_error: Optional[Callable[[Any], Any]], ): self.name = name self.method_spec = method_spec @@ -28,29 +27,31 @@ def __init__( def _apply_args(self, *args, **kwargs) -> Dict: return getcallargs( - self.method_spec.func, self.client, *args, **kwargs, + self.method_spec.func, + self.client, + *args, + **kwargs, ) def _get_url(self, args) -> str: - args = copy.copy(args) - - if not self.method_spec.url_template_func_pop_args: - return self.method_spec.url_template_func(**args) - - for arg in self.method_spec.url_template_func_pop_args: - args.pop(arg) - - return self.method_spec.url_template_func(**args) + args = { + arg: value + for arg, value in args.items() + if arg in self.method_spec.url_params + } + return self.method_spec.url_template(**args) def _get_body(self, args) -> Any: python_body = args.get(self.method_spec.body_param_name) return self.client.request_body_factory.dump( - python_body, self.method_spec.body_type, + python_body, + self.method_spec.body_type, ) def _get_query_params(self, args) -> Any: return self.client.request_args_factory.dump( - args, self.method_spec.query_params_type, + args, + self.method_spec.query_params_type, ) def _get_files(self, args) -> Dict[str, File]: @@ -61,11 +62,11 @@ def _get_files(self, args) -> Dict[str, File]: } def _create_request( - self, - url: str, - query_params: Any, - files: Dict[str, File], - data: Any, + self, + url: str, + query_params: Any, + files: Dict[str, File], + data: Any, ) -> HttpRequest: return HttpRequest( method=self.method_spec.http_method, diff --git a/src/dataclass_rest/client_protocol.py b/src/dataclass_rest/client_protocol.py index 1ed30d5..454b28f 100644 --- a/src/dataclass_rest/client_protocol.py +++ b/src/dataclass_rest/client_protocol.py @@ -25,7 +25,9 @@ def load(self, data: Any, class_: Type[TypeT]) -> TypeT: raise NotImplementedError def dump( - self, data: TypeT, class_: Optional[Type[TypeT]] = None, + self, + data: TypeT, + class_: Optional[Type[TypeT]] = None, ) -> Any: raise NotImplementedError @@ -37,6 +39,7 @@ class ClientProtocol(Protocol): method_class: Optional[Callable] def do_request( - self, request: HttpRequest, + self, + request: HttpRequest, ) -> Any: raise NotImplementedError diff --git a/src/dataclass_rest/http/aiohttp.py b/src/dataclass_rest/http/aiohttp.py index e8e400f..a8e2d60 100644 --- a/src/dataclass_rest/http/aiohttp.py +++ b/src/dataclass_rest/http/aiohttp.py @@ -48,9 +48,9 @@ class AiohttpClient(BaseClient): method_class = AiohttpMethod def __init__( - self, - base_url: str, - session: Optional[ClientSession] = None, + self, + base_url: str, + session: Optional[ClientSession] = None, ): super().__init__() self.session = session or ClientSession() @@ -68,7 +68,8 @@ async def do_request(self, request: HttpRequest) -> Any: for name, file in request.files.items(): data.add_field( name, - filename=file.filename, content_type=file.content_type, + filename=file.filename, + content_type=file.content_type, value=file.contents, ) try: diff --git a/src/dataclass_rest/http/requests.py b/src/dataclass_rest/http/requests.py index 6b17484..f8c58a8 100644 --- a/src/dataclass_rest/http/requests.py +++ b/src/dataclass_rest/http/requests.py @@ -16,7 +16,6 @@ class RequestsMethod(SyncMethod): - def _on_error_default(self, response: Response) -> Any: if 400 <= response.status_code < 500: raise ClientError(response.status_code) @@ -39,9 +38,9 @@ class RequestsClient(BaseClient): method_class = RequestsMethod def __init__( - self, - base_url: str, - session: Optional[Session] = None, + self, + base_url: str, + session: Optional[Session] = None, ): super().__init__() self.session = session or Session() diff --git a/src/dataclass_rest/method.py b/src/dataclass_rest/method.py index 23c11fa..b924995 100644 --- a/src/dataclass_rest/method.py +++ b/src/dataclass_rest/method.py @@ -7,9 +7,9 @@ class Method: def __init__( - self, - method_spec: MethodSpec, - method_class: Optional[Callable[..., BoundMethod]] = None, + self, + method_spec: MethodSpec, + method_class: Optional[Callable[..., BoundMethod]] = None, ): self.name = method_spec.func.__name__ self.method_spec = method_spec @@ -29,7 +29,9 @@ def __set_name__(self, owner, name): ) def __get__( - self, instance: Optional[ClientProtocol], objtype=None, + self, + instance: Optional[ClientProtocol], + objtype=None, ) -> BoundMethod: return self.method_class( name=self.name, diff --git a/src/dataclass_rest/methodspec.py b/src/dataclass_rest/methodspec.py index 0101213..e8032c4 100644 --- a/src/dataclass_rest/methodspec.py +++ b/src/dataclass_rest/methodspec.py @@ -1,26 +1,24 @@ -from typing import Any, Dict, Type, Callable, List, Optional +from typing import Any, Callable, Dict, List, Type class MethodSpec: def __init__( - self, - func: Callable, - url_template: Optional[str], - url_template_func: Optional[Callable[..., str]], - url_template_func_pop_args: Optional[List[str]], - http_method: str, - response_type: Type, - body_param_name: str, - body_type: Type, - is_json_request: bool, - query_params_type: Type, - file_param_names: List[str], - additional_params: Dict[str, Any], + self, + func: Callable, + url_template: Callable[..., str], + url_params: List[str], + http_method: str, + response_type: Type, + body_param_name: str, + body_type: Type, + is_json_request: bool, # noqa: FBT001 + query_params_type: Type, + file_param_names: List[str], + additional_params: Dict[str, Any], ): self.func = func self.url_template = url_template - self.url_template_func = url_template_func - self.url_template_func_pop_args = url_template_func_pop_args + self.url_params = url_params self.http_method = http_method self.response_type = response_type self.body_param_name = body_param_name diff --git a/src/dataclass_rest/parse_func.py b/src/dataclass_rest/parse_func.py index fa5c826..9714d94 100644 --- a/src/dataclass_rest/parse_func.py +++ b/src/dataclass_rest/parse_func.py @@ -1,22 +1,33 @@ import string -from inspect import getfullargspec, FullArgSpec, isclass -from typing import Callable, List, Sequence, Any, Type, TypedDict, Dict, Union +from inspect import FullArgSpec, getfullargspec, isclass +from typing import ( + Any, + Callable, + Dict, + List, + Sequence, + Type, + TypeAlias, + TypedDict, + Union, +) from .http_request import File from .methodspec import MethodSpec DEFAULT_BODY_PARAM = "body" +UrlTemplate: TypeAlias = Union[str, Callable[..., str]] def get_url_params_from_string(url_template: str) -> List[str]: parsed_format = string.Formatter().parse(url_template) - return [x[1] for x in parsed_format] + return [x[1] for x in parsed_format if x[1]] def create_query_params_type( - spec: FullArgSpec, - func: Callable, - skipped: Sequence[str], + spec: FullArgSpec, + func: Callable, + skipped: Sequence[str], ) -> Type: fields = {} self_processed = False @@ -31,14 +42,14 @@ def create_query_params_type( def create_body_type( - spec: FullArgSpec, - body_param_name: str, + spec: FullArgSpec, + body_param_name: str, ) -> Type: return spec.annotations.get(body_param_name, Any) def create_response_type( - spec: FullArgSpec, + spec: FullArgSpec, ) -> Type: return spec.annotations.get("return", Any) @@ -52,31 +63,24 @@ def get_file_params(spec): def parse_func( - func: Callable, - method: str, - url_template: Union[str, Callable[..., str]], - additional_params: Dict[str, Any], - is_json_request: bool, - body_param_name: str, + func: Callable, + method: str, + url_template: UrlTemplate, + additional_params: Dict[str, Any], + is_json_request: bool, # noqa: FBT001 + body_param_name: str, ) -> MethodSpec: spec = getfullargspec(func) file_params = get_file_params(spec) is_string_url_template = isinstance(url_template, str) - url_template_func = url_template.format if is_string_url_template else url_template - - url_template_func_pop_args = None + url_template_callable = ( + url_template.format if is_string_url_template else url_template + ) if not is_string_url_template: - url_template_func_arg_spec = getfullargspec(url_template_func) - url_template_func_args = url_template_func_arg_spec.args - - url_template_func_args_set = set(url_template_func_args) - diff_kwargs = set(spec.kwonlyargs).difference(url_template_func_args_set) - diff_args = set(spec.args).difference(url_template_func_args_set) - - url_template_func_pop_args = diff_args.union(diff_kwargs) - url_params = url_template_func_args + url_template_func_arg_spec = getfullargspec(url_template_callable) + url_params = url_template_func_arg_spec.args else: url_params = get_url_params_from_string(url_template) @@ -85,9 +89,8 @@ def parse_func( return MethodSpec( func=func, http_method=method, - url_template=url_template if is_string_url_template else None, - url_template_func=url_template_func, - url_template_func_pop_args=url_template_func_pop_args, + url_template=url_template_callable, + url_params=url_params, query_params_type=create_query_params_type(spec, func, skipped_params), body_type=create_body_type(spec, body_param_name), response_type=create_response_type(spec), diff --git a/src/dataclass_rest/rest.py b/src/dataclass_rest/rest.py index 21e03b0..61e29d1 100644 --- a/src/dataclass_rest/rest.py +++ b/src/dataclass_rest/rest.py @@ -2,13 +2,13 @@ from .boundmethod import BoundMethod from .method import Method -from .parse_func import DEFAULT_BODY_PARAM, parse_func +from .parse_func import DEFAULT_BODY_PARAM, UrlTemplate, parse_func _Func = TypeVar("_Func", bound=Callable[..., Any]) def rest( - url_template: str, + url_template: UrlTemplate, *, method: str, body_name: str = DEFAULT_BODY_PARAM, diff --git a/tests/requests/conftest.py b/tests/requests/conftest.py index 849f945..2950ca8 100644 --- a/tests/requests/conftest.py +++ b/tests/requests/conftest.py @@ -12,6 +12,7 @@ def session(): @pytest.fixture def mocker(session): with requests_mock.Mocker( - session=session, case_sensitive=True, + session=session, + case_sensitive=True, ) as session_mock: yield session_mock diff --git a/tests/requests/test_factory.py b/tests/requests/test_factory.py index d6a5ebc..72ae49f 100644 --- a/tests/requests/test_factory.py +++ b/tests/requests/test_factory.py @@ -27,19 +27,25 @@ class ResponseBody: def test_body(session, mocker): class Api(RequestsClient): def _init_request_body_factory(self) -> Retort: - return Retort(recipe=[ - name_mapping(name_style=NameStyle.CAMEL), - ]) + return Retort( + recipe=[ + name_mapping(name_style=NameStyle.CAMEL), + ], + ) def _init_request_args_factory(self) -> Retort: - return Retort(recipe=[ - name_mapping(name_style=NameStyle.UPPER_DOT), - ]) + return Retort( + recipe=[ + name_mapping(name_style=NameStyle.UPPER_DOT), + ], + ) def _init_response_body_factory(self) -> Retort: - return Retort(recipe=[ - name_mapping(name_style=NameStyle.LOWER_KEBAB), - ]) + return Retort( + recipe=[ + name_mapping(name_style=NameStyle.LOWER_KEBAB), + ], + ) @patch("/post/") def post_x(self, long_param: str, body: RequestBody) -> ResponseBody: diff --git a/tests/requests/test_params.py b/tests/requests/test_params.py index bd8405b..d91fc56 100644 --- a/tests/requests/test_params.py +++ b/tests/requests/test_params.py @@ -46,15 +46,18 @@ def post_x(self, id: str, param: Optional[int]) -> List[int]: mocker.post( url="http://example.com/post/x?", - text="[0]", complete_qs=True, + text="[0]", + complete_qs=True, ) mocker.post( url="http://example.com/post/x?param=1", - text="[1]", complete_qs=True, + text="[1]", + complete_qs=True, ) mocker.post( url="http://example.com/post/x?param=2", - text="[1,2]", complete_qs=True, + text="[1,2]", + complete_qs=True, ) client = Api(base_url="http://example.com", session=session) assert client.post_x("x", None) == [0] @@ -76,7 +79,8 @@ def post_x(self, body: RequestBody) -> None: mocker.post( url="http://example.com/post/", - text="null", complete_qs=True, + text="null", + complete_qs=True, ) client = Api(base_url="http://example.com", session=session) assert client.post_x(RequestBody(x=1, y="test")) is None diff --git a/tests/test_init.py b/tests/test_init.py index e2226e8..431302f 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -23,9 +23,11 @@ def __init__(self): ) def _init_request_body_factory(self) -> Retort: - return Retort(recipe=[ - name_mapping(name_style=NameStyle.CAMEL), - ]) + return Retort( + recipe=[ + name_mapping(name_style=NameStyle.CAMEL), + ], + ) @get("todos/{id}") def get_todo(self, id: str) -> Todo: @@ -41,9 +43,11 @@ def __init__(self): super().__init__("https://jsonplaceholder.typicode.com/") def _init_request_body_factory(self) -> Retort: - return Retort(recipe=[ - name_mapping(name_style=NameStyle.CAMEL), - ]) + return Retort( + recipe=[ + name_mapping(name_style=NameStyle.CAMEL), + ], + ) @get("todos/{id}") async def get_todo(self, id: str) -> Todo: From 3e0be5fe20902586e3be693f671d4fabb28f649e Mon Sep 17 00:00:00 2001 From: lubaskincode Date: Mon, 29 Jul 2024 15:37:43 +0300 Subject: [PATCH 24/29] inline --- src/dataclass_rest/boundmethod.py | 1 - src/dataclass_rest/parse_func.py | 17 ++++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/dataclass_rest/boundmethod.py b/src/dataclass_rest/boundmethod.py index e74c560..c57a222 100644 --- a/src/dataclass_rest/boundmethod.py +++ b/src/dataclass_rest/boundmethod.py @@ -1,4 +1,3 @@ - from abc import ABC, abstractmethod from inspect import getcallargs from logging import getLogger diff --git a/src/dataclass_rest/parse_func.py b/src/dataclass_rest/parse_func.py index 9714d94..acbffab 100644 --- a/src/dataclass_rest/parse_func.py +++ b/src/dataclass_rest/parse_func.py @@ -62,6 +62,13 @@ def get_file_params(spec): ] +def get_url_params_from_callable( + url_template: Callable[..., str], +) -> List[str]: + url_template_func_arg_spec = getfullargspec(url_template) + return url_template_func_arg_spec.args + + def parse_func( func: Callable, method: str, @@ -78,11 +85,11 @@ def parse_func( url_template.format if is_string_url_template else url_template ) - if not is_string_url_template: - url_template_func_arg_spec = getfullargspec(url_template_callable) - url_params = url_template_func_arg_spec.args - else: - url_params = get_url_params_from_string(url_template) + url_params = ( + get_url_params_from_string(url_template) + if is_string_url_template + else get_url_params_from_callable(url_template) + ) skipped_params = url_params + file_params + [body_param_name] From c9b6654e9d324184509ef1f2ed215c15f5fcb7ac Mon Sep 17 00:00:00 2001 From: lubaskincode Date: Mon, 29 Jul 2024 17:37:56 +0300 Subject: [PATCH 25/29] tests, docs --- README.md | 27 ++++++++- tests/requests/test_callable_url.py | 94 +++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/requests/test_callable_url.py diff --git a/README.md b/README.md index dc1e039..ad8b6d5 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,32 @@ class RealClient(RequestsClient): @post("todos") def create_todo(self, body: Todo) -> Todo: - """Создаем Todo""" + pass +``` + +You can use Callable ```(...) -> str``` as the url source, +all parameters passed to the client method can be obtained inside the Callable + +```python +from requests import Session +from dataclass_rest import get +from dataclass_rest.http.requests import RequestsClient + +def url_generator(todo_id: int) -> str: + return f"/todos/{todo_id}/" + + +class RealClient(RequestsClient): + def __init__(self): + super().__init__("https://dummyjson.com/", Session()) + + @get(url_generator) + def todo(self, todo_id: int) -> Todo: + pass + + +client = RealClient() +client.todo(5) ``` ## Asyncio diff --git a/tests/requests/test_callable_url.py b/tests/requests/test_callable_url.py new file mode 100644 index 0000000..1fa4e54 --- /dev/null +++ b/tests/requests/test_callable_url.py @@ -0,0 +1,94 @@ +from typing import List, Optional + +import pytest +import requests +import requests_mock + +from dataclass_rest import get +from dataclass_rest.http.requests import RequestsClient + + +def static_url() -> str: + return "/get" + + +def param_url(entry_id: int) -> str: + return f"/get/{entry_id}" + + +def kwonly_param_url(entry_id: Optional[int] = None) -> str: + if entry_id: + return f"/get/{entry_id}" + return "/get/random" + + +def test_simple(session: requests.Session, mocker: requests_mock.Mocker): + class Api(RequestsClient): + @get(static_url) + def get_x(self) -> List[int]: + raise NotImplementedError + + mocker.get("http://example.com/get", text="[1,2]", complete_qs=True) + client = Api(base_url="http://example.com", session=session) + assert client.get_x() == [1, 2] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ( + 1, + 1, + ), + ( + 2, + 2, + ), + ], +) +def test_with_param( + session: requests.Session, + mocker: requests_mock.Mocker, + value: int, + expected: int, +): + class Api(RequestsClient): + @get(param_url) + def get_entry(self, entry_id: int) -> int: + raise NotImplementedError + + url = f"http://example.com/get/{expected}" + mocker.get(url, text=str(expected), complete_qs=True) + + client = Api(base_url="http://example.com", session=session) + assert client.get_entry(value) == expected + + +def test_excess_param(session: requests.Session, mocker: requests_mock.Mocker): + class Api(RequestsClient): + @get(param_url) + def get_entry( + self, entry_id: int, some_param: Optional[int] = None, + ) -> int: + raise NotImplementedError + + mocker.get( + "http://example.com/get/1?some_param=2", text="1", complete_qs=True, + ) + + client = Api(base_url="http://example.com", session=session) + assert client.get_entry(1, 2) == 1 + + +def test_kwonly_param(session: requests.Session, mocker: requests_mock.Mocker): + class Api(RequestsClient): + @get(kwonly_param_url) + def get_entry(self, *, entry_id: Optional[int] = None) -> int: + raise NotImplementedError + + mocker.get("http://example.com/get/1", text="1", complete_qs=True) + mocker.get("http://example.com/get/random", text="2", complete_qs=True) + + client = Api(base_url="http://example.com", session=session) + assert client.get_entry(entry_id=1) == 1 + assert client.get_entry() == 2 From cc151e34b1eab83cf59b98d66acf638a9b0d3e83 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Sat, 3 Aug 2024 00:02:10 +0200 Subject: [PATCH 26/29] Add headers to request model --- src/dataclass_rest/boundmethod.py | 1 + src/dataclass_rest/http/aiohttp.py | 1 + src/dataclass_rest/http/requests.py | 1 + src/dataclass_rest/http_request.py | 3 ++- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/dataclass_rest/boundmethod.py b/src/dataclass_rest/boundmethod.py index c57a222..8fb7b6a 100644 --- a/src/dataclass_rest/boundmethod.py +++ b/src/dataclass_rest/boundmethod.py @@ -74,6 +74,7 @@ def _create_request( data=data, files=files, url=url, + headers={}, ) def get_query_params_type(self) -> Type: diff --git a/src/dataclass_rest/http/aiohttp.py b/src/dataclass_rest/http/aiohttp.py index a8e2d60..833ec80 100644 --- a/src/dataclass_rest/http/aiohttp.py +++ b/src/dataclass_rest/http/aiohttp.py @@ -79,6 +79,7 @@ async def do_request(self, request: HttpRequest) -> Any: json=json, data=data, params=request.query_params, + headers=request.headers, ) except AioHttpClientError as e: raise ClientLibraryError from e diff --git a/src/dataclass_rest/http/requests.py b/src/dataclass_rest/http/requests.py index f8c58a8..70ed560 100644 --- a/src/dataclass_rest/http/requests.py +++ b/src/dataclass_rest/http/requests.py @@ -69,6 +69,7 @@ def do_request(self, request: HttpRequest) -> Any: json=json, params=request.query_params, data=data, + headers=request.headers, files=files, ) except RequestException as e: diff --git a/src/dataclass_rest/http_request.py b/src/dataclass_rest/http_request.py index b7ba4e0..8ea441e 100644 --- a/src/dataclass_rest/http_request.py +++ b/src/dataclass_rest/http_request.py @@ -14,6 +14,7 @@ class HttpRequest: is_json_request: bool data: Any files: Dict[str, File] - query_params: Dict + query_params: Dict[str, Any] + headers: Dict[str, str] url: str method: str From 091927342942fb1aa57bf45a8815155488519731 Mon Sep 17 00:00:00 2001 From: Angel <145038102+KurosawaAngel@users.noreply.github.com> Date: Tue, 8 Oct 2024 06:48:39 +0500 Subject: [PATCH 27/29] fix kwonlyargs --- src/dataclass_rest/parse_func.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dataclass_rest/parse_func.py b/src/dataclass_rest/parse_func.py index acbffab..43a99ee 100644 --- a/src/dataclass_rest/parse_func.py +++ b/src/dataclass_rest/parse_func.py @@ -31,7 +31,7 @@ def create_query_params_type( ) -> Type: fields = {} self_processed = False - for x in spec.args: + for x in spec.args + spec.kwonlyargs: if not self_processed: self_processed = True continue From 14574ebde2f6fe8cc0a62027e06526286c7dbd74 Mon Sep 17 00:00:00 2001 From: Kurosawa <145038102+KurosawaAngel@users.noreply.github.com> Date: Wed, 9 Oct 2024 00:35:26 +0500 Subject: [PATCH 28/29] add test --- tests/requests/test_params.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/requests/test_params.py b/tests/requests/test_params.py index d91fc56..89466ef 100644 --- a/tests/requests/test_params.py +++ b/tests/requests/test_params.py @@ -86,3 +86,35 @@ def post_x(self, body: RequestBody) -> None: assert client.post_x(RequestBody(x=1, y="test")) is None assert mocker.called_once assert mocker.request_history[0].json() == {"x": 1, "y": "test"} + + +def test_kwonly_param(session: requests.Session, mocker: requests_mock.Mocker): + class Api(RequestsClient): + @post("/post/") + def post( + self, + *, + body: RequestBody, + ) -> None: + raise NotImplementedError + + @get("/get/{id}") + def get_x(self, *, id: str, param: str = "1") -> List[int]: + raise NotImplementedError + + mocker.post( + url="http://example.com/post/", + text="null", + complete_qs=True, + ) + mocker.get( + url="http://example.com/get/x?param=1", + text="[0]", + complete_qs=True, + ) + client = Api(base_url="http://example.com", session=session) + assert client.post(body=RequestBody(x=1, y="test")) is None + assert mocker.called_once + assert mocker.request_history[0].json() == {"x": 1, "y": "test"} + + assert client.get_x(id="x") == [0] From 78e98eb3ecf7edd0f5c6ab8a9d3464163ee46cbb Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Sat, 19 Oct 2024 14:15:19 +0200 Subject: [PATCH 29/29] Run ci on 3.13 --- .github/workflows/setup.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/setup.yml b/.github/workflows/setup.yml index 2eef0c1..1f548bf 100644 --- a/.github/workflows/setup.yml +++ b/.github/workflows/setup.yml @@ -20,6 +20,7 @@ jobs: - "3.10" - "3.11" - "3.12" + - "3.13" steps: - uses: actions/checkout@v4