Skip to content

Commit 0883c7a

Browse files
author
Oluwafemi Adenuga
authored
Elorus API client for python
2 parents 2ee27b4 + 1bb3e56 commit 0883c7a

16 files changed

+987
-1
lines changed

.devcontainer/devcontainer.json

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "elorus-python-sdk",
3+
"dockerComposeFile": [
4+
"../docker-compose.yml"
5+
],
6+
"service": "elorus-python-sdk",
7+
"workspaceFolder": "/usr/src/app",
8+
"features": {
9+
"ghcr.io/devcontainers/features/git:1": {
10+
"ppa": true,
11+
"version": "os-provided"
12+
}
13+
},
14+
"customizations": {
15+
"vscode": {
16+
"extensions": [
17+
"editorconfig.editorconfig",
18+
"ms-python.black-formatter",
19+
"ms-python.isort",
20+
"ms-python.python",
21+
"ms-python.vscode-pylance"
22+
],
23+
"settings": {
24+
"editor.formatOnPaste": true,
25+
"editor.formatOnSave": true,
26+
"editor.formatOnSaveMode": "modifications",
27+
"isort.args": [
28+
"--profile",
29+
"black"
30+
],
31+
"python.formatting.provider": "black",
32+
"[python]": {
33+
"editor.defaultFormatter": "ms-python.black-formatter",
34+
"editor.formatOnSave": true,
35+
"editor.formatOnSaveMode": "modifications",
36+
"editor.formatOnPaste": true,
37+
"editor.codeActionsOnSave": {
38+
"source.organizeImports": "always"
39+
}
40+
}
41+
}
42+
}
43+
}
44+
}

.github/ci.yml

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/[email protected]
14+
- run: pipx install poetry==1.8.2
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.12"
18+
cache: poetry
19+
- run: poetry install
20+
- run: poetry run black --check .
21+
- run: poetry run mypy --check .

.github/publish.yml

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
release:
5+
types:
6+
- published
7+
8+
jobs:
9+
deploy:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/[email protected]
13+
- run: pipx install poetry==1.8.2
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.12"
17+
cache: "poetry"
18+
- run: poetry install
19+
- name: Build and publish
20+
run: |
21+
poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}
22+
poetry publish --build

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,4 @@ cython_debug/
158158
# and can be added to the global gitignore or merged into this file. For a more nuclear
159159
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
160160
#.idea/
161+
run.py

Dockerfile

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
FROM ghcr.io/withlogicco/poetry:1.8.2-python-3.12
2+
3+
WORKDIR /usr/src/app
4+
COPY pyproject.toml poetry.lock README.md ./
5+
RUN poetry check && poetry lock --check
6+
RUN poetry install
7+
8+
COPY ./ ./

README.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
# elorus-python-sdk
1+
# Elorus Python SDK
2+
3+
`Elorus` is a Python SDK for interacting with the API of [Elorus](https://developer.elorus.com/).
4+
5+
## Requirements
6+
7+
Python 3.12 or later

docker-compose.yml

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
version: "3.8"
2+
3+
services:
4+
elorus-python-sdk:
5+
build: .
6+
environment:
7+
ELORUS_API_KEY: ${ELORUS_API_KEY}
8+
ELORUS_ORGANIZATION_ID: ${ELORUS_ORGANIZATION_ID}
9+
ELORUS_BASE_URL: ${ELORUS_BASE_URL}
10+
volumes:
11+
- .:/usr/src/app
12+
network_mode: host
13+
image: elorus-python-sdk:latest
14+
command: sleep infinity

elorus/__init__.py

Whitespace-only changes.

elorus/auth.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from httpx import Auth
2+
3+
4+
class ElorusAuthentication(Auth):
5+
def __init__(self, token: str, elorus_organization_id: str, is_demo: bool = False):
6+
self.token = token
7+
self.elorus_organization_id = elorus_organization_id
8+
self.is_demo = is_demo
9+
10+
def auth_flow(self, request):
11+
request.headers["Authorization"] = f"Token {self.token}"
12+
request.headers["X-Elorus-Organization"] = self.elorus_organization_id
13+
if self.is_demo:
14+
request.headers["X-Elorus-Demo"] = "true"
15+
yield request

elorus/client.py

+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
from dataclasses import asdict
2+
from typing import Optional
3+
4+
import httpx
5+
6+
from elorus.auth import ElorusAuthentication
7+
from elorus.exceptions import (
8+
AuthenticationError,
9+
AuthorizationError,
10+
BadRequestError,
11+
Error,
12+
ThrottlingError,
13+
)
14+
from elorus.models import Contact, EmailBody, Invoice
15+
16+
17+
class Client:
18+
def __init__(
19+
self,
20+
api_key: str,
21+
elorus_organization_id: str,
22+
is_demo: bool = False,
23+
base_url: Optional[str] = "https://api.elorus.com",
24+
api_version: Optional[str] = "v1.1",
25+
):
26+
self.base_url = base_url
27+
self.api_key = api_key
28+
self.elorus_organization_id = elorus_organization_id
29+
self.is_demo = is_demo
30+
self.api_version = api_version
31+
32+
self.contacts = Contacts(self)
33+
self.invoices = Invoices(self)
34+
35+
def _get_auth(self):
36+
return ElorusAuthentication(
37+
token=self.api_key,
38+
elorus_organization_id=self.elorus_organization_id,
39+
is_demo=self.is_demo,
40+
)
41+
42+
def handle_file_download(self, response):
43+
content_disposition = response.headers.get("Content-Disposition", "")
44+
if "filename=" not in content_disposition:
45+
raise ValueError("No filename found in Content-Disposition header")
46+
filename_kv = content_disposition.split("filename=")
47+
if len(filename_kv) < 2:
48+
raise ValueError("Invalid Content-Disposition header format")
49+
filename = filename_kv[1]
50+
return filename, response.content
51+
52+
def _handle_response(self, response):
53+
if response.headers.get("Content-Type", "").lower() == "application/pdf":
54+
return self.handle_file_download(response)
55+
56+
if response.status_code == 204:
57+
return response.text
58+
59+
message = response.json()
60+
61+
if response.status_code == 401:
62+
raise AuthenticationError(
63+
message=message,
64+
response=response,
65+
)
66+
67+
if response.status_code == 403:
68+
raise AuthorizationError(
69+
message=message,
70+
response=response,
71+
)
72+
73+
if response.status_code == 429:
74+
raise ThrottlingError(
75+
message=message,
76+
response=response,
77+
)
78+
79+
if response.status_code == 400:
80+
raise BadRequestError(
81+
message=message,
82+
response=response,
83+
)
84+
85+
if response.status_code >= 500:
86+
raise Error(
87+
message=message,
88+
response=response,
89+
)
90+
91+
try:
92+
response.raise_for_status()
93+
return message
94+
except:
95+
error = f"{response.text}"
96+
content_type = response.headers.get("Content-Type", "").lower()
97+
if content_type == "application/json":
98+
resp = response.json()
99+
error_message_keys = ("message", "msg", "detail")
100+
error_message = next(
101+
(resp[key] for key in error_message_keys if key in resp), None
102+
)
103+
if error_message:
104+
error = f"Message: {error_message} , Error details: {resp.get('errors')}, {resp.get('data')}"
105+
raise Error(error, response)
106+
107+
raise Error(error, response)
108+
109+
def _handle_request(
110+
self, method: str, path: str, payload: Optional[dict] = None, **kwargs
111+
):
112+
auth = self._get_auth()
113+
url = f"{self.base_url}/{self.api_version}/{path}"
114+
with httpx.Client(auth=auth) as client:
115+
response = client.request(method, url, json=payload, **kwargs)
116+
return self._handle_response(response)
117+
118+
119+
class SubClient:
120+
client = Client
121+
122+
def __init__(self, client: Client):
123+
self.client = client
124+
125+
126+
class Contacts(SubClient):
127+
128+
def list(self):
129+
return self.client._handle_request("GET", "contacts/")
130+
131+
def create(self, contact: Contact):
132+
payload = asdict(contact)
133+
return self.client._handle_request("POST", "contacts/", payload=payload)
134+
135+
def get(self, contact_id: str):
136+
return self.client._handle_request("GET", f"contacts/{contact_id}/")
137+
138+
def update(self, contact_id: str, contact: Contact):
139+
payload = asdict(contact)
140+
return self.client._handle_request(
141+
"PUT", f"contacts/{contact_id}/", payload=payload
142+
)
143+
144+
def delete(self, contact_id: str):
145+
return self.client._handle_request("DELETE", f"contacts/{contact_id}/")
146+
147+
148+
class Invoices(SubClient):
149+
150+
def list(self):
151+
return self.client._handle_request("GET", "invoices/")
152+
153+
def create(self, invoice: Invoice):
154+
payload = invoice.clean_dict()
155+
return self.client._handle_request("POST", "invoices/", payload=payload)
156+
157+
def get(self, invoice_id: str):
158+
return self.client._handle_request("GET", f"invoices/{invoice_id}/")
159+
160+
def update(self, invoice_id: str, invoice: Invoice):
161+
payload = invoice.clean_dict()
162+
return self.client._handle_request(
163+
"PUT", f"invoices/{invoice_id}/", payload=payload
164+
)
165+
166+
def partial_update(self, invoice_id: str, invoice: Invoice):
167+
payload = invoice.clean_dict()
168+
return self.client._handle_request(
169+
"PATCH", f"invoices/{invoice_id}/", payload=payload
170+
)
171+
172+
def delete(self, invoice_id: str):
173+
return self.client._handle_request("DELETE", f"invoices/{invoice_id}/")
174+
175+
def post_email(self, invoice_id: str, email_body: EmailBody):
176+
payload = email_body.clean_dict()
177+
return self.client._handle_request(
178+
"POST", f"invoices/{invoice_id}/email/", payload=payload
179+
)
180+
181+
def get_email(self, invoice_id: str):
182+
return self.client._handle_request("GET", f"invoices/{invoice_id}/email/")
183+
184+
def get_pdf(self, invoice_id: str):
185+
return self.client._handle_request("GET", f"invoices/{invoice_id}/pdf/")
186+
187+
def mark_void(self, invoice_id: str, is_void: bool = True):
188+
payload = {"is_void": is_void}
189+
return self.client._handle_request(
190+
"POST", f"invoices/{invoice_id}/void/", payload=payload
191+
)

elorus/exceptions.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
class Error(Exception):
2+
"""Base class for other exceptions"""
3+
4+
def __init__(self, message, response=None):
5+
self.message = message
6+
self.response = response
7+
8+
def __str__(self) -> str:
9+
return f"Error code: {self.response.status_code}, Error message: {self.message}"
10+
11+
12+
class AuthenticationError(Error):
13+
"""Raised when there is an authentication error"""
14+
15+
def __init__(self, message, response=None):
16+
super().__init__(message, response)
17+
18+
19+
class AuthorizationError(Error):
20+
"""Raised when there is an authorization error"""
21+
22+
def __init__(self, message, response=None):
23+
super().__init__(message, response)
24+
25+
26+
class BadRequestError(Error):
27+
"""Raised when the request is bad"""
28+
29+
def __init__(self, message, response=None):
30+
super().__init__(message, response)
31+
32+
33+
class ThrottlingError(Error):
34+
"""Raised when the rate limit is exceeded"""
35+
36+
def __init__(self, message, response=None):
37+
super().__init__(message, response)

0 commit comments

Comments
 (0)