Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add python Client #65

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
afa030a
initial setup for python client
punit-kulal Aug 8, 2023
0df61bf
create client stub
punit-kulal Aug 8, 2023
eebbfb6
create serde specific classes
punit-kulal Aug 8, 2023
3a8d6ef
create client classes
punit-kulal Aug 8, 2023
4bbaa6c
fix import paths
punit-kulal Aug 8, 2023
d22ce1d
added a config builder
punit-kulal Aug 8, 2023
589c884
added tests for rest client
punit-kulal Aug 9, 2023
c8309db
add readme for setting up the environment
punit-kulal Aug 9, 2023
dfed4d6
added test files
punit-kulal Aug 9, 2023
4866a0e
check for parse_response call in test
punit-kulal Aug 16, 2023
f3da249
add test for parsing json response
punit-kulal Aug 16, 2023
0200df1
added wire format, changes to implement it
punit-kulal Aug 16, 2023
d78b16f
added wire format, changes to implement it
punit-kulal Aug 16, 2023
e23e1e8
added tests wrt wire type changes and refactoring
punit-kulal Aug 17, 2023
ff27249
added support for timeout and fixed uuid parameter generation
punit-kulal Aug 17, 2023
d420e56
add connection failure test. return error response instead of excepti…
punit-kulal Aug 17, 2023
c44f91c
added Json serde Test
punit-kulal Aug 17, 2023
f1ecb28
added test for serde factory
punit-kulal Aug 17, 2023
5c4c2f2
added content_type test
punit-kulal Aug 17, 2023
10b3dd5
Python protobuf client (#63)
punit-kulal Aug 18, 2023
587fb14
added failure tests for util factories
punit-kulal Aug 21, 2023
951646e
updated response signature to contain error
punit-kulal Aug 21, 2023
adac404
add test for json serialiser and protobuf wire
punit-kulal Aug 21, 2023
0e10cc7
add test for protobuf serialiser and json wire
punit-kulal Aug 21, 2023
6af0042
added fixes
punit-kulal Aug 21, 2023
8f627cd
added info about generated .pyi files
punit-kulal Aug 21, 2023
46bc354
Update JsonSerde#marshal to preserve case and modify sent_time to dict
punit-kulal Aug 22, 2023
a9ef1a3
update raccoon_client-0.1.0-py3-none-any.whl
punit-kulal Aug 22, 2023
0535be2
remove commented line
punit-kulal Aug 23, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@ coverage
.vscode
*.env
*.idea/
raccoon
.temp
clients/python/venv
clients/python/poetry.lock
__pycache__
raccoon
!clients/python/raccoon_client/protos/raystack/raccoon/v1beta1/
5 changes: 5 additions & 0 deletions buf.gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ plugins:
- plugin: buf.build/grpc/go:v1.3.0
out: proto
opt: paths=source_relative,require_unimplemented_servers=true
- plugin: buf.build/protocolbuffers/python:v23.4
out: clients/python/raccoon_client/protos
- plugin: buf.build/protocolbuffers/pyi:v23.4
out: clients/python/raccoon_client/protos

12 changes: 12 additions & 0 deletions clients/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Python Client

### Setup
- The project uses poetry for build, and virutal env management.
- Make sure to install poetry via https://python-poetry.org/docs/#installing-manually
- After installing poetry you can activate the env by `poetry env use`
- Install all dependencies using `poetry install --no-root` (no-root tells that the package is not at the root of the directory)
- For setting up in IDE, make sure to setup the interpreter to use the virtual environment that was created when you activated poetry env.

Note:
- During development, make sure to open just the python directory, otherwise the IDE misconfigures the imports.
- The protos package contain generated code and should not be edited manually.
Binary file not shown.
20 changes: 20 additions & 0 deletions clients/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[tool.poetry]
name = "raccoon-client"
version = "0.1.0"
description = "A python client to serve requests to raccoon server"
authors = ["Punit Kulal <[email protected]>"]
readme = "README.md"
packages = [{include = "raccoon_client"}]

[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.31.0"
protobuf = "^4.23.4"
google = "^3.0.0"

[tool.poetry.group.dev.dependencies]
requests = "^2.31.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Empty file.
19 changes: 19 additions & 0 deletions clients/python/raccoon_client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import SendEventResponse


class RaccoonResponseError(IOError):

def __init__(self, status_code, msg):
super().__init__(msg)
self.status_code = status_code


class Event:
type: str
event: object


class Client:

def send(self, events: [Event]) -> (str, SendEventResponse, RaccoonResponseError):
raise NotImplementedError()
Empty file.
Empty file.
Empty file.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
### Generated by protoc

from google.protobuf import timestamp_pb2 as _timestamp_pb2
prakharmathur82 marked this conversation as resolved.
Show resolved Hide resolved
from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union

DESCRIPTOR: _descriptor.FileDescriptor

class Status(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
STATUS_UNSPECIFIED: _ClassVar[Status]
STATUS_SUCCESS: _ClassVar[Status]
STATUS_ERROR: _ClassVar[Status]

class Code(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
CODE_UNSPECIFIED: _ClassVar[Code]
CODE_OK: _ClassVar[Code]
CODE_BAD_REQUEST: _ClassVar[Code]
CODE_INTERNAL_ERROR: _ClassVar[Code]
CODE_MAX_CONNECTION_LIMIT_REACHED: _ClassVar[Code]
CODE_MAX_USER_LIMIT_REACHED: _ClassVar[Code]
STATUS_UNSPECIFIED: Status
STATUS_SUCCESS: Status
STATUS_ERROR: Status
CODE_UNSPECIFIED: Code
CODE_OK: Code
CODE_BAD_REQUEST: Code
CODE_INTERNAL_ERROR: Code
CODE_MAX_CONNECTION_LIMIT_REACHED: Code
CODE_MAX_USER_LIMIT_REACHED: Code

class SendEventRequest(_message.Message):
__slots__ = ["req_guid", "sent_time", "events"]
REQ_GUID_FIELD_NUMBER: _ClassVar[int]
SENT_TIME_FIELD_NUMBER: _ClassVar[int]
EVENTS_FIELD_NUMBER: _ClassVar[int]
req_guid: str
sent_time: _timestamp_pb2.Timestamp
events: _containers.RepeatedCompositeFieldContainer[Event]
def __init__(self, req_guid: _Optional[str] = ..., sent_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., events: _Optional[_Iterable[_Union[Event, _Mapping]]] = ...) -> None: ...

class Event(_message.Message):
__slots__ = ["event_bytes", "type"]
EVENT_BYTES_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
event_bytes: bytes
type: str
def __init__(self, event_bytes: _Optional[bytes] = ..., type: _Optional[str] = ...) -> None: ...

class SendEventResponse(_message.Message):
__slots__ = ["status", "code", "sent_time", "reason", "data"]
class DataEntry(_message.Message):
__slots__ = ["key", "value"]
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: str
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
STATUS_FIELD_NUMBER: _ClassVar[int]
CODE_FIELD_NUMBER: _ClassVar[int]
SENT_TIME_FIELD_NUMBER: _ClassVar[int]
REASON_FIELD_NUMBER: _ClassVar[int]
DATA_FIELD_NUMBER: _ClassVar[int]
status: Status
code: Code
sent_time: int
reason: str
data: _containers.ScalarMap[str, str]
def __init__(self, status: _Optional[_Union[Status, str]] = ..., code: _Optional[_Union[Code, str]] = ..., sent_time: _Optional[int] = ..., reason: _Optional[str] = ..., data: _Optional[_Mapping[str, str]] = ...) -> None: ...
Empty file.
75 changes: 75 additions & 0 deletions clients/python/raccoon_client/rest/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import time
import uuid

import requests
from requests.adapters import HTTPAdapter
from urllib3 import Retry

from raccoon_client.client import Client, Event, RaccoonResponseError
from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import SendEventRequest, SendEventResponse, Event as EventPb
from raccoon_client.rest.option import RestClientConfig
from raccoon_client.serde.enum import Serialiser
from raccoon_client.serde.serde import Serde
from raccoon_client.serde.util import get_serde, CONTENT_TYPE_HEADER_KEY, get_wire_type
from raccoon_client.serde.wire import Wire


class RestClient(Client):
session: requests.Session
serde: Serde
wire: Wire

def __init__(self, config: RestClientConfig):
self.config = config
self.session = requests.session()
self.url = config.url
self.serde = get_serde(config.serialiser)
self.wire = get_wire_type(config.wire_type)
self.headers = self._set_content_type_header(config.headers)
self._set_retries(self.session, config.max_retries)
self.timeout = config.timeout

def _set_retries(self, session, max_retries):
retries = Retry(
total=max_retries,
backoff_factor=1,
status_forcelist=[500, 502, 503, 504, 521, 429],
allowed_methods=["POST"],
raise_on_status=False,
)
session.mount("https://", HTTPAdapter(max_retries=retries))
prakharmathur82 marked this conversation as resolved.
Show resolved Hide resolved
session.mount("http://", HTTPAdapter(max_retries=retries))

def send(self, events: [Event]):
req = self._get_init_request()
events_pb = map(lambda x: self._convert_to_event_pb(x), events)
req.events.extend(events_pb)
response = self.session.post(url=self.url, data=self.wire.marshal(req), headers=self.headers, timeout=self.timeout)
deserialised_response, err = self._parse_response(response)
return req.req_guid, deserialised_response, err

def _convert_to_event_pb(self, e: Event):
proto_event = EventPb()
proto_event.event_bytes = self.serde.serialise(e.event)
proto_event.type = e.type
return proto_event

def _get_init_request(self):
req = SendEventRequest()
req.req_guid = str(uuid.uuid4())
req.sent_time.FromNanoseconds(time.time_ns())
return req

def _set_content_type_header(self, headers):
headers[CONTENT_TYPE_HEADER_KEY] = self.wire.get_content_type()
return headers

def _parse_response(self, response: requests.Response) -> (SendEventResponse, ValueError):
event_response = error = None
if len(response.content) != 0:
event_response = self.wire.unmarshal(response.content, SendEventResponse())

if 200 < response.status_code >= 300:
error = RaccoonResponseError(response.status_code, response.content)

return event_response, error
62 changes: 62 additions & 0 deletions clients/python/raccoon_client/rest/option.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from raccoon_client.serde.enum import Serialiser, WireType


class RestClientConfig:
url: str
max_retries: int
serialiser: Serialiser
headers: dict

def __init__(self):
self.headers = {}
self.serialiser = Serialiser.JSON
self.max_retries = 0
self.wire_type = WireType.JSON
self.timeout = 1.0


class RestClientConfigBuilder:

def __init__(self):
self.config = RestClientConfig()

def with_url(self, url):
self.config.url = url
return self

def with_retry_count(self, retry_count):
if not isinstance(retry_count, int):
raise ValueError("retry_count should be an integer")
elif retry_count > 10:
raise ValueError("retry should not be greater than 10")
self.config.max_retries = retry_count
return self

def with_serialiser(self, content_type):
if not isinstance(content_type, Serialiser):
raise ValueError("invalid serialiser/deserialiser type")
self.config.serialiser = content_type
return self

def with_headers(self, headers):
self.config.headers = headers

def with_wire_type(self, wire_type):
if not isinstance(wire_type, WireType):
raise ValueError("invalid serialiser/deserialiser type")
self.config.wire_type = wire_type
return self

def with_timeout(self, timeout):
if not isinstance(timeout, float):
raise ValueError
if timeout > 10:
raise ValueError("timeout too high")
elif timeout < 0.010:
raise ValueError("timeout is too low")
else:
self.config.timeout = timeout
return self

def build(self):
return self.config
Empty file.
14 changes: 14 additions & 0 deletions clients/python/raccoon_client/serde/enum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from enum import Enum

from raccoon_client.serde.json_serde import JsonSerde
from raccoon_client.serde.protobuf_serde import ProtobufSerde


class Serialiser(Enum):
JSON = JsonSerde
PROTOBUF = ProtobufSerde


class WireType(Enum):
JSON = JsonSerde
PROTOBUF = ProtobufSerde
28 changes: 28 additions & 0 deletions clients/python/raccoon_client/serde/json_serde.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import json

from google.protobuf import json_format
from google.protobuf.message import Message

from raccoon_client.protos.raystack.raccoon.v1beta1.raccoon_pb2 import SendEventRequest, SendEventResponse
from raccoon_client.serde.serde import Serde
from raccoon_client.serde.wire import Wire


class JsonSerde(Serde, Wire):
def serialise(self, event):
if isinstance(event, Message):
return bytes(json_format.MessageToJson(event), "utf-8")
return bytes(json.dumps(event), "utf-8") # uses json.dumps since the input can be either protobuf message or dictionary

def get_content_type(self):
return "application/json"

def marshal(self, event: SendEventRequest):
req_dict = json_format.MessageToDict(event, preserving_proto_field_name=True)
req_dict["sent_time"] = {"seconds": event.sent_time.seconds, "nanos": event.sent_time.nanos}
return json.dumps(req_dict) # uses json_format since the event is always a protobuf message

def unmarshal(self, data, template: SendEventResponse):
return json_format.Parse(data, template)


21 changes: 21 additions & 0 deletions clients/python/raccoon_client/serde/protobuf_serde.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from google.protobuf.message import Message

from raccoon_client.serde.serde import Serde
from raccoon_client.serde.wire import Wire


class ProtobufSerde(Serde, Wire):
def serialise(self, event: Message):
if not isinstance(event, Message):
raise ValueError("event should be a protobuf message")
return event.SerializeToString() # the name is a misnomer, returns bytes

def marshal(self, obj: Message):
return obj.SerializeToString()

def unmarshal(self, marshalled_data: bytes, template: Message):
template.ParseFromString(marshalled_data)
prakharmathur82 marked this conversation as resolved.
Show resolved Hide resolved
return template

def get_content_type(self):
return "application/proto"
3 changes: 3 additions & 0 deletions clients/python/raccoon_client/serde/serde.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Serde:
def serialise(self, event):
raise NotImplementedError()
Loading
Loading