pyrelukko 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.

Potentially problematic release.


This version of pyrelukko might be problematic. Click here for more details.

pyrelukko/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """
2
+ Relukko client.
3
+ """
4
+ from .pyrelukko import RelukkoClient
pyrelukko/pyrelukko.py ADDED
@@ -0,0 +1,332 @@
1
+ """
2
+ TBD
3
+ """
4
+ import asyncio
5
+ import logging
6
+ import os
7
+ import json
8
+ import threading
9
+ import ssl
10
+ import time
11
+
12
+ from datetime import datetime
13
+ from typing import Dict, List, Union
14
+ from pathlib import Path
15
+ from urllib3.util import Url, parse_url
16
+ from urllib3.util.retry import Retry
17
+
18
+ import requests
19
+ from websockets import ConnectionClosed as WsConnectionClosed
20
+
21
+ from websockets.asyncio.client import connect as ws_connect
22
+
23
+ from .retry import retry
24
+
25
+ SSL_KWARGS = [
26
+ 'check_hostname',
27
+ 'hostname_checks_common_name',
28
+ 'verify_mode',
29
+ 'verify_flags',
30
+ 'options',
31
+ ]
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class RelukkoDoRetry(Exception):
37
+ """
38
+ Exception thrown on some errors which we still want to retry
39
+ """
40
+
41
+ # pylint: disable=too-many-instance-attributes
42
+ class RelukkoClient:
43
+ """
44
+ TBD
45
+ """
46
+ def __init__(
47
+ self, base_url: Union[Url, str], api_key:str, **kwargs) -> None:
48
+ """
49
+ TBD
50
+ """
51
+ self.session = requests.Session()
52
+ self.api_key = api_key
53
+ self._setup_session(api_key, **kwargs)
54
+ self._setup_http_adapters_retry(**kwargs)
55
+
56
+ self.base_url = self._setup_base_url(base_url)
57
+ self.ws_url = self._setup_ws_url(str(self.base_url))
58
+ self.ssl_ctx = None
59
+ self._setup_ssl_ctx(**kwargs)
60
+
61
+ # event for websocket thread to signal it got a message
62
+ self.message_received = threading.Event()
63
+ # As long as it's set the websocket thread runs
64
+ self.ws_running = threading.Event()
65
+ self.ws_listener: threading.Thread = None
66
+
67
+ def reconfigure_relukko(
68
+ self, base_url: Union[Url, str]=None, api_key: str=None, **kwargs):
69
+ """
70
+ TBD
71
+ """
72
+ self.api_key = api_key or self.api_key
73
+ self._setup_session(self.api_key, **kwargs)
74
+ self._setup_http_adapters_retry(**kwargs)
75
+ self.base_url = self._setup_base_url(base_url or self.base_url)
76
+ self.ws_url = self._setup_ws_url(str(self.base_url))
77
+ self._setup_ssl_ctx(**kwargs)
78
+
79
+ def _setup_http_adapters_retry(self, **kwargs):
80
+ for _, http_adapter in self.session.adapters.items():
81
+ http_retry: Retry = http_adapter.max_retries
82
+ for key, value in kwargs.items():
83
+ if hasattr(http_retry, key):
84
+ setattr(http_retry, key, value)
85
+ http_adapter.max_retries = http_retry
86
+
87
+ def _setup_session(self, api_key: str, **kwargs):
88
+ self.session.headers = {'X-api-Key': api_key}
89
+ for key, value in kwargs.items():
90
+ if hasattr(self.session, key):
91
+ setattr(self.session, key, value)
92
+
93
+ def _setup_ssl_ctx(self, **kwargs) -> Union[ssl.SSLContext, None]:
94
+ if self.ws_url.scheme == "wss":
95
+ if self.ssl_ctx is None:
96
+ self.ssl_ctx = ssl.create_default_context(
97
+ ssl.Purpose.SERVER_AUTH)
98
+ for kwarg in SSL_KWARGS:
99
+ setattr(
100
+ self.ssl_ctx,
101
+ kwarg,
102
+ kwargs.get(kwarg, getattr(self.ssl_ctx, kwarg)))
103
+
104
+ # Try to behave like requests library and take *_CA_BUNDLE env vars
105
+ # into account.
106
+ ca_bundle = (
107
+ os.environ.get("REQUESTS_CA_BUNDLE")
108
+ or os.environ.get("CURL_CA_BUNDLE"))
109
+
110
+ ca_bundle_file = None
111
+ ca_bundle_path = None
112
+ if ca_bundle is not None:
113
+ ca_bundle = Path(ca_bundle)
114
+ ca_bundle_file = ca_bundle if ca_bundle.is_file() else None
115
+ ca_bundle_path = ca_bundle if ca_bundle.is_dir() else None
116
+
117
+ # values from kwargs take precedence env vars
118
+ ca_file = kwargs.get('cafile', ca_bundle_file)
119
+ ca_path = kwargs.get('capath', ca_bundle_path)
120
+ ca_data = kwargs.get('cadata')
121
+
122
+ if ca_file or ca_path or ca_data:
123
+ self.ssl_ctx.load_verify_locations(
124
+ cafile=ca_file, capath=ca_path, cadata=ca_data)
125
+ else:
126
+ self.ssl_ctx = None
127
+
128
+ def _setup_ws_url(self, ws_url: str) -> Url:
129
+ url = ws_url.replace("http", "ws", 1)
130
+ return parse_url(f"{url}/deletions")
131
+
132
+ def _setup_base_url(self, base_url: Union[Url, str]) -> Url:
133
+ if isinstance(base_url, str):
134
+ base_url = parse_url(base_url)
135
+ if not isinstance(base_url, Url):
136
+ raise ValueError("must be URL or string!")
137
+
138
+ return base_url
139
+
140
+ async def _websocket_listener(self):
141
+ """
142
+ The WebSocket thread, which waits for messages from Relukko and
143
+ notifies the HTTP thread in case deletions happend, so the HTTP
144
+ can retry to get the lock. Does not verify the wanted lock got
145
+ deleted yet.
146
+ """
147
+ additional_headers = { "X-API-KEY": self.api_key }
148
+ async with ws_connect(
149
+ str(self.ws_url),
150
+ additional_headers=additional_headers,
151
+ ssl=self.ssl_ctx,
152
+ logger=logger,
153
+ ) as websocket:
154
+ while self.ws_running.is_set():
155
+ try:
156
+ ws_message = await asyncio.wait_for(websocket.recv(), timeout=2)
157
+ if ws_message:
158
+ logger.debug("Received message: '%s'", ws_message)
159
+ msg: Dict = json.loads(ws_message)
160
+ if msg.get('deletions'):
161
+ # Signal the HTTP thread to wake up
162
+ self.message_received.set()
163
+ except TimeoutError:
164
+ # no messages, try in a moment again...
165
+ time.sleep(0.5)
166
+ except WsConnectionClosed:
167
+ logger.error("Lost WS connection!")
168
+ continue
169
+
170
+ def _acquire_relukko(
171
+ self, url: Union[Url, str], max_run_time: int,
172
+ payload: Dict, _thread_store: List):
173
+ """
174
+ The HTTP thread which tries to create the Relukko lock.
175
+ """
176
+
177
+ start_time = time.time()
178
+
179
+ while True:
180
+ elapsed_time = time.time() - start_time
181
+ if elapsed_time > max_run_time:
182
+ self.ws_running.clear()
183
+ _thread_store.insert(0, None)
184
+ return
185
+
186
+ res = self._make_request(
187
+ url=url, method="POST", payload=payload)
188
+ if res is None:
189
+ # Conflict 409
190
+ self.message_received.wait(timeout=30)
191
+ self.message_received.clear()
192
+ else:
193
+ _thread_store.insert(0, res)
194
+ self.ws_running.clear()
195
+ return
196
+
197
+ def _check_response(self, response: requests.Response):
198
+ match response.status_code:
199
+ case 200 | 201 | 404:
200
+ return response.json()
201
+ case 400 | 403:
202
+ err = response.json()
203
+ logger.warning(err.get('status'), err.get('message'))
204
+ response.raise_for_status()
205
+ case 409:
206
+ err = response.json()
207
+ logger.info(err.get('status'), err.get('message'))
208
+ return None
209
+ case 500 | 502 | 503 | 504:
210
+ logger.warning(response.status_code, response.text)
211
+ raise RelukkoDoRetry()
212
+ case _:
213
+ logger.warning(response.status_code, response.text)
214
+ raise RuntimeError()
215
+
216
+ def _make_request(
217
+ self,
218
+ url: str,
219
+ method: str,
220
+ payload: Dict=None) -> requests.Response:
221
+
222
+ excpetions = (
223
+ requests.ConnectionError,
224
+ RelukkoDoRetry,
225
+ )
226
+
227
+ @retry(logger, exceptions=excpetions, delay=10)
228
+ def _do_request():
229
+ response = self.session.request(
230
+ method=method,
231
+ url=url,
232
+ json=payload,
233
+ )
234
+ return self._check_response(response)
235
+
236
+ return _do_request()
237
+
238
+ def acquire_relukko(self, lock_name, creator, max_run_time):
239
+ """
240
+ TBD
241
+ """
242
+ payload = {
243
+ "lock_name": lock_name,
244
+ "creator": creator,
245
+ }
246
+
247
+ url = f"{self.base_url}/v1/locks/"
248
+
249
+ self.ws_running.set()
250
+ self.ws_listener = threading.Thread(
251
+ target=asyncio.run, args=(self._websocket_listener(),))
252
+ self.ws_listener.start()
253
+
254
+ thread_store = []
255
+ http_thread = threading.Thread(
256
+ target=self._acquire_relukko, args=(url, max_run_time, payload, thread_store))
257
+ http_thread.start()
258
+ http_thread.join()
259
+ self.ws_listener.join()
260
+
261
+ return thread_store[0]
262
+
263
+ def get_lock(self, lock_id: str) -> Dict:
264
+ """
265
+ TBD
266
+ """
267
+ url = f"{self.base_url}/v1/locks/{lock_id}"
268
+ return self._make_request(url, "GET")
269
+
270
+ def get_locks(self) -> List:
271
+ """
272
+ TBD
273
+ """
274
+ url = f"{self.base_url}/v1/locks/"
275
+ return self._make_request(url, "GET")
276
+
277
+ def update_relukko(self, lock_id: str, creator: str, expires_at: datetime):
278
+ """
279
+ TBD
280
+ """
281
+ if isinstance(expires_at, datetime):
282
+ expires_at = expires_at.isoformat()
283
+ else:
284
+ raise ValueError("has to be datetime!")
285
+
286
+ payload = {
287
+ "creator": creator,
288
+ "expires_at": expires_at,
289
+ }
290
+ url = f"{self.base_url}/v1/locks/{lock_id}"
291
+ return self._make_request(url, "POST", payload)
292
+
293
+ def delete_relukko(self, lock_id: str):
294
+ """
295
+ TBD
296
+ """
297
+ url = f"{self.base_url}/v1/locks/{lock_id}"
298
+ return self._make_request(url, "DELETE")
299
+
300
+ def keep_relukko_alive(self, lock_id: str):
301
+ """
302
+ TBD
303
+ """
304
+ url = f"{self.base_url}/v1/locks/{lock_id}/keep_alive"
305
+ return self._make_request(url, "GET")
306
+
307
+ def keep_relukko_alive_put(self, lock_id: str, seconds: int):
308
+ """
309
+ TBD
310
+ """
311
+ url = f"{self.base_url}/v1/locks/{lock_id}/keep_alive"
312
+ payload = {
313
+ "seconds": seconds
314
+ }
315
+ return self._make_request(url, "PUT", payload)
316
+
317
+ def add_to_expires_at_time(self, lock_id: str):
318
+ """
319
+ TBD
320
+ """
321
+ url = f"{self.base_url}/v1/locks/{lock_id}/add_to_expire_at"
322
+ return self._make_request(url, "GET")
323
+
324
+ def add_to_expires_at_time_put(self, lock_id: str, seconds: int):
325
+ """
326
+ TBD
327
+ """
328
+ url = f"{self.base_url}/v1/locks/{lock_id}/add_to_expire_at"
329
+ payload = {
330
+ "seconds": seconds
331
+ }
332
+ return self._make_request(url, "PUT", payload)
pyrelukko/retry.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ TBD
3
+ """
4
+ import time
5
+ from functools import wraps
6
+
7
+
8
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
9
+ def retry(logger, exceptions, tries=4, delay=5,
10
+ backoff=2.0, max_delay=None):
11
+ """
12
+ Retry calling the decorated function using an exponential backoff.
13
+ https://www.calazan.com/retry-decorator-for-python-3/
14
+
15
+ :param exceptions: The exception to check. may be a tuple of
16
+ exceptions to check.
17
+ :param tries: Number of times to try (not retry) before giving up.
18
+ :param delay: Initial delay between retries in seconds.
19
+ :param backoff: Backoff multiplier (e.g. value of 2 will double the delay
20
+ each retry).
21
+ :param max_delay: maximum value for delay
22
+ """
23
+ def deco_retry(f):
24
+
25
+ @wraps(f)
26
+ def f_retry(*args, **kwargs):
27
+ remaining_tries, retry_delay = tries, delay
28
+ while remaining_tries > 1:
29
+ try:
30
+ return f(*args, **kwargs)
31
+ except exceptions:
32
+ remaining_tries -= 1
33
+ logger.warning('(%i/%i): Retrying in %i seconds...',
34
+ tries - remaining_tries,
35
+ tries,
36
+ retry_delay,
37
+ )
38
+ time.sleep(retry_delay)
39
+ if max_delay is not None:
40
+ retry_delay = min(retry_delay*backoff, max_delay)
41
+ else:
42
+ retry_delay *= backoff
43
+ return f(*args, **kwargs)
44
+ return f_retry # true decorator
45
+ return deco_retry
pyrelukko/version.py ADDED
@@ -0,0 +1,2 @@
1
+ # pylint: disable=all
2
+ __version__ = "0.1.0"
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Reto Zingg <g.d0b3rm4n@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyrelukko
3
+ Version: 0.1.0
4
+ Summary: Relukko client.
5
+ Author-email: Reto Zingg <g.d0b3rm4n@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Topic :: Internet :: WWW/HTTP
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Requires-Dist: requests >=2.32.3
14
+ Project-URL: Homepage, https://gitlab.com/relukko/pyrelukko
15
+ Project-URL: Issues, https://gitlab.com/relukko/pyrelukko/-/issues
16
+
17
+ # PyRelukko
18
+ Python library to access a
19
+ [Relukko back-end](https://gitlab.com/relukko/relukko).
20
+
@@ -0,0 +1,8 @@
1
+ pyrelukko/__init__.py,sha256=n-mTJV7AOfr4bljtDxok3E3gXIBRk-kFa2_Ql3S8Ht0,61
2
+ pyrelukko/pyrelukko.py,sha256=xW97JA3Za7cg0K0P8cu134sDs7i-wM75vB-Wh1CkWqw,10646
3
+ pyrelukko/retry.py,sha256=XeCc0trPhAggdEnu2nwtpFderlHaDQFTXH_O1dLeiGQ,1587
4
+ pyrelukko/version.py,sha256=AaOhlz8bhe74XhrpzHuwnyjVm0cDxwlXlZGQ3Qvzs0o,44
5
+ pyrelukko-0.1.0.dist-info/LICENSE,sha256=aEI4dThWmRUiEPch0-KaZQmHZgTyuz88Edmp25H6tAw,1089
6
+ pyrelukko-0.1.0.dist-info/WHEEL,sha256=CpUCUxeHQbRN5UGRQHYRJorO5Af-Qy_fHMctcQ8DSGI,82
7
+ pyrelukko-0.1.0.dist-info/METADATA,sha256=G7uhJSin6c3wyWUj5HSdcOyP9pCfIoco_P2Ulw0WY9o,701
8
+ pyrelukko-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.10.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any