hardpy 0.12.1__py3-none-any.whl → 0.13.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.
- hardpy/cli/cli.py +16 -11
- hardpy/common/stand_cloud/connector.py +142 -135
- hardpy/common/stand_cloud/oauth2.py +88 -0
- hardpy/common/stand_cloud/registration.py +50 -211
- hardpy/common/stand_cloud/token_manager.py +119 -0
- hardpy/common/stand_cloud/utils.py +33 -0
- hardpy/hardpy_panel/frontend/dist/assets/{allPaths-B26356fZ.js → allPaths-Cg7WZDXy.js} +1 -1
- hardpy/hardpy_panel/frontend/dist/assets/{allPathsLoader-0BeGWuiy.js → allPathsLoader-C79wUwqR.js} +2 -2
- hardpy/hardpy_panel/frontend/dist/assets/{index-Bl_IX0Up.js → index-De5CJ3kt.js} +2 -2
- hardpy/hardpy_panel/frontend/dist/assets/{splitPathsBySizeLoader-BEs5IL5-.js → splitPathsBySizeLoader-hWuLTMwD.js} +1 -1
- hardpy/hardpy_panel/frontend/dist/index.html +1 -1
- hardpy/pytest_hardpy/plugin.py +47 -28
- hardpy/pytest_hardpy/pytest_call.py +7 -3
- hardpy/pytest_hardpy/reporter/base.py +8 -0
- hardpy/pytest_hardpy/reporter/hook_reporter.py +25 -8
- {hardpy-0.12.1.dist-info → hardpy-0.13.0.dist-info}/METADATA +2 -1
- {hardpy-0.12.1.dist-info → hardpy-0.13.0.dist-info}/RECORD +20 -19
- hardpy/common/stand_cloud/oauth_callback.py +0 -95
- hardpy/common/stand_cloud/token_storage.py +0 -27
- {hardpy-0.12.1.dist-info → hardpy-0.13.0.dist-info}/WHEEL +0 -0
- {hardpy-0.12.1.dist-info → hardpy-0.13.0.dist-info}/entry_points.txt +0 -0
- {hardpy-0.12.1.dist-info → hardpy-0.13.0.dist-info}/licenses/LICENSE +0 -0
hardpy/cli/cli.py
CHANGED
|
@@ -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:
|
|
@@ -4,48 +4,26 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
from datetime import datetime, timedelta, timezone
|
|
7
|
-
from
|
|
7
|
+
from http import HTTPStatus
|
|
8
8
|
from logging import getLogger
|
|
9
|
-
from
|
|
9
|
+
from time import sleep
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
10
11
|
|
|
12
|
+
import requests
|
|
11
13
|
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
|
|
12
14
|
from requests.exceptions import RequestException
|
|
13
15
|
from requests_oauth2client import ApiClient, BearerToken
|
|
14
16
|
from requests_oauth2client.tokens import ExpiredAccessToken
|
|
15
|
-
from requests_oauthlib import OAuth2Session
|
|
16
17
|
|
|
17
18
|
from hardpy.common.stand_cloud.exception import StandCloudError
|
|
18
|
-
from hardpy.common.stand_cloud.
|
|
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
|
|
19
22
|
|
|
20
23
|
if TYPE_CHECKING:
|
|
21
24
|
from requests import Response
|
|
22
25
|
|
|
23
26
|
|
|
24
|
-
class StandCloudURL(NamedTuple):
|
|
25
|
-
"""URL.
|
|
26
|
-
|
|
27
|
-
api: API address
|
|
28
|
-
token: token address
|
|
29
|
-
par: pushed-authorization-request address
|
|
30
|
-
auth: auth address
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
api: str
|
|
34
|
-
token: str
|
|
35
|
-
par: str
|
|
36
|
-
auth: str
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class StandCloudAPIMode(str, Enum):
|
|
40
|
-
"""StandCloud API mode.
|
|
41
|
-
|
|
42
|
-
HARDPY for test stand, integration for third-party service.
|
|
43
|
-
"""
|
|
44
|
-
|
|
45
|
-
HARDPY = "hardpy"
|
|
46
|
-
INTEGRATION = "integration"
|
|
47
|
-
|
|
48
|
-
|
|
49
27
|
class StandCloudConnector:
|
|
50
28
|
"""StandCloud API connector."""
|
|
51
29
|
|
|
@@ -55,7 +33,7 @@ class StandCloudConnector:
|
|
|
55
33
|
api_mode: StandCloudAPIMode = StandCloudAPIMode.HARDPY,
|
|
56
34
|
api_version: int = 1,
|
|
57
35
|
) -> None:
|
|
58
|
-
"""Create
|
|
36
|
+
"""Create StandCloud API connector.
|
|
59
37
|
|
|
60
38
|
Args:
|
|
61
39
|
addr (str): StandCloud service name.
|
|
@@ -68,20 +46,87 @@ class StandCloudConnector:
|
|
|
68
46
|
https_prefix = "https://"
|
|
69
47
|
auth_addr = addr + "/auth"
|
|
70
48
|
|
|
71
|
-
self.
|
|
49
|
+
self._addr: StandCloudAddr = StandCloudAddr(
|
|
50
|
+
domain=addr,
|
|
72
51
|
api=https_prefix + addr + f"/{api_mode.value}/api/v{api_version}",
|
|
73
52
|
token=https_prefix + auth_addr + "/api/oidc/token",
|
|
74
|
-
par=https_prefix + auth_addr + "/api/oidc/pushed-authorization-request",
|
|
75
53
|
auth=https_prefix + auth_addr + "/api/oidc/authorization",
|
|
54
|
+
device=https_prefix + auth_addr + "/api/oidc/device-authorization",
|
|
76
55
|
)
|
|
77
56
|
|
|
57
|
+
self._client_id = "hardpy-report-uploader"
|
|
78
58
|
self._verify_ssl = not __debug__
|
|
59
|
+
self._token_manager = TokenManager(self._addr.domain)
|
|
60
|
+
self._token: BearerToken = self.get_access_token()
|
|
79
61
|
self._log = getLogger(__name__)
|
|
80
62
|
|
|
81
63
|
@property
|
|
82
|
-
def
|
|
83
|
-
"""Get StandCloud
|
|
84
|
-
return self.
|
|
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)
|
|
85
130
|
|
|
86
131
|
def get_api(self, endpoint: str) -> ApiClient:
|
|
87
132
|
"""Get StandCloud API client.
|
|
@@ -112,110 +157,72 @@ class StandCloudConnector:
|
|
|
112
157
|
except OAuth2Error as exc:
|
|
113
158
|
raise StandCloudError(exc.description) from exc
|
|
114
159
|
except RequestException as exc:
|
|
115
|
-
raise StandCloudError(exc.strerror) from exc
|
|
160
|
+
raise StandCloudError(exc.strerror) from exc # type: ignore
|
|
116
161
|
|
|
117
162
|
return resp
|
|
118
163
|
|
|
119
|
-
def
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
_hardpy = "HardPy"
|
|
126
|
-
|
|
127
|
-
storage_keyring.set_password(_hardpy, _refresh_token, token[_refresh_token])
|
|
128
|
-
mem_keyring.set_password(_hardpy, _access_token, token[_access_token])
|
|
129
|
-
|
|
130
|
-
token_data = {
|
|
131
|
-
_access_token: token[_access_token],
|
|
132
|
-
_expires_at: token[_expires_at],
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
mem_keyring.set_password(_hardpy, _access_token, json.dumps(token_data))
|
|
136
|
-
|
|
137
|
-
def _get_expires_in(self, expires_at: float | None) -> int:
|
|
138
|
-
if expires_at is None:
|
|
139
|
-
return -1
|
|
140
|
-
expires_at_datetime = datetime.fromtimestamp(expires_at, timezone.utc)
|
|
141
|
-
|
|
142
|
-
now_datetime = datetime.now(timezone.utc)
|
|
143
|
-
|
|
144
|
-
expires_in_datetime = expires_at_datetime - now_datetime
|
|
145
|
-
return int(expires_in_datetime.total_seconds())
|
|
146
|
-
|
|
147
|
-
def _get_access_token_info(self) -> tuple[str | None, float | None]:
|
|
148
|
-
_, mem_keyring = get_token_store()
|
|
149
|
-
|
|
150
|
-
_access_token = "access_token" # noqa: S105
|
|
151
|
-
_expires_at = "expires_at"
|
|
152
|
-
|
|
153
|
-
token_info = mem_keyring.get_password("HardPy", _access_token)
|
|
154
|
-
if token_info is None:
|
|
155
|
-
return None, None
|
|
156
|
-
token_dict = json.loads(token_info)
|
|
157
|
-
access_token = token_dict[_access_token]
|
|
158
|
-
expired_at = token_dict[_expires_at]
|
|
159
|
-
|
|
160
|
-
return access_token, expired_at
|
|
164
|
+
def wait_verification(
|
|
165
|
+
self,
|
|
166
|
+
response: dict,
|
|
167
|
+
waiting_time_m: int = 5,
|
|
168
|
+
) -> BearerToken | None:
|
|
169
|
+
"""Wait StandCloud verification.
|
|
161
170
|
|
|
162
|
-
|
|
163
|
-
|
|
171
|
+
Args:
|
|
172
|
+
response (dict): verification response
|
|
173
|
+
waiting_time_m (int): authorization waiting time in minutes
|
|
164
174
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
168
209
|
|
|
169
210
|
def _get_api(self, endpoint: str) -> ApiClient:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if token.expires_in and token.expires_in < early_refresh.seconds:
|
|
189
|
-
is_need_refresh = True
|
|
190
|
-
|
|
191
|
-
if token.access_token is None:
|
|
192
|
-
self._log.debug("Want to refresh token since don't have access token")
|
|
193
|
-
is_need_refresh = True
|
|
194
|
-
|
|
195
|
-
if is_need_refresh:
|
|
196
|
-
try:
|
|
197
|
-
ret = session.refresh_token(
|
|
198
|
-
token_url=self._url.token,
|
|
199
|
-
refresh_token=self._get_refresh_token(),
|
|
200
|
-
verify=False,
|
|
201
|
-
**extra,
|
|
202
|
-
)
|
|
203
|
-
except OAuth2Error as exc:
|
|
204
|
-
raise StandCloudError(exc.description) from exc
|
|
205
|
-
except RequestException as exc:
|
|
206
|
-
raise StandCloudError(exc.strerror) from exc # type: ignore
|
|
207
|
-
self._token_update(ret) # type: ignore
|
|
208
|
-
|
|
209
|
-
return ApiClient(self._url.api + "/" + endpoint, session=session, timeout=10)
|
|
210
|
-
|
|
211
|
-
def _get_token(self) -> BearerToken:
|
|
212
|
-
access_token, expires_at = self._get_access_token_info()
|
|
213
|
-
expires_in = self._get_expires_in(expires_at)
|
|
214
|
-
|
|
215
|
-
return BearerToken(
|
|
216
|
-
scope=["authelia.bearer.authz", "offline_access"],
|
|
217
|
-
token_type="bearer", # noqa: S106
|
|
218
|
-
access_token=access_token,
|
|
219
|
-
expires_at=expires_at,
|
|
220
|
-
expires_in=expires_in,
|
|
221
|
-
)
|
|
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
|