egauge-python 0.9.8__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.
Files changed (44) hide show
  1. egauge/ctid/__init__.py +7 -0
  2. egauge/ctid/bit_stuffer.py +65 -0
  3. egauge/ctid/ctid.py +967 -0
  4. egauge/ctid/encoder.py +436 -0
  5. egauge/ctid/intel_hex_encoder.py +98 -0
  6. egauge/ctid/waveform.py +299 -0
  7. egauge/examples/data/test-ctid-decoder.raw +0 -0
  8. egauge/examples/test_capture.py +77 -0
  9. egauge/examples/test_common.py +26 -0
  10. egauge/examples/test_ctid.py +89 -0
  11. egauge/examples/test_ctid_decoder.py +93 -0
  12. egauge/examples/test_local.py +201 -0
  13. egauge/examples/test_register.py +104 -0
  14. egauge/loggers.py +72 -0
  15. egauge/pyside/__init__.py +0 -0
  16. egauge/pyside/ansi2html.py +112 -0
  17. egauge/pyside/terminal.py +295 -0
  18. egauge/webapi/__init__.py +34 -0
  19. egauge/webapi/auth.py +364 -0
  20. egauge/webapi/cloud/__init__.py +30 -0
  21. egauge/webapi/cloud/credentials.py +86 -0
  22. egauge/webapi/cloud/credentials_dialog.py +58 -0
  23. egauge/webapi/cloud/gui/credentials_dialog.py +100 -0
  24. egauge/webapi/cloud/serial_number.py +276 -0
  25. egauge/webapi/device/__init__.py +38 -0
  26. egauge/webapi/device/capture.py +453 -0
  27. egauge/webapi/device/ctid_info.py +553 -0
  28. egauge/webapi/device/device.py +349 -0
  29. egauge/webapi/device/local.py +268 -0
  30. egauge/webapi/device/physical_quantity.py +439 -0
  31. egauge/webapi/device/physical_units.py +473 -0
  32. egauge/webapi/device/register.py +338 -0
  33. egauge/webapi/device/register_row.py +145 -0
  34. egauge/webapi/device/register_type.py +851 -0
  35. egauge/webapi/device/slop.py +334 -0
  36. egauge/webapi/device/virtual_register.py +353 -0
  37. egauge/webapi/error.py +34 -0
  38. egauge/webapi/json_api.py +332 -0
  39. egauge_python-0.9.8.dist-info/METADATA +148 -0
  40. egauge_python-0.9.8.dist-info/RECORD +44 -0
  41. egauge_python-0.9.8.dist-info/WHEEL +5 -0
  42. egauge_python-0.9.8.dist-info/entry_points.txt +2 -0
  43. egauge_python-0.9.8.dist-info/licenses/LICENSE +22 -0
  44. egauge_python-0.9.8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,295 @@
1
+ #
2
+ # Copyright (c) 2014, 2016-2017, 2020, 2025 eGauge Systems LLC
3
+ # 1644 Conestoga St, Suite 2
4
+ # Boulder, CO 80301
5
+ # voice: 720-545-9767
6
+ # email: davidm@egauge.net
7
+ #
8
+ # All rights reserved.
9
+ #
10
+ # MIT License
11
+ #
12
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ # of this software and associated documentation files (the "Software"), to deal
14
+ # in the Software without restriction, including without limitation the rights
15
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ # copies of the Software, and to permit persons to whom the Software is
17
+ # furnished to do so, subject to the following conditions:
18
+ #
19
+ # The above copyright notice and this permission notice shall be included in
20
+ # all copies or substantial portions of the Software.
21
+ #
22
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
28
+ # THE SOFTWARE.
29
+ #
30
+ """Basic terminal emulation for QT."""
31
+
32
+ import os
33
+ import re
34
+
35
+ from PySide6.QtGui import QCursor, QTextCursor
36
+ from PySide6.QtWidgets import QApplication
37
+
38
+ from . import ansi2html
39
+
40
+ CLEAR_LINE_PATTERN = re.compile(r".*\033\[2K")
41
+ CURSOR_REPORT_PATTERN = re.compile(r"\033\[6n")
42
+ # ANSI CSI sequence: ESC [ followed by any number of parameter bytes in
43
+ # range 0x30-0x3f, followed by any number of intermediate bytes in
44
+ # range 0x20-0x2f, followed by a single final byte in range 0x40-0x74:
45
+ INCOMPLETE_ANSI_CSI_PATTERN = re.compile(r"\033(\[([0-?]*[ -/]*)?)?")
46
+
47
+
48
+ def split_keepends(string: str, pattern: re.Pattern) -> list[str]:
49
+ res = []
50
+ while len(string) > 0:
51
+ m = re.search(pattern, string)
52
+ if m:
53
+ last_matched = m.end(0)
54
+ res.append(string[0:last_matched])
55
+ string = string[last_matched:]
56
+ else:
57
+ res.append(string)
58
+ break
59
+ return res
60
+
61
+
62
+ def incomplete_ansi_csi(string: str) -> bool:
63
+ """Return True if the string forms a partial ANSI CSI ESCape sequence,
64
+ False otherwise.
65
+
66
+ """
67
+ m = INCOMPLETE_ANSI_CSI_PATTERN.match(string)
68
+ if m is None:
69
+ return False
70
+ return m.end() == len(string)
71
+
72
+
73
+ class Terminal:
74
+ """Provides basic terminal emulation for a QT plain text widget."""
75
+
76
+ def __init__(self, plain_text_edit):
77
+ if "FORCE_COLOR" not in os.environ:
78
+ os.environ["FORCE_COLOR"] = "1"
79
+
80
+ self.plain_text_edit = plain_text_edit
81
+ self.partial_line = ""
82
+
83
+ def write(self, string: str):
84
+ """Write a string to the terminal.
85
+
86
+ The string may consist of multiple lines.
87
+
88
+ Required arguments:
89
+
90
+ string -- The string to write to the terminal.
91
+
92
+ """
93
+
94
+ if len(string) == 0:
95
+ return
96
+
97
+ string = self.partial_line + string
98
+ self.partial_line = ""
99
+
100
+ # if the string ends with an incomplete ANSI CSI sequence,
101
+ # hold off processing the partial escape sequence until it's
102
+ # complete but process the text up to that point immediately:
103
+ for m in re.finditer(r"\033", string):
104
+ if incomplete_ansi_csi(string[m.start() :]):
105
+ self.partial_line = string[m.start() :]
106
+ string = string[0 : m.start()]
107
+ break
108
+ if len(self.partial_line) == 0 and string[-1] == "\r":
109
+ # if the string ends with a carriage-return, hold off
110
+ # processing the carriage return until we get the next
111
+ # character:
112
+ self.partial_line = "\r"
113
+ string = string[:-1]
114
+
115
+ lines = split_keepends(string, re.compile("(\r\n|\n|\r|\b)"))
116
+ for line in lines:
117
+ # position cursor at end of text (in case someone clicked in
118
+ # the middle of the text):
119
+ cursor = self.plain_text_edit.textCursor()
120
+ cursor.movePosition(QTextCursor.MoveOperation.End)
121
+ self.plain_text_edit.setTextCursor(cursor)
122
+
123
+ back_space = False
124
+ line_feed = False
125
+ txt = line
126
+ if len(txt) >= 1:
127
+ if txt[-1] == "\b":
128
+ back_space = True
129
+ txt = txt[:-1]
130
+ elif txt[-1] == "\r":
131
+ txt = ""
132
+ cursor.movePosition(
133
+ QTextCursor.MoveOperation.StartOfBlock,
134
+ QTextCursor.MoveMode.KeepAnchor,
135
+ )
136
+ cursor.removeSelectedText()
137
+ elif len(txt) >= 2 and txt[-2] == "\r" and txt[-1] == "\n":
138
+ txt = txt[:-2]
139
+ line_feed = True
140
+ elif txt[-1] == "\n":
141
+ txt = txt[:-1]
142
+ line_feed = True
143
+ txt = CURSOR_REPORT_PATTERN.sub("", txt)
144
+ m = CLEAR_LINE_PATTERN.match(txt)
145
+ if m:
146
+ txt = txt[m.end() :]
147
+ cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
148
+ cursor.movePosition(
149
+ QTextCursor.MoveOperation.EndOfBlock,
150
+ QTextCursor.MoveMode.KeepAnchor,
151
+ )
152
+ cursor.removeSelectedText()
153
+
154
+ if len(txt) > 0:
155
+ # Unfortunately, the "<pre>" seems to for the font to
156
+ # "Courier New" but without "<pre>" whitespace is not
157
+ # preserved by insertHtml. Argh...
158
+ # Use qt5ct to configure the "Fixed width" font which will
159
+ # be used for <pre>.
160
+ html = "<pre>" + ansi2html.convert(txt) + "</pre>"
161
+ cursor.insertHtml(html)
162
+ if back_space:
163
+ cursor.deletePreviousChar()
164
+ QApplication.processEvents()
165
+ elif line_feed:
166
+ # Each appendHtml() writes a separate line. Newline
167
+ # characters are ignored.
168
+ self.plain_text_edit.appendHtml("")
169
+ self.plain_text_edit.centerCursor()
170
+
171
+ def flush(self):
172
+ """Flush pending output."""
173
+
174
+
175
+ def test_incomplete_ansi_csi(seq: str):
176
+ print("Testing: " + seq)
177
+ CSI = "\033["
178
+ csi_seq = CSI + seq
179
+ for l in range(len(csi_seq) - 1):
180
+ if not incomplete_ansi_csi(csi_seq[0 : l + 1]):
181
+ print(f"Error: CSI test sequence {seq}, l={l} returned False")
182
+ if incomplete_ansi_csi(csi_seq):
183
+ print(f"Error: CSI test sequence {seq} returned True")
184
+
185
+
186
+ def test():
187
+ if incomplete_ansi_csi("hi there"):
188
+ print('Error: CSI test sequence "hi there" returned True')
189
+ if incomplete_ansi_csi("x\033[s"):
190
+ print('Error: CSI test sequence "xCSIs" returned True')
191
+ test_incomplete_ansi_csi("6n")
192
+ test_incomplete_ansi_csi("16;34H") # set cursor position
193
+ test_incomplete_ansi_csi("s") # save cursor position
194
+
195
+ import sys
196
+
197
+ from PySide6 import QtCore, QtWidgets
198
+ from PySide6.QtCore import QBasicTimer
199
+ from PySide6.QtWidgets import QApplication, QMainWindow
200
+
201
+ class Ui_MainWindow:
202
+ def setupUi(self, MainWindow):
203
+ MainWindow.setObjectName("Terminal Test")
204
+ MainWindow.resize(908, 480)
205
+ self.centralwidget = QtWidgets.QWidget(MainWindow)
206
+ self.centralwidget.setObjectName("centralwidget")
207
+ self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
208
+ self.verticalLayout.setObjectName("verticalLayout")
209
+ self.plainTextEdit = QtWidgets.QPlainTextEdit(self.centralwidget)
210
+ self.plainTextEdit.setObjectName("plainTextEdit")
211
+ self.plainTextEdit.viewport().setProperty(
212
+ "cursor", QCursor(QtCore.Qt.CursorShape.IBeamCursor)
213
+ )
214
+ self.plainTextEdit.setMouseTracking(False)
215
+ self.plainTextEdit.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus)
216
+ self.plainTextEdit.setAcceptDrops(False)
217
+ self.plainTextEdit.setStyleSheet(
218
+ "background-color: black;\ncolor: white;\n"
219
+ )
220
+ self.plainTextEdit.setLineWidth(1)
221
+ self.plainTextEdit.setUndoRedoEnabled(False)
222
+ self.plainTextEdit.setLineWrapMode(
223
+ QtWidgets.QPlainTextEdit.LineWrapMode.WidgetWidth
224
+ )
225
+ self.plainTextEdit.setReadOnly(True)
226
+ self.plainTextEdit.setTabStopDistance(80)
227
+ self.plainTextEdit.setCursorWidth(16)
228
+ self.plainTextEdit.setBackgroundVisible(False)
229
+ self.plainTextEdit.setCenterOnScroll(False)
230
+ self.verticalLayout.addWidget(self.plainTextEdit)
231
+ MainWindow.setCentralWidget(self.centralwidget)
232
+
233
+ self.retranslateUi(MainWindow)
234
+ QtCore.QMetaObject.connectSlotsByName(MainWindow)
235
+
236
+ def retranslateUi(self, MainWindow):
237
+ pass
238
+
239
+ class UI(QMainWindow, Ui_MainWindow):
240
+ def __init__(self):
241
+ QMainWindow.__init__(self)
242
+ self.setupUi(window)
243
+
244
+ self.console = Terminal(self.plainTextEdit)
245
+ self.console.write("\033")
246
+ self.console.write("[")
247
+ self.console.write("6")
248
+ self.console.write("n")
249
+ self.console.write("\033[6nHi There!\033[6n\n")
250
+
251
+ self.console.write(" \t8 blanks and a tab before me\n")
252
+ self.console.write("8 blanks and a tab after me> \t")
253
+ self.console.write("<\n")
254
+ self.console.write("8 blanks \tand a tab\n")
255
+
256
+ self.console.write("GOT mo")
257
+ self.console.write("del\r")
258
+ self.console.write("\n")
259
+
260
+ self.console.write("Feeling")
261
+ self.console.write(" a ")
262
+ self.console.write("little \033[44mblue\033[0m?\r\n")
263
+
264
+ self.console.write("80 columns:\n")
265
+ self.console.write("*" * 80 + "\n")
266
+
267
+ self.console.write("132 columns:\n")
268
+ self.console.write("*" * 132 + "\n")
269
+
270
+ self.console.write("partial line")
271
+ self.console.write("\nthis should be on a new line\n")
272
+
273
+ # test spinner:
274
+ self.console.write("Wait a little: x\bX")
275
+
276
+ self.spinner_pos = 0
277
+ self.timer = QBasicTimer()
278
+ self.timer.start(100, self)
279
+
280
+ def timerEvent(self, event):
281
+ if event.timerId() != self.timer.timerId():
282
+ super(UI, self).timerEvent(event)
283
+
284
+ progress_ch = r"-\|/"[self.spinner_pos % 4]
285
+ self.console.write("\b" + progress_ch)
286
+ self.spinner_pos += 1
287
+ if self.spinner_pos >= 16:
288
+ self.timer.stop()
289
+ self.console.write("\bdone with that...\n")
290
+
291
+ app = QApplication(sys.argv)
292
+ window = QMainWindow()
293
+ _ = UI()
294
+ window.show()
295
+ sys.exit(app.exec())
@@ -0,0 +1,34 @@
1
+ #
2
+ # Copyright (c) 2020 eGauge Systems LLC
3
+ # 1644 Conestoga St, Suite 2
4
+ # Boulder, CO 80301
5
+ # voice: 720-545-9767
6
+ # email: davidm@egauge.net
7
+ #
8
+ # All rights reserved.
9
+ #
10
+ # MIT License
11
+ #
12
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ # of this software and associated documentation files (the "Software"), to deal
14
+ # in the Software without restriction, including without limitation the rights
15
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ # copies of the Software, and to permit persons to whom the Software is
17
+ # furnished to do so, subject to the following conditions:
18
+ #
19
+ # The above copyright notice and this permission notice shall be included in
20
+ # all copies or substantial portions of the Software.
21
+ #
22
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
28
+ # THE SOFTWARE.
29
+ #
30
+ from .auth import * # NOQA
31
+ from .error import * # NOQA
32
+ from .json_api import * # NOQA
33
+ from . import cloud # NOQA
34
+ from . import device # NOQA
egauge/webapi/auth.py ADDED
@@ -0,0 +1,364 @@
1
+ #
2
+ # Copyright (c) 2020-2022, 2024-2025 eGauge Systems LLC
3
+ # 4805 Sterling Dr, Suite 1
4
+ # Boulder, CO 80301
5
+ # voice: 720-545-9767
6
+ # email: davidm@egauge.net
7
+ #
8
+ # All rights reserved.
9
+ #
10
+ # MIT License
11
+ #
12
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ # of this software and associated documentation files (the "Software"), to deal
14
+ # in the Software without restriction, including without limitation the rights
15
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ # copies of the Software, and to permit persons to whom the Software is
17
+ # furnished to do so, subject to the following conditions:
18
+ #
19
+ # The above copyright notice and this permission notice shall be included in
20
+ # all copies or substantial portions of the Software.
21
+ #
22
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
28
+ # THE SOFTWARE.
29
+ #
30
+ """This module provides support additional requests auth services, in
31
+ particular for JWT-token based authentication (JWTAuth) and for plain
32
+ token-based authentication (TokenAuth).
33
+
34
+ """
35
+
36
+ import hashlib
37
+ import os
38
+ import secrets
39
+ import types
40
+ from functools import wraps
41
+ from pathlib import Path
42
+ from urllib.parse import urlparse
43
+
44
+ import requests
45
+ from requests.auth import AuthBase
46
+
47
+ from egauge.loggers import ModuleLogger
48
+
49
+ from . import json_api
50
+
51
+ # The name of the optional environment variable storing a token:
52
+ ENV_EGAUGE_API_TOKEN = "EGAUGE_API_TOKEN"
53
+
54
+ MAX_RETRIES = 10
55
+
56
+ log = ModuleLogger.get(__name__)
57
+
58
+
59
+ def _decorate_public_metaclass(decorator):
60
+ """Return a metaclass which will decorate all public methods of a
61
+ class with the DECORATOR function.
62
+
63
+ """
64
+
65
+ class MetaClass(type):
66
+ def __new__(mcs, class_name, bases, class_dict, **kwargs):
67
+ if bases:
68
+ decorated_class = bases[0]
69
+ for attr_name, attr in decorated_class.__dict__.items():
70
+ if isinstance(attr, types.FunctionType):
71
+ if attr_name[0] == "_":
72
+ continue
73
+ attr = decorator(attr)
74
+ class_dict[attr_name] = attr
75
+ return type.__new__(mcs, class_name, bases, class_dict, **kwargs)
76
+
77
+ return MetaClass
78
+
79
+
80
+ def decorate_public(cls, decorator):
81
+ """Return a subclass of CLS in which all public methods of CLS are
82
+ decorated with function DECORATOR. Methods whose name start with
83
+ an underscore ('_') are considered private, all other methods are
84
+ considered public.
85
+
86
+ """
87
+
88
+ # pylint: disable=unused-variable
89
+ def wrapper(method):
90
+ @wraps(method)
91
+ def wrapped(*args, **kwargs):
92
+ return decorator(method, *args, *kwargs)
93
+
94
+ return wrapped
95
+
96
+ class DecoratedClass(cls, metaclass=_decorate_public_metaclass(wrapper)):
97
+ # pylint: disable=too-few-public-methods
98
+ pass
99
+
100
+ return DecoratedClass
101
+
102
+
103
+ class JWTAuth(AuthBase):
104
+ """Implements the eGauge device WebAPI's JWT-based authentication
105
+ scheme. Digest login is used so the password is never sent over
106
+ the HTTP connection.
107
+
108
+ """
109
+
110
+ def __init__(self, username: str, password: str):
111
+ self.bearer_token: str | None = None
112
+ self.username = username
113
+ self.password = password
114
+
115
+ def __call__(self, r):
116
+ if self.bearer_token:
117
+ self.add_auth_header(r)
118
+ r.register_hook("response", self.handle_response)
119
+ return r
120
+
121
+ def __eq__(self, other):
122
+ return self.username == getattr(
123
+ other, "username", None
124
+ ) and self.password == getattr(other, "password", None)
125
+
126
+ def add_auth_header(
127
+ self, req: requests.Request | requests.PreparedRequest
128
+ ):
129
+ """If we have a bearer-token, add an HTTP Authorization header
130
+ to a request.
131
+
132
+ Required arguments:
133
+
134
+ req -- The request to which to add an Authorization header.
135
+
136
+ """
137
+ if self.bearer_token:
138
+ req.headers["Authorization"] = "Bearer " + self.bearer_token
139
+
140
+ def handle_401(self, r, **kwargs):
141
+ """Called when server responds with 401 Unauthorized."""
142
+ log.debug("handle_401: auth request received for %s", r.request.url)
143
+
144
+ try:
145
+ auth_request = r.json()
146
+ except ValueError:
147
+ log.debug("handle_401: auth request is not valid JSON")
148
+ return r
149
+
150
+ realm = auth_request["rlm"]
151
+ server_nonce = auth_request["nnc"]
152
+
153
+ client_nonce = f"{secrets.randbits(64):x}"
154
+
155
+ content = self.username + ":" + realm + ":" + self.password
156
+ ha1 = hashlib.md5(content.encode("utf-8")).hexdigest()
157
+
158
+ content = ha1 + ":" + server_nonce + ":" + client_nonce
159
+ ha2 = hashlib.md5(content.encode("utf-8")).hexdigest()
160
+
161
+ data = {
162
+ "rlm": realm,
163
+ "usr": self.username,
164
+ "nnc": server_nonce,
165
+ "cnnc": client_nonce,
166
+ "hash": ha2,
167
+ }
168
+
169
+ url = urlparse(r.request.url)
170
+ login_uri = url.scheme + "://" + url.netloc + "/api/auth/login"
171
+ verify = kwargs.get("verify", True)
172
+ auth_r = json_api.post(
173
+ login_uri, data, timeout=60, verify=verify, raw_response=True
174
+ )
175
+
176
+ # to keep pyright happy (shouldn't be possible):
177
+ if not isinstance(auth_r, requests.Response):
178
+ log.debug("handle_401: login attempt failed")
179
+ return r
180
+
181
+ if auth_r.status_code != 200:
182
+ log.debug(
183
+ "handle_401: login response status %d", auth_r.status_code
184
+ )
185
+ return auth_r
186
+
187
+ try:
188
+ auth_reply = auth_r.json()
189
+ except requests.JSONDecodeError:
190
+ log.debug("handle_401: login response is invalid")
191
+ return auth_r
192
+
193
+ if not isinstance(auth_reply, dict):
194
+ log.debug("handle_401: login reply is not a dict")
195
+ return r
196
+
197
+ err = auth_reply.get("error")
198
+ if err:
199
+ if err != "Nonce expired.":
200
+ log.debug("handle_401: login reply error: %s", err)
201
+ return auth_r
202
+ log.debug("handle_401: server nonce expired - retrying")
203
+ # if the server nonce expired, retry the original request
204
+ # without a token so we get a fresh auth required response:
205
+ token = None
206
+ else:
207
+ token = auth_reply.get("jwt")
208
+ if not isinstance(token, str):
209
+ log.debug(
210
+ "handle_401: token in auth reply is not a string: %s",
211
+ token,
212
+ )
213
+ return r
214
+
215
+ self.bearer_token = token
216
+
217
+ prep = r.request.copy()
218
+ self.add_auth_header(prep)
219
+ _r = r.connection.send(prep, **kwargs)
220
+ _r.history.append(r)
221
+ _r.request = prep
222
+ return _r
223
+
224
+ def handle_response(self, r, **kwargs):
225
+ """Called when a server response is received."""
226
+ if r.status_code == 401:
227
+ for i in range(MAX_RETRIES):
228
+ r = self.handle_401(r, **kwargs)
229
+ if r.status_code != 401:
230
+ break
231
+ log.debug("handle_response: auth attempt %d failed", i + 1)
232
+ log.debug("handle_response: returning status %d", r.status_code)
233
+ return r
234
+
235
+
236
+ class TokenAuth(AuthBase):
237
+ """Implements the eGauge web services' token-based authentication
238
+ scheme. This sends the password to the server, so it must not be
239
+ used unless the underlying transport is encrypted!
240
+
241
+ """
242
+
243
+ def __init__(
244
+ self,
245
+ username=None,
246
+ password=None,
247
+ ask=None,
248
+ token_service_url="https://api.egauge.net/v1/api-token-auth/",
249
+ ):
250
+ self.username = username
251
+ self.password = password
252
+ self.ask_credentials = ask
253
+ self.token_file = None
254
+ self.token_service_url = token_service_url
255
+ self.token = os.environ.get(ENV_EGAUGE_API_TOKEN)
256
+ if self.token is None:
257
+ self.token_file = Path.home() / ".cache" / "egauge" / "api_token"
258
+ self.token = None
259
+ try:
260
+ with open(self.token_file, "r", encoding="utf-8") as f:
261
+ self.token = f.read().rstrip()
262
+ except IOError:
263
+ pass
264
+
265
+ # try old token file:
266
+ if self.token is None:
267
+ old_token_file = Path.home() / ".egauge_api_token"
268
+ try:
269
+ with open(old_token_file, "r", encoding="utf-8") as f:
270
+ self.token = f.read().rstrip()
271
+ old_token_file.unlink(missing_ok=True)
272
+ self._save_token()
273
+ except IOError:
274
+ pass
275
+
276
+ if not isinstance(self.token, str) or len(self.token) < 32:
277
+ self.token = None
278
+
279
+ def __call__(self, r):
280
+ self.add_auth_header(r)
281
+ r.register_hook("response", self.handle_response)
282
+ return r
283
+
284
+ def __eq__(self, other):
285
+ return self.username == getattr(
286
+ other, "username", None
287
+ ) and self.password == getattr(other, "password", None)
288
+
289
+ def add_auth_header(
290
+ self, req: requests.Request | requests.PreparedRequest
291
+ ):
292
+ """If we have a token, add an HTTP Authorization header to a
293
+ request.
294
+
295
+ Required arguments:
296
+
297
+ req -- The request to which to add an Authorization header.
298
+
299
+ """
300
+ if self.token:
301
+ req.headers["Authorization"] = "Token " + self.token
302
+
303
+ def handle_401(self, r, **kwargs):
304
+ """Called when server responds with 401 Unauthorized."""
305
+ usr = self.username
306
+ pwd = self.password
307
+
308
+ if usr is None or pwd is None:
309
+ if self.ask_credentials is None:
310
+ return r
311
+
312
+ credentials = self.ask_credentials()
313
+ if credentials is None:
314
+ return r
315
+ [usr, pwd] = credentials
316
+
317
+ creds = {"username": usr, "password": pwd}
318
+ verify = kwargs.get("verify", True)
319
+ auth_reply = requests.post(
320
+ self.token_service_url, json=creds, timeout=60, verify=verify
321
+ ).json()
322
+
323
+ if not isinstance(auth_reply, dict):
324
+ return r
325
+
326
+ token = auth_reply.get("token")
327
+ if not isinstance(token, str) or not token:
328
+ return r
329
+
330
+ self.token = auth_reply["token"]
331
+
332
+ if self.token_file is None:
333
+ # the original token came for the os.environ
334
+ os.environ[ENV_EGAUGE_API_TOKEN] = self.token
335
+ else:
336
+ self._save_token()
337
+
338
+ prep = r.request.copy()
339
+ self.add_auth_header(prep)
340
+ _r = r.connection.send(prep, **kwargs)
341
+ _r.history.append(r)
342
+ _r.request = prep
343
+ return _r
344
+
345
+ def handle_response(self, r, **kwargs):
346
+ """Called when a server response is received."""
347
+ if r.status_code == 401:
348
+ for _ in range(MAX_RETRIES):
349
+ r = self.handle_401(r, **kwargs)
350
+ if r.status_code != 401:
351
+ break
352
+ return r
353
+
354
+ def _save_token(self):
355
+ if self.token_file is None or self.token is None:
356
+ return
357
+
358
+ self.token_file.parent.mkdir(parents=True, exist_ok=True)
359
+ try:
360
+ fd = os.open(self.token_file, os.O_CREAT | os.O_WRONLY, 0o600)
361
+ os.write(fd, (self.token + "\n").encode("utf-8"))
362
+ os.close(fd)
363
+ except IOError:
364
+ pass