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.
Files changed (81) hide show
  1. {hardpy-0.12.0 → hardpy-0.13.0}/PKG-INFO +2 -1
  2. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/cli/cli.py +16 -11
  3. hardpy-0.13.0/hardpy/common/stand_cloud/connector.py +228 -0
  4. hardpy-0.13.0/hardpy/common/stand_cloud/oauth2.py +88 -0
  5. hardpy-0.13.0/hardpy/common/stand_cloud/registration.py +75 -0
  6. hardpy-0.13.0/hardpy/common/stand_cloud/token_manager.py +119 -0
  7. hardpy-0.13.0/hardpy/common/stand_cloud/utils.py +33 -0
  8. 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
  9. 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
  10. 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
  11. 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
  12. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/index.html +1 -1
  13. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/plugin.py +48 -24
  14. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/pytest_call.py +7 -3
  15. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/reporter/base.py +8 -0
  16. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/reporter/hook_reporter.py +40 -5
  17. {hardpy-0.12.0 → hardpy-0.13.0}/pyproject.toml +2 -1
  18. hardpy-0.12.0/hardpy/common/stand_cloud/connector.py +0 -221
  19. hardpy-0.12.0/hardpy/common/stand_cloud/oauth_callback.py +0 -95
  20. hardpy-0.12.0/hardpy/common/stand_cloud/registration.py +0 -236
  21. hardpy-0.12.0/hardpy/common/stand_cloud/token_storage.py +0 -27
  22. {hardpy-0.12.0 → hardpy-0.13.0}/.gitignore +0 -0
  23. {hardpy-0.12.0 → hardpy-0.13.0}/LICENSE +0 -0
  24. {hardpy-0.12.0 → hardpy-0.13.0}/README.md +0 -0
  25. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/__init__.py +0 -0
  26. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/cli/__init__.py +0 -0
  27. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/cli/template.py +0 -0
  28. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/common/__init__.py +0 -0
  29. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/common/config.py +0 -0
  30. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/common/stand_cloud/__init__.py +0 -0
  31. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/common/stand_cloud/exception.py +0 -0
  32. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/__init__.py +0 -0
  33. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/api.py +0 -0
  34. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-Bfs1BwbR.ttf +0 -0
  35. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-Btb8d-Hu.woff +0 -0
  36. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-CzsyEoPG.svg +0 -0
  37. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-DrH54W_x.woff2 +0 -0
  38. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-16-RCDSkC4W.eot +0 -0
  39. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-BGGGsqDJ.ttf +0 -0
  40. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-D9WO2FSG.woff2 +0 -0
  41. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-Doom1bSH.eot +0 -0
  42. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-DyVnGNfQ.svg +0 -0
  43. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/blueprint-icons-20-ZW-9JnPf.woff +0 -0
  44. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/index-BMEat_ws.js +0 -0
  45. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/index-BwCQzehg.css +0 -0
  46. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/index-xb4M2ucX.js +0 -0
  47. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/assets/logo_smol-CK3jE85c.png +0 -0
  48. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/favicon.ico +0 -0
  49. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/logo192.png +0 -0
  50. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/logo512.png +0 -0
  51. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/hardpy_panel/frontend/dist/manifest.json +0 -0
  52. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/__init__.py +0 -0
  53. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/__init__.py +0 -0
  54. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/base_connector.py +0 -0
  55. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/base_server.py +0 -0
  56. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/base_store.py +0 -0
  57. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/const.py +0 -0
  58. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/runstore.py +0 -0
  59. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/schema/__init__.py +0 -0
  60. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/schema/v1.py +0 -0
  61. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/db/statestore.py +0 -0
  62. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/pytest_wrapper.py +0 -0
  63. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/reporter/__init__.py +0 -0
  64. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/reporter/runner_reporter.py +0 -0
  65. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/__init__.py +0 -0
  66. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/couchdb_config.py +0 -0
  67. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_loader/__init__.py +0 -0
  68. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_loader/couchdb_loader.py +0 -0
  69. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_loader/stand_cloud_loader.py +0 -0
  70. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_reader/__init__.py +0 -0
  71. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_reader/couchdb_reader.py +0 -0
  72. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/result/report_reader/stand_cloud_reader.py +0 -0
  73. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/__init__.py +0 -0
  74. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/connection_data.py +0 -0
  75. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/const.py +0 -0
  76. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/dialog_box.py +0 -0
  77. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/exception.py +0 -0
  78. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/machineid.py +0 -0
  79. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/node_info.py +0 -0
  80. {hardpy-0.12.0 → hardpy-0.13.0}/hardpy/pytest_hardpy/utils/progress_calculator.py +0 -0
  81. {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.12.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(address)
244
+ auth_login(sc_connector)
244
245
 
245
246
 
246
247
  @cli.command()
247
- def sc_logout() -> None:
248
- """Logout HardPy from all StandCloud accounts."""
249
- if auth_logout():
250
- print("HardPy logout success")
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-Bl_IX0Up.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
+ 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-B26356fZ.js","assets/index-xb4M2ucX.js","assets/index-BMEat_ws.js","assets/index-Bl_IX0Up.js","assets/index-BwCQzehg.css"])))=>i.map(i=>d[i]);
2
- import{_ as o,a as n,b as i}from"./index-Bl_IX0Up.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-B26356fZ.js"),__vite__mapDeps([0,1,2,3,4]))];case 1:return t=r.sent().getIconPaths,[2,t(e,a)]}})})};export{_ as allPathsLoader};
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};