weclappy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weclappy-0.1.0.dist-info/METADATA +88 -0
- weclappy-0.1.0.dist-info/RECORD +5 -0
- weclappy-0.1.0.dist-info/WHEEL +5 -0
- weclappy-0.1.0.dist-info/top_level.txt +1 -0
- weclappy.py +249 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: weclappy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python client for the Weclapp API.
|
|
5
|
+
Author-email: Markus Wals <markus@wals.pro>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://wals.pro/
|
|
8
|
+
Project-URL: Repository, https://github.com/Wals-pro/weclappy
|
|
9
|
+
Project-URL: Documentation, https://github.com/Wals-pro/weclappy#readme
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.26.0
|
|
15
|
+
|
|
16
|
+
# weclappy
|
|
17
|
+
|
|
18
|
+
The weclapp Python Client.
|
|
19
|
+
|
|
20
|
+
There is no lightweight, simple weclapp client library available for Python currently. Let's build it together.
|
|
21
|
+
|
|
22
|
+
## Overview
|
|
23
|
+
|
|
24
|
+
The goal of this library is to provide a minimal, threaded client that handles pagination effectively when fetching lists from the weclapp API. It is capable of retrieving large volumes of data by parallelizing page requests, significantly reducing wait times. This library is designed to be lean with no unnecessary bloat, allowing you to get started very quickly.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Threaded Pagination:** Fetch multiple pages concurrently for enhanced performance.
|
|
29
|
+
- **Minimal Dependencies:** Only dependency is [`requests`](https://pypi.org/project/requests/).
|
|
30
|
+
- **Simplicity:** A lean bloat free solution to interact with the weclapp API.
|
|
31
|
+
- **Open Source:** Free to use in any project, with contributions and improvements highly welcome.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Install the package via pip:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install weclapp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
|
|
45
|
+
from weclapp import Weclapp
|
|
46
|
+
|
|
47
|
+
# Initialize the client with your base URL and API key
|
|
48
|
+
client = Weclapp("https://acme.weclapp.com/webapp/api/v1", "your_api_key")
|
|
49
|
+
|
|
50
|
+
# Fetch a single entity by ID, e.g., 'salesOrder' with ID '12345'
|
|
51
|
+
sales_order = client.get("salesOrder", id="12345")
|
|
52
|
+
|
|
53
|
+
# Fetch paginated results for an entity, e.g., 'salesOrder' with a filter
|
|
54
|
+
sales_orders = client.get_all("salesOrder", { "salesOrderPaymentType-eq": "ADVANCE_PAYMENT" }, threaded=True)
|
|
55
|
+
|
|
56
|
+
# Create a new entity, e.g., 'salesOrder'
|
|
57
|
+
new_sales_order = client.post("salesOrder", { "customerId": "12345", "commission": "Hello, world!" })
|
|
58
|
+
|
|
59
|
+
# Update an existing entity, e.g., 'salesOrder' with ID '12345', ignoreMissingProperties is True per default
|
|
60
|
+
updated_sales_order = client.put("salesOrder", id="12345", data={ "commission": "Hello, universe!" })
|
|
61
|
+
|
|
62
|
+
# Delete an entity, e.g., 'salesOrder' with ID '12345'
|
|
63
|
+
client.delete("salesOrder", id="12345")
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Contributing
|
|
68
|
+
|
|
69
|
+
Contributions are very welcome. Any improvements, bug fixes, or new features are gladly accepted. Let’s build this client together to make working with the weclapp API as efficient as possible.
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
This project is licensed under the MIT License. Feel free to use it in your commercial projects with no restrictions.
|
|
75
|
+
|
|
76
|
+
# Get in touch
|
|
77
|
+
|
|
78
|
+
If you are interested in working with us or want us to implement your integrations, then book a call. You can always book a call with me at
|
|
79
|
+
https://wals.pro/termin.
|
|
80
|
+
|
|
81
|
+
## Support
|
|
82
|
+
|
|
83
|
+
Feel free to use this library in all your projects for free. If you have a lot of fun and build something great with it, consider buying me a coffee.
|
|
84
|
+
|
|
85
|
+
## Follow me on:
|
|
86
|
+
- [LinkedIn](https://www.linkedin.com/in/markuswals)
|
|
87
|
+
- [YouTube](https://www.youtube.com/@wals-pro)
|
|
88
|
+
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
weclappy.py,sha256=_uNCQiBs-GPNlr2Q-5QuddePeLI-dE5le3wtVhA-EB8,9841
|
|
2
|
+
weclappy-0.1.0.dist-info/METADATA,sha256=aRqy3O199nRzmBkyJnjtfhhe1hiI3F0W9tUh_dihUPg,3296
|
|
3
|
+
weclappy-0.1.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
4
|
+
weclappy-0.1.0.dist-info/top_level.txt,sha256=7yyrXZdPMplBP9dMxrN2d59AT5a-2pNW22KOxFg1oL8,9
|
|
5
|
+
weclappy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
weclappy
|
weclappy.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A Python client for the Weclapp API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import logging
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
from urllib.parse import urljoin
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from requests.adapters import HTTPAdapter
|
|
13
|
+
from urllib3.util.retry import Retry
|
|
14
|
+
|
|
15
|
+
# Module-level constants
|
|
16
|
+
DEFAULT_PAGE_SIZE = 1000
|
|
17
|
+
DEFAULT_MAX_WORKERS = 10
|
|
18
|
+
|
|
19
|
+
# Module-level logger
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WeclappAPIError(Exception):
|
|
24
|
+
"""Custom exception for Weclapp API errors."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Weclapp:
|
|
29
|
+
"""
|
|
30
|
+
Client for interacting with the Weclapp API.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
base_url: str
|
|
34
|
+
session: requests.Session
|
|
35
|
+
|
|
36
|
+
def __init__(self, base_url: str, api_key: str) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Initialize the Weclapp client.
|
|
39
|
+
|
|
40
|
+
:param base_url: Base URL for the API.
|
|
41
|
+
:param api_key: Authentication token.
|
|
42
|
+
"""
|
|
43
|
+
self.base_url = base_url.rstrip('/') + '/'
|
|
44
|
+
self.session = requests.Session()
|
|
45
|
+
self.session.headers.update({
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"AuthenticationToken": api_key
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
# Configure HTTP retry strategy.
|
|
51
|
+
retry_strategy = Retry(
|
|
52
|
+
total=3,
|
|
53
|
+
backoff_factor=0.3,
|
|
54
|
+
status_forcelist=[500, 502, 503, 504],
|
|
55
|
+
allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
|
|
56
|
+
)
|
|
57
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
58
|
+
self.session.mount("http://", adapter)
|
|
59
|
+
self.session.mount("https://", adapter)
|
|
60
|
+
|
|
61
|
+
def _send_request(self, method: str, url: str, **kwargs: Any) -> Dict[str, Any]:
|
|
62
|
+
"""
|
|
63
|
+
Send an HTTP request and return the JSON response.
|
|
64
|
+
|
|
65
|
+
If the response status code is 204 or no content is present, return an empty dict.
|
|
66
|
+
|
|
67
|
+
:param method: HTTP method.
|
|
68
|
+
:param url: URL for the request.
|
|
69
|
+
:param kwargs: Additional request parameters.
|
|
70
|
+
:return: JSON response as a dict, or {} for 204 responses.
|
|
71
|
+
:raises WeclappAPIError: on request failure.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
response = self.session.request(method, url, **kwargs)
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
# Return {} if there is no content (204 No Content) or empty body
|
|
77
|
+
if response.status_code == 204 or not response.content.strip():
|
|
78
|
+
return {}
|
|
79
|
+
else:
|
|
80
|
+
return response.json()
|
|
81
|
+
except requests.exceptions.RequestException as e:
|
|
82
|
+
logger.error(f"HTTP {method} request failed for {url}: {e}")
|
|
83
|
+
raise WeclappAPIError(f"HTTP {method} request failed for {url}: {e}. Details: {response.text}") from e
|
|
84
|
+
|
|
85
|
+
def get(
|
|
86
|
+
self,
|
|
87
|
+
endpoint: str,
|
|
88
|
+
id: Optional[str] = None,
|
|
89
|
+
params: Optional[Dict[str, Any]] = None
|
|
90
|
+
) -> Union[List[Any], Dict[str, Any]]:
|
|
91
|
+
"""
|
|
92
|
+
Perform a GET request. If an id is provided, fetch a single record using the
|
|
93
|
+
URL pattern 'endpoint/id/{id}'. Otherwise, fetch records as a list from the endpoint.
|
|
94
|
+
|
|
95
|
+
:param endpoint: API endpoint.
|
|
96
|
+
:param id: Optional identifier to fetch a single record.
|
|
97
|
+
:param params: Query parameters.
|
|
98
|
+
:return: A single record as a dict if id is provided, or a list of records otherwise.
|
|
99
|
+
:raises WeclappAPIError: on request failure.
|
|
100
|
+
"""
|
|
101
|
+
if id is not None:
|
|
102
|
+
new_endpoint = f"{endpoint}/id/{id}"
|
|
103
|
+
url = urljoin(self.base_url, new_endpoint)
|
|
104
|
+
logger.debug(f"GET single record from {url} with params {params}")
|
|
105
|
+
return self._send_request("GET", url, params=params)
|
|
106
|
+
else:
|
|
107
|
+
url = urljoin(self.base_url, endpoint)
|
|
108
|
+
logger.debug(f"GET {url} with params {params}")
|
|
109
|
+
data = self._send_request("GET", url, params=params)
|
|
110
|
+
return data.get('result', [])
|
|
111
|
+
|
|
112
|
+
def get_all(
|
|
113
|
+
self,
|
|
114
|
+
entity: str,
|
|
115
|
+
params: Optional[Dict[str, Any]] = None,
|
|
116
|
+
limit: Optional[int] = None,
|
|
117
|
+
threaded: bool = False,
|
|
118
|
+
max_workers: int = DEFAULT_MAX_WORKERS
|
|
119
|
+
) -> List[Any]:
|
|
120
|
+
"""
|
|
121
|
+
Retrieve all records for the given entity with automatic pagination.
|
|
122
|
+
|
|
123
|
+
:param entity: Entity name, e.g. 'salesOrder'.
|
|
124
|
+
:param params: Query parameters.
|
|
125
|
+
:param limit: Limit total records returned.
|
|
126
|
+
:param threaded: Fetch pages in parallel if True.
|
|
127
|
+
:param max_workers: Maximum parallel threads (default is 10).
|
|
128
|
+
:return: List of records.
|
|
129
|
+
:raises WeclappAPIError: on request failure.
|
|
130
|
+
"""
|
|
131
|
+
params = params.copy() if params is not None else {}
|
|
132
|
+
results: List[Any] = []
|
|
133
|
+
|
|
134
|
+
if not threaded:
|
|
135
|
+
# Sequential pagination.
|
|
136
|
+
params['page'] = 1
|
|
137
|
+
params['pageSize'] = limit if (limit is not None and limit < DEFAULT_PAGE_SIZE) else DEFAULT_PAGE_SIZE
|
|
138
|
+
|
|
139
|
+
while True:
|
|
140
|
+
url = urljoin(self.base_url, entity)
|
|
141
|
+
logger.info(f"Fetching page {params['page']} for {entity}")
|
|
142
|
+
logger.debug(f"GET {url} with params {params}")
|
|
143
|
+
data = self._send_request("GET", url, params=params)
|
|
144
|
+
current_page = data.get('result', [])
|
|
145
|
+
results.extend(current_page)
|
|
146
|
+
|
|
147
|
+
if len(current_page) < params['pageSize'] or (limit is not None and len(results) >= limit):
|
|
148
|
+
break
|
|
149
|
+
params['page'] += 1
|
|
150
|
+
|
|
151
|
+
return results[:limit] if limit is not None else results
|
|
152
|
+
|
|
153
|
+
else:
|
|
154
|
+
# Parallel pagination.
|
|
155
|
+
count_endpoint = f"{entity}/count"
|
|
156
|
+
logger.info(f"Fetching total count for {entity} with params {params}")
|
|
157
|
+
total_count_data = self.get(count_endpoint, params=params)
|
|
158
|
+
total_count = total_count_data if isinstance(total_count_data, int) else 0
|
|
159
|
+
|
|
160
|
+
if total_count == 0:
|
|
161
|
+
logger.info(f"No records found for entity '{entity}'")
|
|
162
|
+
return results
|
|
163
|
+
|
|
164
|
+
page_size = limit if (limit is not None and limit < DEFAULT_PAGE_SIZE) else DEFAULT_PAGE_SIZE
|
|
165
|
+
total_for_pages = total_count if (limit is None or limit > total_count) else limit
|
|
166
|
+
total_pages = math.ceil(total_for_pages / page_size)
|
|
167
|
+
|
|
168
|
+
logger.info(
|
|
169
|
+
f"Total {total_count} records for {entity}, fetching up to {total_for_pages} "
|
|
170
|
+
f"records across {total_pages} pages in parallel."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def fetch_page(page_number: int) -> List[Any]:
|
|
174
|
+
# Fetch a single page.
|
|
175
|
+
page_params = params.copy()
|
|
176
|
+
page_params['page'] = page_number
|
|
177
|
+
page_params['pageSize'] = page_size
|
|
178
|
+
url = urljoin(self.base_url, entity)
|
|
179
|
+
logger.info(f"[Threaded] Fetching page {page_number} of {total_pages} for {entity}")
|
|
180
|
+
logger.debug(f"GET {url} with params {page_params}")
|
|
181
|
+
data = self._send_request("GET", url, params=page_params)
|
|
182
|
+
return data.get('result', [])
|
|
183
|
+
|
|
184
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
185
|
+
future_to_page = {executor.submit(fetch_page, page): page for page in range(1, total_pages + 1)}
|
|
186
|
+
for future in as_completed(future_to_page):
|
|
187
|
+
page_number = future_to_page[future]
|
|
188
|
+
try:
|
|
189
|
+
page_results = future.result()
|
|
190
|
+
results.extend(page_results)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error(f"Error fetching page {page_number} for {entity}: {e}")
|
|
193
|
+
else:
|
|
194
|
+
logger.info(f"[Threaded] Completed page {page_number}/{total_pages} for {entity}")
|
|
195
|
+
|
|
196
|
+
return results[:limit] if limit is not None else results
|
|
197
|
+
|
|
198
|
+
def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
199
|
+
"""
|
|
200
|
+
Perform a POST request to the given endpoint.
|
|
201
|
+
|
|
202
|
+
:param endpoint: API endpoint.
|
|
203
|
+
:param data: Data to post.
|
|
204
|
+
:return: JSON response.
|
|
205
|
+
:raises WeclappAPIError: on request failure.
|
|
206
|
+
"""
|
|
207
|
+
url = urljoin(self.base_url, endpoint)
|
|
208
|
+
logger.debug(f"POST {url} - Data: {data}")
|
|
209
|
+
return self._send_request("POST", url, json=data)
|
|
210
|
+
|
|
211
|
+
def put(self, endpoint: str, data: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
212
|
+
"""
|
|
213
|
+
Perform a PUT request to the given endpoint.
|
|
214
|
+
|
|
215
|
+
:param endpoint: API endpoint.
|
|
216
|
+
:param data: Data to put.
|
|
217
|
+
:param params: Query parameters.
|
|
218
|
+
:return: JSON response.
|
|
219
|
+
:raises WeclappAPIError: on request failure.
|
|
220
|
+
"""
|
|
221
|
+
params = params.copy() if params is not None else {}
|
|
222
|
+
params.setdefault("ignoreMissingProperties", True)
|
|
223
|
+
url = urljoin(self.base_url, endpoint)
|
|
224
|
+
logger.debug(f"PUT {url} - Data: {data} - Params: {params}")
|
|
225
|
+
return self._send_request("PUT", url, json=data, params=params)
|
|
226
|
+
|
|
227
|
+
def delete(
|
|
228
|
+
self,
|
|
229
|
+
endpoint: str,
|
|
230
|
+
id: str,
|
|
231
|
+
params: Optional[Dict[str, Any]] = None
|
|
232
|
+
) -> Dict[str, Any]:
|
|
233
|
+
"""
|
|
234
|
+
Perform a DELETE request to delete a record.
|
|
235
|
+
|
|
236
|
+
Since the DELETE endpoint returns a 204 No Content response, this method
|
|
237
|
+
returns an empty dict when deletion is successful.
|
|
238
|
+
|
|
239
|
+
:param endpoint: API endpoint.
|
|
240
|
+
:param id: The identifier of the record to delete.
|
|
241
|
+
:param params: Query parameters (e.g., dryRun).
|
|
242
|
+
:return: An empty dict.
|
|
243
|
+
:raises WeclappAPIError: on request failure.
|
|
244
|
+
"""
|
|
245
|
+
params = params.copy() if params is not None else {}
|
|
246
|
+
new_endpoint = f"{endpoint}/id/{id}"
|
|
247
|
+
url = urljoin(self.base_url, new_endpoint)
|
|
248
|
+
logger.debug(f"DELETE {url} with params {params}")
|
|
249
|
+
return self._send_request("DELETE", url, params=params)
|