Skip to content

Commit

Permalink
Merge pull request #6 from CrocoFactory/dev
Browse files Browse the repository at this point in the history
v0.1.0.rc1
  • Loading branch information
blnkoff authored Sep 28, 2024
2 parents 1e39a6e + 13f6610 commit 1f8fa88
Show file tree
Hide file tree
Showing 36 changed files with 1,901 additions and 392 deletions.
8 changes: 8 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[run]
omit =
sensei/_internal/_core/_types.py
sensei/types.py
sensei/_compat.py
sensei/params.py
sensei/_base_client.py
tests/*
11 changes: 11 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[flake8]
max-line-length = 120
exclude =
.git,
__pycache__,
sensei/_compat.py
sensei/__init__.py
sensei/client/__init__.py
sensei/_internal/__init__.py
sensei/_internal/tools/__init__.py
sensei/_internal/_core/__init__.py
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
</h1><br>
</a>

[![PyPi Version](https://img.shields.io/pypi/v/sensei)](https://pypi.org/project/sensei/)
[![PyPI Downloads](https://img.shields.io/pypi/dm/sensei?label=downloads)](https://pypi.org/project/sensei/)
[![License](https://img.shields.io/github/license/CrocoFactory/sensei.svg)](https://pypi.org/project/sensei/)
[![Last Commit](https://img.shields.io/github/last-commit/CrocoFactory/sensei.svg)](https://pypi.org/project/sensei/)
[![Development Status](https://img.shields.io/pypi/status/sensei)](https://pypi.org/project/sensei/)
[![Python versions](https://img.shields.io/pypi/pyversions/sensei?color=%23F94526)](https://pypi.org/project/sensei/)
[![PyPi Version](https://img.shields.io/pypi/v/sensei?color=%23F94526)](https://pypi.org/project/sensei/)
[![PyPI Downloads](https://img.shields.io/pypi/dm/sensei?label=downloads&color=%23F94526)](https://pypi.org/project/sensei/)

The python framework, providing fast and robust way to build client-side API wrappers.

Expand All @@ -24,7 +22,7 @@ Here is example of OOP style.

```python
from typing import Annotated, Any, Self
from sensei import Router, Query, Path, APIModel, Header, Args, pascal_case, fill_path_params, RateLimit
from sensei import Router, Query, Path, APIModel, Header, Args, pascal_case, format_str, RateLimit

router = Router('https://reqres.in/api', rate_limit=RateLimit(5, 1))

Expand Down Expand Up @@ -71,7 +69,7 @@ class User(BaseModel):
@delete.prepare
def _delete_in(self, args: Args) -> Args:
url = args.url
url = fill_path_params(url, {'id_': self.id})
url = format_str(url, {'id_': self.id})
args.url = url
return args

Expand Down
24 changes: 16 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = 'sensei'
version = '0.1.0.beta3.post1'
version = '0.1.0.rc1'
description = 'The python framework, providing fast and robust way to build client-side API wrappers.'
authors = ['Alexey <[email protected]>']
license = 'MIT'
Expand All @@ -11,27 +11,35 @@ classifiers = [
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'Topic :: Software Development :: Libraries :: Python Modules',
'Programming Language :: Python :: 3.11',
'Topic :: Software Development :: Libraries :: Application Frameworks',
'Framework :: Pydantic',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'License :: OSI Approved :: MIT License',
'Operating System :: Microsoft :: Windows',
'Operating System :: MacOS'
'Operating System :: OS Independent',
'Typing :: Typed'
]
packages = [{ include = 'sensei' }]

[tool.poetry.dependencies]
python = '^3.11'
pydantic = "^2.9.0"
python = '^3.9'
typing-extensions = "^4.12.2"
httpx = "^0.27.2"
pydantic = "^2.9.2"

[tool.poetry.group.dev.dependencies]
pytest = "^8.2.2"
python-dotenv = "^1.0.1"
build = "^1.2.1"
twine = "^5.1.1"
croco-cli = "^0.3.1"
flake8 = "^7.1.1"
respx = "^0.21.1"
pyjwt = "^2.9.0"
email-validator = "^2.2.0"
coverage = "^7.6.1"
pytest-asyncio = "^0.24.0"

[build-system]
requires = ['poetry-core']
Expand Down
3 changes: 2 additions & 1 deletion sensei/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
from .client import RateLimit, Manager, Client, AsyncClient
from .api_model import APIModel
from .cases import *
from ._utils import fill_path_params
from ._utils import format_str, placeholders
from .types import Json
32 changes: 21 additions & 11 deletions sensei/_base_client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
from __future__ import annotations

from abc import abstractmethod, ABC
from ._utils import get_path_params, fill_path_params
from ._utils import get_base_url
from .types import IRateLimit, IResponse
from sensei._descriptors import RateLimitAttr, PortAttr


class _PortAttr(PortAttr):
def __set__(self, obj: object, value: int) -> None:
if not obj.__dict__.get('_port_set'):
super().__set__(obj, value)
obj.__dict__['_port_set'] = True
else:
raise AttributeError('Port can only be set at creation')


class BaseClient(ABC):
rate_limit = RateLimitAttr()
port = PortAttr()
port = _PortAttr()

def __init__(
self,
Expand All @@ -22,19 +33,18 @@ def __init__(

self._host = host

if 'port' in get_path_params(host):
api_url = fill_path_params(host, {'port': port})
elif port is not None:
api_url = f'{host}:{port}'
else:
api_url = host

self._api_url = api_url
base_url = get_base_url(host, port)
self._api_url = base_url

@property
def host(self) -> str:
return self._host

@abstractmethod
def request(self, method: str, *args, **kwargs) -> IResponse:
def request(self, method: str, url: str, *args, **kwargs) -> IResponse:
pass

@property
@abstractmethod
def base_url(self) -> str:
pass
29 changes: 16 additions & 13 deletions sensei/_descriptors.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
from typing import Any
from sensei.types import IRateLimit


class RateLimitAttr:
class _Attr:
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
self._name = name

def __get__(self, obj: object, owner: type) -> Any:
return obj.__dict__[self._name]


class RateLimitAttr(_Attr):
def __get__(self, obj: object, owner: type) -> IRateLimit:
return obj.__dict__[self.name]
return super().__get__(obj, owner)

def __set__(self, obj: object, value: IRateLimit) -> None:
if value is None or isinstance(value, IRateLimit):
obj.__dict__[self.name] = value
obj.__dict__[self._name] = value
else:
raise TypeError(f'Value must implement {IRateLimit} interface')


class PortAttr:
def __set_name__(self, owner: type, name: str) -> None:
self.name = name

def __get__(self, obj: object, owner: type) -> IRateLimit:
return obj.__dict__[self.name]
class PortAttr(_Attr):
def __get__(self, obj: object, owner: type) -> int:
return super().__get__(obj, owner)

def __set__(self, obj: object, value: IRateLimit) -> None:
def __set__(self, obj: object, value: int) -> None:
if value is None or isinstance(value, int) and 1 <= value <= 65535:
obj.__dict__[self.name] = value
obj.__dict__[self._name] = value
else:
raise ValueError('Port must be between 1 and 65535')
raise ValueError('Port must be between 1 and 65535')
118 changes: 38 additions & 80 deletions sensei/_internal/_core/_callable_handler.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from __future__ import annotations

import inspect
from typing import Callable, TypeVar, Generic, Any, Self, get_origin, get_args
from typing import Callable, TypeVar, Generic, Any, get_origin, get_args
from typing_extensions import Self
from sensei.client import Manager, AsyncClient, Client
from sensei._base_client import BaseClient
from ._endpoint import Endpoint, ResponseModel, RESPONSE_TYPES, CaseConverter
from ._endpoint import Endpoint, ResponseModel, RESPONSE_TYPES, CaseConverter, Args
from ._requester import Requester, ResponseFinalizer, Preparer, JsonFinalizer
from ..tools import HTTPMethod, args_to_kwargs, MethodType, identical
from sensei.types import IRateLimit
from sensei.types import IRateLimit, IResponse
from ..tools.utils import is_coroutine_function

_Client = TypeVar('_Client', bound=BaseClient)
_RequestArgs = tuple[tuple[Any, ...], dict[str, Any]]
Expand Down Expand Up @@ -42,7 +46,7 @@ def __init__(
method_type: MethodType,
manager: Manager[_Client] | None,
case_converters: dict[str, CaseConverter],
response_finalizer: ResponseFinalizer = identical,
response_finalizer: ResponseFinalizer | None = None,
json_finalizer: JsonFinalizer = identical,
pre_preparer: Preparer = identical,
post_preparer: Preparer = identical,
Expand All @@ -60,8 +64,25 @@ def __init__(
self._temp_client: _Client | None = None
self._converters = case_converters

self._preparer = lambda value: post_preparer(pre_preparer(value))
self._response_finalizer = response_finalizer
if is_coroutine_function(post_preparer):
async def preparer(value: Args) -> Args:
return await post_preparer(pre_preparer(value))
else:
def preparer(value: Args) -> Args:
return post_preparer(pre_preparer(value))

if response_finalizer:
if is_coroutine_function(response_finalizer):
async def finalizer(value: IResponse) -> ResponseModel:
return await response_finalizer(value)
else:
def finalizer(value: IResponse) -> ResponseModel:
return response_finalizer(value)
else:
finalizer = response_finalizer

self._preparer = preparer
self._response_finalizer = finalizer

self._json_finalizer = json_finalizer

Expand Down Expand Up @@ -92,35 +113,42 @@ def __make_endpoint(self) -> Endpoint:
if MethodType.self_method(method_type):
return_type = func.__self__ # type: ignore
else:
raise ValueError(f'Response "Self" is only for instance and class methods')
raise ValueError('Response "Self" is only for instance and class methods')
elif get_origin(return_type) is list and get_args(return_type)[0] is Self:
if method_type is MethodType.CLASS:
return_type = list[func.__self__] # type: ignore
else:
raise ValueError(f'Response "list[Self]" is only for class methods')
raise ValueError('Response "list[Self]" is only for class methods')
elif self._response_finalizer is None:
raise ValueError(f'Response finalizer must be set, if response is not from: {RESPONSE_TYPES}')
else:
return_type = dict

endpoint = Endpoint(self._path, self._method, params=params, response=return_type, **self._converters)
converters = self._converters.copy()
converters.pop('response_case')
endpoint = Endpoint(self._path, self._method, params=params, response=return_type, **converters)
return endpoint

def _make_requester(self, client: BaseClient) -> Requester:
case_converter = self._converters.get('response_case', identical)
endpoint = self.__make_endpoint()
requester = Requester(
client,
endpoint,
response_finalizer=self._response_finalizer,
json_finalizer=self._json_finalizer,
preparer=self._preparer
preparer=self._preparer,
response_case=case_converter,
)
return requester

def _get_request_args(self, client: BaseClient) -> tuple[Requester, dict]:
if client.host != self._host:
raise ValueError('Client host must be equal to default host')

if client.port != self._port:
raise ValueError('Client port must be equal to default port')

requester = self._make_requester(client)
kwargs = args_to_kwargs(self._func, *self._request_args[0], **self._request_args[1])
method_type = self._method_type
Expand All @@ -132,41 +160,6 @@ def _get_request_args(self, client: BaseClient) -> tuple[Requester, dict]:


class AsyncCallableHandler(_CallableHandler[AsyncClient], Generic[ResponseModel]):
def __init__(
self,
*,
path: str,
method: HTTPMethod,
func: Callable,
host: str,
port: int | None = None,
rate_limit: IRateLimit | None = None,
request_args: _RequestArgs,
method_type: MethodType,
manager: Manager[AsyncClient] | None,
case_converters: dict[str, CaseConverter],
response_finalizer: ResponseFinalizer = identical,
json_finalizer: JsonFinalizer = identical,
pre_preparer: Preparer = identical,
post_preparer: Preparer = identical,
):
super().__init__(
func=func,
host=host,
port=port,
request_args=request_args,
rate_limit=rate_limit,
manager=manager,
method_type=method_type,
method=method,
path=path,
response_finalizer=response_finalizer,
case_converters=case_converters,
json_finalizer=json_finalizer,
pre_preparer=pre_preparer,
post_preparer=post_preparer
)

async def __aenter__(self) -> ResponseModel:
manager = self._manager
if manager is None or manager.empty():
Expand All @@ -189,41 +182,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):


class CallableHandler(_CallableHandler[Client], Generic[ResponseModel]):
def __init__(
self,
*,
path: str,
method: HTTPMethod,
func: Callable,
host: str,
port: int | None = None,
rate_limit: IRateLimit | None = None,
request_args: _RequestArgs,
method_type: MethodType,
manager: Manager[Client] | None,
case_converters: dict[str, CaseConverter],
response_finalizer: ResponseFinalizer = identical,
json_finalizer: JsonFinalizer = identical,
pre_preparer: Preparer = identical,
post_preparer: Preparer = identical,
):
super().__init__(
func=func,
host=host,
port=port,
request_args=request_args,
rate_limit=rate_limit,
manager=manager,
method_type=method_type,
method=method,
path=path,
response_finalizer=response_finalizer,
case_converters=case_converters,
json_finalizer=json_finalizer,
pre_preparer=pre_preparer,
post_preparer=post_preparer
)

def __enter__(self) -> ResponseModel:
manager = self._manager
if manager is None or manager.empty():
Expand Down
Loading

0 comments on commit 1f8fa88

Please sign in to comment.