hardpy 0.12.0__tar.gz → 0.13.0__tar.gz
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.
- {hardpy-0.12.0 → hardpy-0.13.0}/PKG-INFO +2 -1
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/cli/cli.py +16 -11
- hardpy-0.13.0/hardpy/common/stand_cloud/connector.py +228 -0
- hardpy-0.13.0/hardpy/common/stand_cloud/oauth2.py +88 -0
- hardpy-0.13.0/hardpy/common/stand_cloud/registration.py +75 -0
- hardpy-0.13.0/hardpy/common/stand_cloud/token_manager.py +119 -0
- hardpy-0.13.0/hardpy/common/stand_cloud/utils.py +33 -0
- hardpy-0.12.0/hardpy/hardpy_panel/frontend/dist/assets/allPaths-B26356fZ.js → hardpy-0.13.0/hardpy/hardpy_panel/frontend/dist/assets/allPaths-Cg7WZDXy.js +1 -1
- hardpy-0.12.0/hardpy/hardpy_panel/frontend/dist/assets/allPathsLoader-0BeGWuiy.js → hardpy-0.13.0/hardpy/hardpy_panel/frontend/dist/assets/allPathsLoader-C79wUwqR.js +2 -2
- hardpy-0.12.0/hardpy/hardpy_panel/frontend/dist/assets/index-Bl_IX0Up.js → hardpy-0.13.0/hardpy/hardpy_panel/frontend/dist/assets/index-De5CJ3kt.js +2 -2
- hardpy-0.12.0/hardpy/hardpy_panel/frontend/dist/assets/splitPathsBySizeLoader-BEs5IL5-.js → hardpy-0.13.0/hardpy/hardpy_panel/frontend/dist/assets/splitPathsBySizeLoader-hWuLTMwD.js +1 -1
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/index.html +1 -1
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/plugin.py +48 -24
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/pytest_call.py +7 -3
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/reporter/base.py +8 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/reporter/hook_reporter.py +40 -5
- {hardpy-0.12.0 → hardpy-0.13.0}/pyproject.toml +2 -1
- hardpy-0.12.0/hardpy/common/stand_cloud/connector.py +0 -221
- hardpy-0.12.0/hardpy/common/stand_cloud/oauth_callback.py +0 -95
- hardpy-0.12.0/hardpy/common/stand_cloud/registration.py +0 -236
- hardpy-0.12.0/hardpy/common/stand_cloud/token_storage.py +0 -27
- {hardpy-0.12.0 → hardpy-0.13.0}/.gitignore +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/LICENSE +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/README.md +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/cli/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/cli/template.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/common/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/common/config.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/common/stand_cloud/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/common/stand_cloud/exception.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/api.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-Bfs1BwbR.ttf +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-Btb8d-Hu.woff +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-CzsyEoPG.svg +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-DrH54W_x.woff2 +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-RCDSkC4W.eot +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-BGGGsqDJ.ttf +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-D9WO2FSG.woff2 +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-Doom1bSH.eot +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-DyVnGNfQ.svg +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-ZW-9JnPf.woff +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/index-BMEat_ws.js +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/index-BwCQzehg.css +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/index-xb4M2ucX.js +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/logo_smol-CK3jE85c.png +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/favicon.ico +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/logo192.png +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/logo512.png +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/manifest.json +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/base_connector.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/base_server.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/base_store.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/const.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/runstore.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/schema/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/schema/v1.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/statestore.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/pytest_wrapper.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/reporter/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/reporter/runner_reporter.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/couchdb_config.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_loader/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_loader/couchdb_loader.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_loader/stand_cloud_loader.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_reader/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_reader/couchdb_reader.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_reader/stand_cloud_reader.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/__init__.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/connection_data.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/const.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/dialog_box.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/exception.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/machineid.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/node_info.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/progress_calculator.py +0 -0
- {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/singleton.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hardpy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: HardPy library for device testing
|
|
5
5
|
Project-URL: Homepage, https://github.com/everypinio/hardpy/
|
|
6
6
|
Project-URL: Documentation, https://everypinio.github.io/hardpy/
|
|
@@ -34,6 +34,7 @@ Requires-Dist: py-machineid~=0.6.0
|
|
|
34
34
|
Requires-Dist: pycouchdb<2,>=1.14.2
|
|
35
35
|
Requires-Dist: pydantic<3,>=2.4.0
|
|
36
36
|
Requires-Dist: pytest<9,>=7
|
|
37
|
+
Requires-Dist: qrcode>=8
|
|
37
38
|
Requires-Dist: requests-oauth2client<2,>=1.5.0
|
|
38
39
|
Requires-Dist: requests-oauthlib<3,>=2.0.0
|
|
39
40
|
Requires-Dist: requests<3,>=2.30.0
|
|
@@ -227,12 +227,13 @@ def sc_login(
|
|
|
227
227
|
address (str): StandCloud address
|
|
228
228
|
check (bool): Check StandCloud connection
|
|
229
229
|
"""
|
|
230
|
+
try:
|
|
231
|
+
sc_connector = StandCloudConnector(address)
|
|
232
|
+
except StandCloudError as exc:
|
|
233
|
+
print(str(exc))
|
|
234
|
+
sys.exit()
|
|
235
|
+
|
|
230
236
|
if check:
|
|
231
|
-
try:
|
|
232
|
-
sc_connector = StandCloudConnector(address)
|
|
233
|
-
except StandCloudError as exc:
|
|
234
|
-
print(str(exc))
|
|
235
|
-
sys.exit()
|
|
236
237
|
try:
|
|
237
238
|
sc_connector.healthcheck()
|
|
238
239
|
except StandCloudError:
|
|
@@ -240,16 +241,20 @@ def sc_login(
|
|
|
240
241
|
sys.exit()
|
|
241
242
|
print("StandCloud connection success")
|
|
242
243
|
else:
|
|
243
|
-
auth_login(
|
|
244
|
+
auth_login(sc_connector)
|
|
244
245
|
|
|
245
246
|
|
|
246
247
|
@cli.command()
|
|
247
|
-
def sc_logout() -> None:
|
|
248
|
-
"""Logout HardPy from
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
def sc_logout(address: Annotated[str, typer.Argument()]) -> None:
|
|
249
|
+
"""Logout HardPy from StandCloud account.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
address (str): StandCloud address
|
|
253
|
+
"""
|
|
254
|
+
if auth_logout(address):
|
|
255
|
+
print(f"HardPy logout success from {address}")
|
|
251
256
|
else:
|
|
252
|
-
print("HardPy logout failed")
|
|
257
|
+
print(f"HardPy logout failed from {address}")
|
|
253
258
|
|
|
254
259
|
|
|
255
260
|
def _get_config(tests_dir: str | None = None) -> HardpyConfig:
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Copyright (c) 2024 Everypin
|
|
2
|
+
# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from logging import getLogger
|
|
9
|
+
from time import sleep
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
|
|
14
|
+
from requests.exceptions import RequestException
|
|
15
|
+
from requests_oauth2client import ApiClient, BearerToken
|
|
16
|
+
from requests_oauth2client.tokens import ExpiredAccessToken
|
|
17
|
+
|
|
18
|
+
from hardpy.common.stand_cloud.exception import StandCloudError
|
|
19
|
+
from hardpy.common.stand_cloud.oauth2 import OAuth2
|
|
20
|
+
from hardpy.common.stand_cloud.token_manager import TokenManager
|
|
21
|
+
from hardpy.common.stand_cloud.utils import StandCloudAPIMode, StandCloudAddr
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from requests import Response
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StandCloudConnector:
|
|
28
|
+
"""StandCloud API connector."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
addr: str,
|
|
33
|
+
api_mode: StandCloudAPIMode = StandCloudAPIMode.HARDPY,
|
|
34
|
+
api_version: int = 1,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Create StandCloud API connector.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
addr (str): StandCloud service name.
|
|
40
|
+
api_mode (StandCloudAPIMode): StandCloud API mode,
|
|
41
|
+
hardpy for test stand, integration for third-party service.
|
|
42
|
+
Default: StandCloudAPIMode.HARDPY.
|
|
43
|
+
api_version (int): StandCloud API version.
|
|
44
|
+
Default: 1.
|
|
45
|
+
"""
|
|
46
|
+
https_prefix = "https://"
|
|
47
|
+
auth_addr = addr + "/auth"
|
|
48
|
+
|
|
49
|
+
self._addr: StandCloudAddr = StandCloudAddr(
|
|
50
|
+
domain=addr,
|
|
51
|
+
api=https_prefix + addr + f"/{api_mode.value}/api/v{api_version}",
|
|
52
|
+
token=https_prefix + auth_addr + "/api/oidc/token",
|
|
53
|
+
auth=https_prefix + auth_addr + "/api/oidc/authorization",
|
|
54
|
+
device=https_prefix + auth_addr + "/api/oidc/device-authorization",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
self._client_id = "hardpy-report-uploader"
|
|
58
|
+
self._verify_ssl = not __debug__
|
|
59
|
+
self._token_manager = TokenManager(self._addr.domain)
|
|
60
|
+
self._token: BearerToken = self.get_access_token()
|
|
61
|
+
self._log = getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def addr(self) -> str:
|
|
65
|
+
"""Get StandCloud service name."""
|
|
66
|
+
return self._addr.domain
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def api_url(self) -> str:
|
|
70
|
+
"""Get StandCloud API URL."""
|
|
71
|
+
return self._addr.api
|
|
72
|
+
|
|
73
|
+
def update_token(self, token: BearerToken) -> None:
|
|
74
|
+
"""Update access token.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
token (BearerToken): access token.
|
|
78
|
+
"""
|
|
79
|
+
self._token = token
|
|
80
|
+
|
|
81
|
+
def is_refresh_token_valid(self) -> bool:
|
|
82
|
+
"""Check if token is valid.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
bool: True if token is valid, False otherwise.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
OAuth2(
|
|
89
|
+
sc_addr=self._addr,
|
|
90
|
+
client_id=self._client_id,
|
|
91
|
+
token=self._token,
|
|
92
|
+
token_manager=self._token_manager,
|
|
93
|
+
verify_ssl=self._verify_ssl,
|
|
94
|
+
)
|
|
95
|
+
except OAuth2Error:
|
|
96
|
+
return False
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_access_token(self) -> BearerToken | None:
|
|
101
|
+
"""Read access token from token store.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
BearerToken: access token
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
return self._token_manager.read_access_token()
|
|
108
|
+
except Exception: # noqa: BLE001
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def get_verification_url(self) -> dict:
|
|
112
|
+
"""Get StandCloud verification URL.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
dict: verification URL
|
|
116
|
+
"""
|
|
117
|
+
req = requests.post(
|
|
118
|
+
self._addr.device,
|
|
119
|
+
data={
|
|
120
|
+
"client_id": self._client_id,
|
|
121
|
+
"scope": "offline_access authelia.bearer.authz",
|
|
122
|
+
"audience": self._addr.api,
|
|
123
|
+
},
|
|
124
|
+
verify=self._verify_ssl,
|
|
125
|
+
timeout=10,
|
|
126
|
+
)
|
|
127
|
+
if req.status_code != HTTPStatus.OK:
|
|
128
|
+
raise StandCloudError(req.text)
|
|
129
|
+
return json.loads(req.content)
|
|
130
|
+
|
|
131
|
+
def get_api(self, endpoint: str) -> ApiClient:
|
|
132
|
+
"""Get StandCloud API client.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
endpoint (str): endpoint address.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
ApiClient: API clinet
|
|
139
|
+
"""
|
|
140
|
+
return self._get_api(endpoint)
|
|
141
|
+
|
|
142
|
+
def healthcheck(self) -> Response:
|
|
143
|
+
"""Healthcheck of StandCloud API.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Response: healthcheck response
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
StandCloudError: if StandCloud is unavailable
|
|
150
|
+
"""
|
|
151
|
+
api = self._get_api("healthcheck")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
resp = api.get(verify=self._verify_ssl)
|
|
155
|
+
except ExpiredAccessToken as exc:
|
|
156
|
+
raise StandCloudError(str(exc)) from exc
|
|
157
|
+
except OAuth2Error as exc:
|
|
158
|
+
raise StandCloudError(exc.description) from exc
|
|
159
|
+
except RequestException as exc:
|
|
160
|
+
raise StandCloudError(exc.strerror) from exc # type: ignore
|
|
161
|
+
|
|
162
|
+
return resp
|
|
163
|
+
|
|
164
|
+
def wait_verification(
|
|
165
|
+
self,
|
|
166
|
+
response: dict,
|
|
167
|
+
waiting_time_m: int = 5,
|
|
168
|
+
) -> BearerToken | None:
|
|
169
|
+
"""Wait StandCloud verification.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
response (dict): verification response
|
|
173
|
+
waiting_time_m (int): authorization waiting time in minutes
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
BearerToken: access token
|
|
177
|
+
"""
|
|
178
|
+
interval = response["interval"]
|
|
179
|
+
start_time = datetime.now(tz=timezone.utc)
|
|
180
|
+
device_code = response["device_code"]
|
|
181
|
+
while True:
|
|
182
|
+
sleep(interval)
|
|
183
|
+
|
|
184
|
+
req = requests.post(
|
|
185
|
+
self._addr.token,
|
|
186
|
+
data={
|
|
187
|
+
"client_id": self._client_id,
|
|
188
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
189
|
+
"device_code": device_code,
|
|
190
|
+
},
|
|
191
|
+
verify=self._verify_ssl,
|
|
192
|
+
timeout=interval,
|
|
193
|
+
)
|
|
194
|
+
response = json.loads(req.content)
|
|
195
|
+
|
|
196
|
+
if "error" in response:
|
|
197
|
+
current_time = datetime.now(tz=timezone.utc)
|
|
198
|
+
if current_time >= start_time + timedelta(minutes=waiting_time_m):
|
|
199
|
+
return None
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
if "access_token" in response and "refresh_token" in response:
|
|
203
|
+
new_token = BearerToken(**response)
|
|
204
|
+
|
|
205
|
+
if "expires_at" not in response and new_token.expires_at:
|
|
206
|
+
response["expires_at"] = new_token.expires_at.timestamp()
|
|
207
|
+
self._token_manager.save_token_info(response)
|
|
208
|
+
return new_token
|
|
209
|
+
|
|
210
|
+
def _get_api(self, endpoint: str) -> ApiClient:
|
|
211
|
+
if self._token is None:
|
|
212
|
+
msg = (
|
|
213
|
+
f"Access token to {self._addr.domain} is not set."
|
|
214
|
+
f"Login to {self._addr.domain} first"
|
|
215
|
+
)
|
|
216
|
+
raise StandCloudError(msg)
|
|
217
|
+
try:
|
|
218
|
+
auth = OAuth2(
|
|
219
|
+
sc_addr=self._addr,
|
|
220
|
+
client_id=self._client_id,
|
|
221
|
+
token=self._token,
|
|
222
|
+
token_manager=self._token_manager,
|
|
223
|
+
verify_ssl=self._verify_ssl,
|
|
224
|
+
)
|
|
225
|
+
except OAuth2Error as exc:
|
|
226
|
+
raise StandCloudError(exc.description) from exc
|
|
227
|
+
session = auth.session
|
|
228
|
+
return ApiClient(f"{self._addr.api}/{endpoint}", session=session, timeout=10)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright (c) 2025 Everypin
|
|
2
|
+
# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from requests.auth import AuthBase
|
|
10
|
+
from requests_oauth2client import BearerToken
|
|
11
|
+
from requests_oauthlib import OAuth2Session
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from requests import PreparedRequest
|
|
15
|
+
from requests_oauth2client import ApiClient
|
|
16
|
+
|
|
17
|
+
from hardpy.common.stand_cloud.token_manager import TokenManager
|
|
18
|
+
from hardpy.common.stand_cloud.utils import StandCloudAddr
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OAuth2(AuthBase):
|
|
22
|
+
"""Authorize HardPy using the device flow of OAuth 2.0."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
sc_addr: StandCloudAddr,
|
|
27
|
+
client_id: str,
|
|
28
|
+
token: BearerToken,
|
|
29
|
+
token_manager: TokenManager,
|
|
30
|
+
verify_ssl: bool = True,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._sc_addr = sc_addr
|
|
33
|
+
self._client_id = client_id
|
|
34
|
+
self._verify_ssl = verify_ssl
|
|
35
|
+
self._token_manager = token_manager
|
|
36
|
+
|
|
37
|
+
self._token = self._check_token(token)
|
|
38
|
+
|
|
39
|
+
def __call__(self, req: PreparedRequest) -> PreparedRequest:
|
|
40
|
+
"""Append an OAuth 2 token to the request.
|
|
41
|
+
|
|
42
|
+
Note that currently HTTPS is required for all requests. There may be
|
|
43
|
+
a token type that allows for plain HTTP in the future and then this
|
|
44
|
+
should be updated to allow plain HTTP on a white list basis.
|
|
45
|
+
"""
|
|
46
|
+
req.headers[self._token.AUTHORIZATION_HEADER] = ( # type: ignore
|
|
47
|
+
self._token.authorization_header()
|
|
48
|
+
)
|
|
49
|
+
return req
|
|
50
|
+
|
|
51
|
+
def _check_token(self, token: BearerToken) -> ApiClient:
|
|
52
|
+
"""Check token in OAuth2 session and refresh it if needed.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
token (BearerToken): bearer token to check
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
ApiClient: refreshed token
|
|
59
|
+
"""
|
|
60
|
+
refresh_url = self._sc_addr.token
|
|
61
|
+
|
|
62
|
+
self.session = OAuth2Session(
|
|
63
|
+
client_id=self._client_id,
|
|
64
|
+
token=token.as_dict(),
|
|
65
|
+
token_updater=self._token_manager.save_token_info,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
is_need_refresh = False
|
|
69
|
+
early_refresh = timedelta(seconds=60)
|
|
70
|
+
|
|
71
|
+
if token.expires_in and token.expires_in < early_refresh.seconds:
|
|
72
|
+
is_need_refresh = True
|
|
73
|
+
if token.access_token is None:
|
|
74
|
+
is_need_refresh = True
|
|
75
|
+
|
|
76
|
+
if is_need_refresh:
|
|
77
|
+
extra = {"client_id": self._client_id, "audience": self._sc_addr.api}
|
|
78
|
+
ret = self.session.refresh_token(
|
|
79
|
+
token_url=refresh_url,
|
|
80
|
+
refresh_token=self._token_manager.read_refresh_token(),
|
|
81
|
+
verify=self._verify_ssl,
|
|
82
|
+
**extra,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self._token_manager.save_token_info(ret)
|
|
86
|
+
return BearerToken(**ret)
|
|
87
|
+
|
|
88
|
+
return token
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Copyright (c) 2025 Everypin
|
|
2
|
+
# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from io import StringIO
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
|
|
11
|
+
from qrcode import QRCode
|
|
12
|
+
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
|
|
13
|
+
from requests_oauth2client.tokens import ExpiredAccessToken
|
|
14
|
+
|
|
15
|
+
from hardpy.common.stand_cloud.exception import StandCloudError
|
|
16
|
+
from hardpy.common.stand_cloud.token_manager import TokenManager
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from hardpy.common.stand_cloud.connector import StandCloudConnector
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def login(sc_connector: StandCloudConnector) -> None:
|
|
23
|
+
"""Login HardPy in StandCloud.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
sc_connector (StandCloudConnector): StandCloud connector
|
|
27
|
+
"""
|
|
28
|
+
token = sc_connector.get_access_token()
|
|
29
|
+
if token is None or sc_connector.is_refresh_token_valid() is False:
|
|
30
|
+
try:
|
|
31
|
+
response = sc_connector.get_verification_url()
|
|
32
|
+
except (RequestConnectionError, StandCloudError) as e:
|
|
33
|
+
print(f"Connection error to StandCloud: {sc_connector.addr}. \nError: {e}")
|
|
34
|
+
return
|
|
35
|
+
url = response["verification_uri_complete"]
|
|
36
|
+
_print_user_action_request(url)
|
|
37
|
+
token = sc_connector.wait_verification(response)
|
|
38
|
+
|
|
39
|
+
sc_connector.update_token(token)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
sc_connector.healthcheck()
|
|
43
|
+
except ExpiredAccessToken as e:
|
|
44
|
+
time_diff = timedelta(seconds=abs(e.args[0].expires_in))
|
|
45
|
+
print(f"API access error: token is expired for {time_diff}")
|
|
46
|
+
except OAuth2Error as e:
|
|
47
|
+
print(e.description)
|
|
48
|
+
except HTTPError as e:
|
|
49
|
+
print(e)
|
|
50
|
+
else:
|
|
51
|
+
print(f"HardPy login to {sc_connector.addr} success")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def logout(addr: str) -> bool:
|
|
55
|
+
"""Logout HardPy from StandCloud.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
addr (str): StandCloud address
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
bool: True if successful else False
|
|
62
|
+
"""
|
|
63
|
+
token_manager = TokenManager(addr)
|
|
64
|
+
return token_manager.remove_token()
|
|
65
|
+
|
|
66
|
+
def _print_user_action_request(url_complete: str) -> None:
|
|
67
|
+
qr = QRCode()
|
|
68
|
+
qr.add_data(url_complete)
|
|
69
|
+
f = StringIO()
|
|
70
|
+
qr.print_ascii(out=f)
|
|
71
|
+
f.seek(0)
|
|
72
|
+
|
|
73
|
+
print(f.read())
|
|
74
|
+
print("Scan the QR code or, using a browser on another device, visit:")
|
|
75
|
+
print(url_complete, end="\n\n")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Copyright (c) 2025 Everypin
|
|
2
|
+
# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from platform import system
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from keyring import delete_password, get_credential
|
|
14
|
+
from keyring.core import load_keyring
|
|
15
|
+
from keyring.errors import KeyringError
|
|
16
|
+
from requests_oauth2client import BearerToken
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from keyring.backend import KeyringBackend
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TokenManager:
|
|
23
|
+
"""Token manager.
|
|
24
|
+
|
|
25
|
+
Manage token in keyring storage.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, service_name: str) -> None:
|
|
29
|
+
self._service_name = f"HardPy_{service_name}"
|
|
30
|
+
|
|
31
|
+
def remove_token(self) -> bool:
|
|
32
|
+
"""Remove token from keyring storage.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
bool: True if successful else False
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
while cred := get_credential(self._service_name, None):
|
|
39
|
+
delete_password(self._service_name, cred.username)
|
|
40
|
+
except KeyringError:
|
|
41
|
+
return False
|
|
42
|
+
# TODO(xorialexandrov): fix keyring clearing
|
|
43
|
+
# Windows does not clear refresh token by itself
|
|
44
|
+
if system() == "Windows":
|
|
45
|
+
storage_keyring, _ = self._get_store()
|
|
46
|
+
with suppress(KeyringError):
|
|
47
|
+
storage_keyring.delete_password(self._service_name, "refresh_token")
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def save_token_info(self, token: BearerToken | dict) -> None:
|
|
51
|
+
"""Save token to keyring storage.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
token (BearerToken | dict): token
|
|
55
|
+
"""
|
|
56
|
+
# fmt: off
|
|
57
|
+
storage_keyring, mem_keyring = self._get_store()
|
|
58
|
+
storage_keyring.set_password(self._service_name, "refresh_token", token["refresh_token"]) # noqa: E501
|
|
59
|
+
|
|
60
|
+
token_info = deepcopy(token)
|
|
61
|
+
token_info.pop("expires_in")
|
|
62
|
+
token_info.pop("refresh_token")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
mem_keyring.set_password(self._service_name, "access_token", json.dumps(token_info)) # noqa: E501
|
|
66
|
+
except KeyringError as e:
|
|
67
|
+
print(e) # noqa: T201
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
# fmt: on
|
|
70
|
+
|
|
71
|
+
def read_access_token(self) -> BearerToken:
|
|
72
|
+
"""Read access token from token store.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
BearerToken: access token
|
|
76
|
+
"""
|
|
77
|
+
_, mem_keyring = self._get_store()
|
|
78
|
+
token_info = mem_keyring.get_password(self._service_name, "access_token")
|
|
79
|
+
secret = self._add_expires_in(json.loads(token_info)) # type: ignore
|
|
80
|
+
return BearerToken(**secret)
|
|
81
|
+
|
|
82
|
+
def read_refresh_token(self) -> str | None:
|
|
83
|
+
"""Read refresh token from token store.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
str | None: refresh token
|
|
87
|
+
"""
|
|
88
|
+
storage_keyring, _ = self._get_store()
|
|
89
|
+
service_name = self._service_name
|
|
90
|
+
return storage_keyring.get_password(service_name, "refresh_token")
|
|
91
|
+
|
|
92
|
+
def _get_store(self) -> tuple[KeyringBackend, KeyringBackend]:
|
|
93
|
+
"""Get token store.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
tuple[KeyringBackend, KeyringBackend]: token store
|
|
97
|
+
"""
|
|
98
|
+
if system() == "Linux":
|
|
99
|
+
storage_keyring = load_keyring("keyring.backends.SecretService.Keyring")
|
|
100
|
+
elif system() == "Windows":
|
|
101
|
+
storage_keyring = load_keyring("keyring.backends.Windows.WinVaultKeyring")
|
|
102
|
+
# TODO(xorialexandrov): add memory keyring or other store
|
|
103
|
+
mem_keyring = storage_keyring
|
|
104
|
+
|
|
105
|
+
return storage_keyring, mem_keyring
|
|
106
|
+
|
|
107
|
+
def _add_expires_in(self, secret: dict) -> dict:
|
|
108
|
+
if "expires_at" in secret:
|
|
109
|
+
expires_at = secret["expires_at"]
|
|
110
|
+
elif "id_token" in secret and "exp" in secret["id_token"]:
|
|
111
|
+
expires_at = secret["id_token"]["exp"]
|
|
112
|
+
expires_at_datetime = datetime.fromtimestamp(expires_at, timezone.utc)
|
|
113
|
+
|
|
114
|
+
expires_in_datetime = expires_at_datetime - datetime.now(timezone.utc)
|
|
115
|
+
expires_in = int(expires_in_datetime.total_seconds())
|
|
116
|
+
|
|
117
|
+
secret["expires_in"] = expires_in
|
|
118
|
+
|
|
119
|
+
return secret
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright (c) 2024 Everypin
|
|
2
|
+
# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StandCloudAddr(NamedTuple):
|
|
10
|
+
"""StandCloud address.
|
|
11
|
+
|
|
12
|
+
domain: StandCloud address
|
|
13
|
+
api: API address
|
|
14
|
+
token: token address
|
|
15
|
+
auth: auth address
|
|
16
|
+
device: device-authorization address
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
domain: str
|
|
20
|
+
api: str
|
|
21
|
+
token: str
|
|
22
|
+
auth: str
|
|
23
|
+
device: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StandCloudAPIMode(str, Enum):
|
|
27
|
+
"""StandCloud API mode.
|
|
28
|
+
|
|
29
|
+
HARDPY for test stand, integration for third-party service.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
HARDPY = "hardpy"
|
|
33
|
+
INTEGRATION = "integration"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{I as n}from"./index-xb4M2ucX.js";import{I as e}from"./index-BMEat_ws.js";import{p as r,I as s}from"./index-
|
|
1
|
+
import{I as n}from"./index-xb4M2ucX.js";import{I as e}from"./index-BMEat_ws.js";import{p as r,I as s}from"./index-De5CJ3kt.js";function I(o,t){var a=r(o);return t===s.STANDARD?n[a]:e[a]}function p(o){return r(o)}export{n as IconSvgPaths16,e as IconSvgPaths20,I as getIconPaths,p as iconNameToPathsRecordKey};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/allPaths-
|
|
2
|
-
import{_ as o,a as n,b as i}from"./index-
|
|
1
|
+
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/allPaths-Cg7WZDXy.js","assets/index-xb4M2ucX.js","assets/index-BMEat_ws.js","assets/index-De5CJ3kt.js","assets/index-BwCQzehg.css"])))=>i.map(i=>d[i]);
|
|
2
|
+
import{_ as o,a as n,b as i}from"./index-De5CJ3kt.js";var _=function(e,a){return o(void 0,void 0,void 0,function(){var t;return n(this,function(r){switch(r.label){case 0:return[4,i(()=>import("./allPaths-Cg7WZDXy.js"),__vite__mapDeps([0,1,2,3,4]))];case 1:return t=r.sent().getIconPaths,[2,t(e,a)]}})})};export{_ as allPathsLoader};
|