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.
- egauge/ctid/__init__.py +7 -0
- egauge/ctid/bit_stuffer.py +65 -0
- egauge/ctid/ctid.py +967 -0
- egauge/ctid/encoder.py +436 -0
- egauge/ctid/intel_hex_encoder.py +98 -0
- egauge/ctid/waveform.py +299 -0
- egauge/examples/data/test-ctid-decoder.raw +0 -0
- egauge/examples/test_capture.py +77 -0
- egauge/examples/test_common.py +26 -0
- egauge/examples/test_ctid.py +89 -0
- egauge/examples/test_ctid_decoder.py +93 -0
- egauge/examples/test_local.py +201 -0
- egauge/examples/test_register.py +104 -0
- egauge/loggers.py +72 -0
- egauge/pyside/__init__.py +0 -0
- egauge/pyside/ansi2html.py +112 -0
- egauge/pyside/terminal.py +295 -0
- egauge/webapi/__init__.py +34 -0
- egauge/webapi/auth.py +364 -0
- egauge/webapi/cloud/__init__.py +30 -0
- egauge/webapi/cloud/credentials.py +86 -0
- egauge/webapi/cloud/credentials_dialog.py +58 -0
- egauge/webapi/cloud/gui/credentials_dialog.py +100 -0
- egauge/webapi/cloud/serial_number.py +276 -0
- egauge/webapi/device/__init__.py +38 -0
- egauge/webapi/device/capture.py +453 -0
- egauge/webapi/device/ctid_info.py +553 -0
- egauge/webapi/device/device.py +349 -0
- egauge/webapi/device/local.py +268 -0
- egauge/webapi/device/physical_quantity.py +439 -0
- egauge/webapi/device/physical_units.py +473 -0
- egauge/webapi/device/register.py +338 -0
- egauge/webapi/device/register_row.py +145 -0
- egauge/webapi/device/register_type.py +851 -0
- egauge/webapi/device/slop.py +334 -0
- egauge/webapi/device/virtual_register.py +353 -0
- egauge/webapi/error.py +34 -0
- egauge/webapi/json_api.py +332 -0
- egauge_python-0.9.8.dist-info/METADATA +148 -0
- egauge_python-0.9.8.dist-info/RECORD +44 -0
- egauge_python-0.9.8.dist-info/WHEEL +5 -0
- egauge_python-0.9.8.dist-info/entry_points.txt +2 -0
- egauge_python-0.9.8.dist-info/licenses/LICENSE +22 -0
- 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
|