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.
@@ -1,236 +1,75 @@
1
- # Copyright (c) 2024 Everypin
1
+ # Copyright (c) 2025 Everypin
2
2
  # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- import base64
7
- import hashlib
8
- import json
9
- import os
10
- import re
11
- import secrets
12
- import socket
13
- import subprocess
14
- import sys
15
- from contextlib import suppress
16
- from platform import system
17
- from time import sleep
6
+ from datetime import timedelta
7
+ from io import StringIO
18
8
  from typing import TYPE_CHECKING
19
- from urllib.parse import urlencode
20
9
 
21
- import requests
22
- from keyring import delete_password, get_credential
23
- from keyring.errors import KeyringError
24
- from oauthlib.common import urldecode
25
- from oauthlib.oauth2 import WebApplicationClient
26
- from oauthlib.oauth2.rfc6749.errors import InvalidClientIdError
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
27
14
 
28
- from hardpy.common.stand_cloud.connector import StandCloudConnector, StandCloudError
29
- from hardpy.common.stand_cloud.token_storage import get_token_store
15
+ from hardpy.common.stand_cloud.exception import StandCloudError
16
+ from hardpy.common.stand_cloud.token_manager import TokenManager
30
17
 
31
18
  if TYPE_CHECKING:
32
- from requests_oauth2client import BearerToken
19
+ from hardpy.common.stand_cloud.connector import StandCloudConnector
33
20
 
34
21
 
35
- def login(addr: str) -> None:
22
+ def login(sc_connector: StandCloudConnector) -> None:
36
23
  """Login HardPy in StandCloud.
37
24
 
38
25
  Args:
39
- addr (str): StandCloud address
26
+ sc_connector (StandCloudConnector): StandCloud connector
40
27
  """
41
- # OAuth client configuration
42
- client_id = "hardpy-report-uploader"
43
- client = WebApplicationClient(client_id)
44
- try:
45
- sc_connector = StandCloudConnector(addr=addr)
46
- except StandCloudError as exc:
47
- print(str(exc))
48
- sys.exit(1)
49
-
50
- verify_ssl = not __debug__
51
-
52
- # Auth requests
53
- port = _reserve_socket_port()
54
- callback_process = _create_callback_process(port)
55
- state = secrets.token_urlsafe(16)
56
- code_verifier = _code_verifier()
57
- data = _par_data(code_verifier, client_id, port, state, sc_connector.url.api)
58
- timeout = 10
59
-
60
- # fmt: off
61
- # pushed authorization response
62
- try:
63
- response = json.loads(requests.post(sc_connector.url.par, data=data, verify=verify_ssl, timeout=timeout).content)
64
- except Exception as e: # noqa: BLE001
65
- print(f"Authentication server is unavailable: {e}")
66
- sys.exit(1)
67
- url = (sc_connector.url.auth + "?" + urlencode({"client_id": client_id, "request_uri": response["request_uri"]}))
68
-
69
- # OAuth authorization code request
70
- print(f"\nOpen the provided URL and authorize HardPy to use StandCloud\n\n{url}")
71
-
72
- # use subprocess
73
- first_line = next(callback_process.stdout) # type: ignore
74
- sleep(1)
75
-
76
- # OAuth authorization code grant
77
- response = json.loads(first_line)
78
- callback_process.kill()
79
-
80
- _check_incorrect_response(response, state)
81
-
82
- code = response["code"]
83
- uri = _redirect_uri(port)
84
- data = client.prepare_request_body(code=code, client_id=client_id, redirect_uri=uri, code_verifier=code_verifier)
85
-
86
- # OAuth access token request
87
- data = dict(urldecode(data))
88
- response = requests.post(sc_connector.url.token, data=data, verify=verify_ssl, timeout=timeout)
89
- # fmt: on
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)
90
40
 
91
41
  try:
92
- # OAuth access token grant
93
- response = client.parse_request_body_response(response.text)
94
- except InvalidClientIdError as e:
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:
95
49
  print(e)
96
- sys.exit(1)
97
-
98
- _store_password(client.token)
50
+ else:
51
+ print(f"HardPy login to {sc_connector.addr} success")
99
52
 
100
53
 
101
- def logout() -> bool:
54
+ def logout(addr: str) -> bool:
102
55
  """Logout HardPy from StandCloud.
103
56
 
104
- Returns:
105
- bool: True if successful else False
106
- """
107
- service_name = "HardPy"
108
-
109
- try:
110
- while cred := get_credential(service_name, None):
111
- delete_password(service_name, cred.username)
112
- except KeyringError:
113
- return False
114
- # TODO(xorialexandrov): fix keyring clearing
115
- # Windows does not clear refresh token by itself
116
- if system() == "Windows":
117
- storage_keyring, _ = get_token_store()
118
- with suppress(KeyringError):
119
- storage_keyring.delete_password(service_name, "refresh_token")
120
- return True
121
-
122
-
123
- def _redirect_uri(port: str) -> str:
124
- return f"http://127.0.0.1:{port}/oauth2/callback"
125
-
126
-
127
- def _reserve_socket_port() -> str:
128
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
129
- sock.bind(("127.0.0.1", 0))
130
- port = sock.getsockname()[1]
131
- sock.close()
132
-
133
- return str(port)
134
-
135
-
136
- def _code_verifier() -> str:
137
- code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8")
138
- return re.sub("[^a-zA-Z0-9]+", "", code_verifier)
139
-
140
-
141
- def _code_challenge(code_verifier: str) -> str:
142
- code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest()
143
- code_challenge = base64.urlsafe_b64encode(code_challenge).decode("utf-8")
144
- return code_challenge.replace("=", "")
145
-
146
-
147
- def _create_callback_process(port: str) -> subprocess.Popen:
148
- args = [
149
- sys.executable,
150
- "-m",
151
- "uvicorn",
152
- "hardpy.common.stand_cloud.oauth_callback:app",
153
- "--host=127.0.0.1",
154
- f"--port={port}",
155
- "--log-level=error",
156
- ]
157
-
158
- if system() == "Windows":
159
- env = os.environ.copy()
160
- env["PYTHONUNBUFFERED"] = "1"
161
-
162
- return subprocess.Popen( # noqa: S603
163
- args,
164
- stdout=subprocess.PIPE,
165
- bufsize=1,
166
- universal_newlines=True,
167
- env=env,
168
- creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, # type: ignore
169
- )
170
- return subprocess.Popen( # noqa: S603
171
- args,
172
- stdout=subprocess.PIPE,
173
- bufsize=1,
174
- universal_newlines=True,
175
- env=dict(PYTHONUNBUFFERED="1"), # noqa: C408
176
- )
177
-
178
-
179
- def _store_password(token: BearerToken) -> None:
180
- storage_keyring, mem_keyring = get_token_store()
181
-
182
- storage_keyring.set_password(
183
- "HardPy",
184
- "refresh_token",
185
- token["refresh_token"],
186
- )
187
- token_data = {
188
- "access_token": token["access_token"],
189
- "expires_at": token["expires_at"],
190
- }
191
- try:
192
- mem_keyring.set_password("HardPy", "access_token", json.dumps(token_data))
193
- except KeyringError as e:
194
- print(e)
195
- return
196
- print("\nRegistration completed successfully")
197
-
198
-
199
- def _check_incorrect_response(response: dict, state: str) -> None:
200
- if response["state"] != state:
201
- print("Wrong state in response")
202
- sys.exit(1)
203
-
204
- if "error" in response:
205
- error = response["error"]
206
- error_description = response["error_description"]
207
- print(f"{error}: {error_description}")
208
- sys.exit(1)
209
-
210
-
211
- def _par_data(
212
- code_verifier: str,
213
- client_id: str,
214
- port: str,
215
- state: str,
216
- api_url: str,
217
- ) -> dict:
218
- """Create pushed authorization request data.
57
+ Args:
58
+ addr (str): StandCloud address
219
59
 
220
60
  Returns:
221
- dict: pushed authorization request data
61
+ bool: True if successful else False
222
62
  """
223
- # Code Challenge Data
224
- code_challenge = _code_challenge(code_verifier)
225
-
226
- # pushed authorization request
227
- return {
228
- "scope": "authelia.bearer.authz offline_access",
229
- "response_type": "code",
230
- "client_id": client_id,
231
- "redirect_uri": _redirect_uri(port),
232
- "code_challenge": code_challenge,
233
- "code_challenge_method": "S256",
234
- "state": state,
235
- "audience": api_url,
236
- }
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};