hardpy 0.12.0__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 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(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:
@@ -4,48 +4,26 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from datetime import datetime, timedelta, timezone
7
- from enum import Enum
7
+ from http import HTTPStatus
8
8
  from logging import getLogger
9
- from typing import TYPE_CHECKING, NamedTuple
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.token_storage import get_token_store
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 StandCLoud loader.
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._url: StandCloudURL = StandCloudURL(
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 url(self) -> StandCloudURL:
83
- """Get StandCloud URL."""
84
- return self._url
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 # type: ignore
160
+ raise StandCloudError(exc.strerror) from exc # type: ignore
116
161
 
117
162
  return resp
118
163
 
119
- def _token_update(self, token: BearerToken) -> None:
120
- storage_keyring, mem_keyring = get_token_store()
121
-
122
- _access_token = "access_token" # noqa: S105
123
- _expires_at = "expires_at"
124
- _refresh_token = "refresh_token" # noqa: S105
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
- def _get_refresh_token(self) -> str | None:
163
- (storage_keyring, _) = get_token_store()
171
+ Args:
172
+ response (dict): verification response
173
+ waiting_time_m (int): authorization waiting time in minutes
164
174
 
165
- refresh_token = storage_keyring.get_password("HardPy", "refresh_token")
166
- self._log.debug("Got refresh token from the storage keyring")
167
- return refresh_token
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
- token = self._get_token()
171
- client_id = "hardpy-report-uploader"
172
-
173
- extra = {
174
- "client_id": client_id,
175
- "audience": self._url.api,
176
- "redirect_uri": "http://localhost/oauth2/callback",
177
- }
178
-
179
- session = OAuth2Session(
180
- client_id,
181
- token=token.as_dict(),
182
- token_updater=self._token_update,
183
- )
184
-
185
- is_need_refresh = False
186
- early_refresh = timedelta(seconds=30)
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