diff --git a/.github/workflows/setup.yml b/.github/workflows/setup.yml new file mode 100644 index 0000000..1f548bf --- /dev/null +++ b/.github/workflows/setup.yml @@ -0,0 +1,43 @@ +# 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" + - "3.13" + + 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 ruff + run: | + ruff check . + + - name: Run tests + run: | + pytest 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/.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/README.md b/README.md index 1b8b830..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 @@ -102,3 +127,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 +``` diff --git a/dataclass_rest/__init__.py b/dataclass_rest/__init__.py deleted file mode 100644 index 6f444a2..0000000 --- a/dataclass_rest/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -__all__ = [ - "File", - "rest", - "get", "put", "post", "patch", "delete", -] - -from .http_request import File -from .rest import rest, get, put, post, patch, delete diff --git a/dataclass_rest/rest.py b/dataclass_rest/rest.py deleted file mode 100644 index 9394811..0000000 --- a/dataclass_rest/rest.py +++ /dev/null @@ -1,39 +0,0 @@ -from functools import partial -from typing import Any, Dict, Optional, Callable - -from .boundmethod import BoundMethod -from .method import Method -from .parse_func import parse_func, DEFAULT_BODY_PARAM - - -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, -) -> Callable[[Callable], Method]: - if additional_params is None: - additional_params = {} - - def dec(func: Callable) -> Method: - method_spec = parse_func( - func=func, - body_param_name=body_name, - url_template=url_template, - method=method, - additional_params=additional_params, - is_json_request=send_json, - ) - return Method(method_spec, method_class=method_class) - - 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") 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): 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.txt b/requirements.txt deleted file mode 100644 index a387c1e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -adaptix -typing_extensions -aiohttp -requests -nose2 -mypy \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..9b3bb7d --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,10 @@ +adaptix +typing_extensions +aiohttp +requests +requests-mock +mypy +pytest +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/src/dataclass_rest/__init__.py b/src/dataclass_rest/__init__.py new file mode 100644 index 0000000..e472fa8 --- /dev/null +++ b/src/dataclass_rest/__init__.py @@ -0,0 +1,12 @@ +__all__ = [ + "File", + "rest", + "get", + "put", + "post", + "patch", + "delete", +] + +from .http_request import File +from .rest import delete, get, patch, post, put, rest diff --git a/dataclass_rest/base_client.py b/src/dataclass_rest/base_client.py similarity index 93% rename from dataclass_rest/base_client.py rename to src/dataclass_rest/base_client.py index 0de8b4f..8272430 100644 --- a/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/dataclass_rest/boundmethod.py b/src/dataclass_rest/boundmethod.py similarity index 80% rename from dataclass_rest/boundmethod.py rename to src/dataclass_rest/boundmethod.py index f69021a..8fb7b6a 100644 --- a/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__) @@ -13,11 +13,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 @@ -26,21 +26,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: - return self.method_spec.url_template.format(**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]: @@ -51,11 +61,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, @@ -64,6 +74,7 @@ def _create_request( data=data, files=files, url=url, + headers={}, ) def get_query_params_type(self) -> Type: @@ -74,7 +85,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 +101,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 +145,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 +171,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/dataclass_rest/client_protocol.py b/src/dataclass_rest/client_protocol.py similarity index 72% rename from dataclass_rest/client_protocol.py rename to src/dataclass_rest/client_protocol.py index eb8e11f..454b28f 100644 --- a/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,11 @@ 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 @@ -29,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/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 76% rename from dataclass_rest/http/aiohttp.py rename to src/dataclass_rest/http/aiohttp.py index 6d64e38..833ec80 100644 --- a/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): @@ -41,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() @@ -61,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: @@ -71,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/dataclass_rest/http/requests.py b/src/dataclass_rest/http/requests.py similarity index 79% rename from dataclass_rest/http/requests.py rename to src/dataclass_rest/http/requests.py index 2082d51..70ed560 100644 --- a/dataclass_rest/http/requests.py +++ b/src/dataclass_rest/http/requests.py @@ -2,18 +2,20 @@ 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): - def _on_error_default(self, response: Response) -> Any: if 400 <= response.status_code < 500: raise ClientError(response.status_code) @@ -36,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() @@ -67,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/dataclass_rest/http_request.py b/src/dataclass_rest/http_request.py similarity index 72% rename from dataclass_rest/http_request.py rename to src/dataclass_rest/http_request.py index c5bc073..8ea441e 100644 --- a/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 @@ -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 diff --git a/dataclass_rest/method.py b/src/dataclass_rest/method.py similarity index 81% rename from dataclass_rest/method.py rename to src/dataclass_rest/method.py index 66612e7..b924995 100644 --- a/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 @@ -25,11 +25,13 @@ 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__( - self, instance: Optional[ClientProtocol], objtype=None, + self, + instance: Optional[ClientProtocol], + objtype=None, ) -> BoundMethod: return self.method_class( name=self.name, diff --git a/dataclass_rest/methodspec.py b/src/dataclass_rest/methodspec.py similarity index 50% rename from dataclass_rest/methodspec.py rename to src/dataclass_rest/methodspec.py index bf42c61..e8032c4 100644 --- a/dataclass_rest/methodspec.py +++ b/src/dataclass_rest/methodspec.py @@ -1,22 +1,24 @@ -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, - 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_params = url_params self.http_method = http_method self.response_type = response_type self.body_param_name = body_param_name diff --git a/dataclass_rest/parse_func.py b/src/dataclass_rest/parse_func.py similarity index 52% rename from dataclass_rest/parse_func.py rename to src/dataclass_rest/parse_func.py index 3c02e8f..43a99ee 100644 --- a/dataclass_rest/parse_func.py +++ b/src/dataclass_rest/parse_func.py @@ -1,26 +1,37 @@ 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, + 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(url_template: str) -> List[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 - for x in spec.args: + for x in spec.args + spec.kwonlyargs: if not self_processed: self_processed = True continue @@ -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) @@ -51,22 +62,42 @@ 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, - url_template: 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) - url_params = get_url_params(url_template) file_params = get_file_params(spec) + + is_string_url_template = isinstance(url_template, str) + url_template_callable = ( + url_template.format if is_string_url_template else 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] + return MethodSpec( func=func, http_method=method, - url_template=url_template, + 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/py.typed b/src/dataclass_rest/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/dataclass_rest/rest.py b/src/dataclass_rest/rest.py new file mode 100644 index 0000000..61e29d1 --- /dev/null +++ b/src/dataclass_rest/rest.py @@ -0,0 +1,47 @@ +from typing import Any, Callable, Dict, Optional, TypeVar, cast + +from .boundmethod import BoundMethod +from .method import Method +from .parse_func import DEFAULT_BODY_PARAM, UrlTemplate, parse_func + +_Func = TypeVar("_Func", bound=Callable[..., Any]) + + +def rest( + url_template: UrlTemplate, + *, + 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 = {} + + def dec(func: Callable) -> Method: + method_spec = parse_func( + func=func, + body_param_name=body_name, + url_template=url_template, + method=method, + additional_params=additional_params, + is_json_request=send_json, + ) + return Method(method_spec, method_class=method_class) + + return dec + + +def _rest_method(func: _Func, method: str) -> _Func: + def wrapper(*args, **kwargs): + return func(*args, **kwargs, method=method) + + return cast(_Func, 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") 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..2950ca8 --- /dev/null +++ b/tests/requests/conftest.py @@ -0,0 +1,18 @@ +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_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 diff --git a/tests/requests/test_factory.py b/tests/requests/test_factory.py new file mode 100644 index 0000000..72ae49f --- /dev/null +++ b/tests/requests/test_factory.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from enum import Enum + +from adaptix import NameStyle, Retort, 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/?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( + long_param="hello", + body=RequestBody(int_param=42, selection=Selection.ONE), + ) + assert result == ResponseBody(int_param=1, selection=Selection.TWO) + assert mocker.called_once + + 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 new file mode 100644 index 0000000..89466ef --- /dev/null +++ b/tests/requests/test_params.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass +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: requests.Session, mocker: requests_mock.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]", 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] + + +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 + + 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] + + +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( + 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] + assert client.post_x("x", 2) == [1, 2] + + +@dataclass +class RequestBody: + x: int + y: str + + +def test_body(session: requests.Session, mocker: requests_mock.Mocker): + class Api(RequestsClient): + @post("/post/") + def post_x(self, body: RequestBody) -> None: + raise NotImplementedError + + 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 + 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] diff --git a/tests/test_init.py b/tests/test_init.py index ba52acf..431302f 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 NameStyle, Retort, 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,19 @@ 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)) + super().__init__( + "https://jsonplaceholder.typicode.com/", + Session(), + ) + + 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 +36,22 @@ 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()