hardpy 0.9.0__py3-none-any.whl → 0.10.1__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.
Files changed (31) hide show
  1. hardpy/__init__.py +4 -1
  2. hardpy/cli/cli.py +78 -11
  3. hardpy/common/config.py +16 -0
  4. hardpy/common/stand_cloud/__init__.py +13 -0
  5. hardpy/common/stand_cloud/connector.py +227 -0
  6. hardpy/common/stand_cloud/exception.py +9 -0
  7. hardpy/common/stand_cloud/oauth_callback.py +95 -0
  8. hardpy/common/stand_cloud/registration.py +236 -0
  9. hardpy/common/stand_cloud/token_storage.py +27 -0
  10. hardpy/hardpy_panel/api.py +10 -8
  11. hardpy/hardpy_panel/frontend/dist/asset-manifest.json +3 -3
  12. hardpy/hardpy_panel/frontend/dist/index.html +1 -1
  13. hardpy/hardpy_panel/frontend/dist/static/js/main.8a7d8f7d.js +3 -0
  14. hardpy/hardpy_panel/frontend/dist/static/js/main.8a7d8f7d.js.map +1 -0
  15. hardpy/pytest_hardpy/db/const.py +1 -0
  16. hardpy/pytest_hardpy/db/schema/v1.py +2 -0
  17. hardpy/pytest_hardpy/plugin.py +52 -4
  18. hardpy/pytest_hardpy/pytest_wrapper.py +10 -0
  19. hardpy/pytest_hardpy/reporter/hook_reporter.py +10 -0
  20. hardpy/pytest_hardpy/result/__init__.py +4 -0
  21. hardpy/pytest_hardpy/result/report_loader/__init__.py +4 -0
  22. hardpy/pytest_hardpy/result/report_loader/stand_cloud_loader.py +72 -0
  23. hardpy/pytest_hardpy/utils/connection_data.py +2 -0
  24. {hardpy-0.9.0.dist-info → hardpy-0.10.1.dist-info}/METADATA +8 -2
  25. {hardpy-0.9.0.dist-info → hardpy-0.10.1.dist-info}/RECORD +29 -22
  26. {hardpy-0.9.0.dist-info → hardpy-0.10.1.dist-info}/WHEEL +1 -1
  27. hardpy/hardpy_panel/frontend/dist/static/js/main.403e9cd8.js +0 -3
  28. hardpy/hardpy_panel/frontend/dist/static/js/main.403e9cd8.js.map +0 -1
  29. /hardpy/hardpy_panel/frontend/dist/static/js/{main.403e9cd8.js.LICENSE.txt → main.8a7d8f7d.js.LICENSE.txt} +0 -0
  30. {hardpy-0.9.0.dist-info → hardpy-0.10.1.dist-info}/entry_points.txt +0 -0
  31. {hardpy-0.9.0.dist-info → hardpy-0.10.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,236 @@
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
+
4
+ from __future__ import annotations
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
18
+ from typing import TYPE_CHECKING
19
+ from urllib.parse import urlencode
20
+
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
27
+
28
+ from hardpy.common.stand_cloud.connector import StandCloudConnector, StandCloudError
29
+ from hardpy.common.stand_cloud.token_storage import get_token_store
30
+
31
+ if TYPE_CHECKING:
32
+ from requests_oauth2client import BearerToken
33
+
34
+
35
+ def login(addr: str) -> None:
36
+ """Login HardPy in StandCloud.
37
+
38
+ Args:
39
+ addr (str): StandCloud address
40
+ """
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
90
+
91
+ try:
92
+ # OAuth access token grant
93
+ response = client.parse_request_body_response(response.text)
94
+ except InvalidClientIdError as e:
95
+ print(e)
96
+ sys.exit(1)
97
+
98
+ _store_password(client.token)
99
+
100
+
101
+ def logout() -> bool:
102
+ """Logout HardPy from StandCloud.
103
+
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.
219
+
220
+ Returns:
221
+ dict: pushed authorization request data
222
+ """
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
+ }
@@ -0,0 +1,27 @@
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 platform import system
6
+ from typing import TYPE_CHECKING
7
+
8
+ from keyring.core import load_keyring
9
+
10
+ if TYPE_CHECKING:
11
+ from keyring.backend import KeyringBackend
12
+
13
+
14
+ def get_token_store() -> tuple[KeyringBackend, KeyringBackend]:
15
+ """Get token store.
16
+
17
+ Returns:
18
+ tuple[KeyringBackend, KeyringBackend]: token store
19
+ """
20
+ if system() == "Linux":
21
+ storage_keyring = load_keyring("keyring.backends.SecretService.Keyring")
22
+ elif system() == "Windows":
23
+ storage_keyring = load_keyring("keyring.backends.Windows.WinVaultKeyring")
24
+ # TODO(xorialexandrov): add memory keyring or other store
25
+ mem_keyring = storage_keyring
26
+
27
+ return storage_keyring, mem_keyring
@@ -1,6 +1,7 @@
1
1
  # Copyright (c) 2024 Everypin
2
2
  # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
3
 
4
+ import os
4
5
  import re
5
6
  from enum import Enum
6
7
  from pathlib import Path
@@ -119,11 +120,12 @@ def confirm_operator_msg(is_msg_visible: str) -> dict:
119
120
  return {"status": Status.ERROR}
120
121
 
121
122
 
122
- app.mount(
123
- "/",
124
- StaticFiles(
125
- directory=Path(__file__).parent / "frontend/dist",
126
- html=True,
127
- ),
128
- name="static",
129
- )
123
+ if "DEBUG_FRONTEND" not in os.environ:
124
+ app.mount(
125
+ "/",
126
+ StaticFiles(
127
+ directory=Path(__file__).parent / "frontend/dist",
128
+ html=True,
129
+ ),
130
+ name="static",
131
+ )
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "files": {
3
3
  "main.css": "/static/css/main.e8a862f1.css",
4
- "main.js": "/static/js/main.403e9cd8.js",
4
+ "main.js": "/static/js/main.8a7d8f7d.js",
5
5
  "blueprint-icons-all-paths-loader.js": "/static/js/blueprint-icons-all-paths-loader.0aa89747.chunk.js",
6
6
  "blueprint-icons-split-paths-by-size-loader.js": "/static/js/blueprint-icons-split-paths-by-size-loader.52a072d3.chunk.js",
7
7
  "static/js/808.ce070002.chunk.js": "/static/js/808.ce070002.chunk.js",
@@ -21,7 +21,7 @@
21
21
  "static/media/logo_smol.png": "/static/media/logo_smol.5b16f92447a4a9e80331.png",
22
22
  "index.html": "/index.html",
23
23
  "main.e8a862f1.css.map": "/static/css/main.e8a862f1.css.map",
24
- "main.403e9cd8.js.map": "/static/js/main.403e9cd8.js.map",
24
+ "main.8a7d8f7d.js.map": "/static/js/main.8a7d8f7d.js.map",
25
25
  "blueprint-icons-all-paths-loader.0aa89747.chunk.js.map": "/static/js/blueprint-icons-all-paths-loader.0aa89747.chunk.js.map",
26
26
  "blueprint-icons-split-paths-by-size-loader.52a072d3.chunk.js.map": "/static/js/blueprint-icons-split-paths-by-size-loader.52a072d3.chunk.js.map",
27
27
  "808.ce070002.chunk.js.map": "/static/js/808.ce070002.chunk.js.map",
@@ -31,6 +31,6 @@
31
31
  },
32
32
  "entrypoints": [
33
33
  "static/css/main.e8a862f1.css",
34
- "static/js/main.403e9cd8.js"
34
+ "static/js/main.8a7d8f7d.js"
35
35
  ]
36
36
  }
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>HardPy Operator Panel</title><script defer="defer" src="/static/js/main.403e9cd8.js"></script><link href="/static/css/main.e8a862f1.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>HardPy Operator Panel</title><script defer="defer" src="/static/js/main.8a7d8f7d.js"></script><link href="/static/css/main.e8a862f1.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>