hardpy 0.9.0__tar.gz → 0.10.1__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.
- {hardpy-0.9.0 → hardpy-0.10.1}/PKG-INFO +8 -2
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/__init__.py +4 -1
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/cli/cli.py +78 -11
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/common/config.py +16 -0
- hardpy-0.10.1/hardpy/common/stand_cloud/__init__.py +13 -0
- hardpy-0.10.1/hardpy/common/stand_cloud/connector.py +227 -0
- hardpy-0.10.1/hardpy/common/stand_cloud/exception.py +9 -0
- hardpy-0.10.1/hardpy/common/stand_cloud/oauth_callback.py +95 -0
- hardpy-0.10.1/hardpy/common/stand_cloud/registration.py +236 -0
- hardpy-0.10.1/hardpy/common/stand_cloud/token_storage.py +27 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/api.py +10 -8
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/asset-manifest.json +3 -3
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/index.html +1 -1
- hardpy-0.10.1/hardpy/hardpy_panel/frontend/dist/static/js/main.8a7d8f7d.js +3 -0
- hardpy-0.10.1/hardpy/hardpy_panel/frontend/dist/static/js/main.8a7d8f7d.js.map +1 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/const.py +1 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/schema/v1.py +2 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/plugin.py +52 -4
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/pytest_wrapper.py +10 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/reporter/hook_reporter.py +10 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/result/__init__.py +4 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/result/report_loader/__init__.py +4 -0
- hardpy-0.10.1/hardpy/pytest_hardpy/result/report_loader/stand_cloud_loader.py +72 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/connection_data.py +2 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/pyproject.toml +10 -2
- hardpy-0.9.0/hardpy/hardpy_panel/frontend/dist/static/js/main.403e9cd8.js +0 -3
- hardpy-0.9.0/hardpy/hardpy_panel/frontend/dist/static/js/main.403e9cd8.js.map +0 -1
- {hardpy-0.9.0 → hardpy-0.10.1}/.gitignore +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/LICENSE +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/README.md +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/cli/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/cli/template.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/common/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/favicon.ico +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/logo192.png +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/logo512.png +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/manifest.json +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/css/main.e8a862f1.css +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/css/main.e8a862f1.css.map +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/808.ce070002.chunk.js +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/808.ce070002.chunk.js.map +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-16px-paths.d605910e.chunk.js +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-16px-paths.d605910e.chunk.js.map +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-20px-paths.7ee05cc8.chunk.js +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-20px-paths.7ee05cc8.chunk.js.map +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-all-paths-loader.0aa89747.chunk.js +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-all-paths-loader.0aa89747.chunk.js.map +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-all-paths.f63155c9.chunk.js +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-all-paths.f63155c9.chunk.js.map +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-split-paths-by-size-loader.52a072d3.chunk.js +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/js/blueprint-icons-split-paths-by-size-loader.52a072d3.chunk.js.map +0 -0
- /hardpy-0.9.0/hardpy/hardpy_panel/frontend/dist/static/js/main.403e9cd8.js.LICENSE.txt → /hardpy-0.10.1/hardpy/hardpy_panel/frontend/dist/static/js/main.8a7d8f7d.js.LICENSE.txt +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-16.520846c6beb41df528c8.eot +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-16.5c52b39c697f2323ce8b.svg +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-16.84db1772f4bfb529f64f.woff +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-16.b67ee1736e20e37a3225.woff2 +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-16.e02ecf515378db143652.ttf +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-20.429cacb8accf72488451.ttf +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-20.6ae3791ee2d86fc228a6.svg +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-20.8cecf62de42997e4d82f.woff2 +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-20.afbadb627d43b7857223.eot +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/blueprint-icons-20.e857f5a5132b8bfa71a1.woff +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/hardpy_panel/frontend/dist/static/media/logo_smol.5b16f92447a4a9e80331.png +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/base_connector.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/base_server.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/base_store.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/runstore.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/schema/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/db/statestore.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/pytest_call.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/reporter/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/reporter/base.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/reporter/runner_reporter.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/result/couchdb_config.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/result/report_loader/couchdb_loader.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/result/report_reader/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/result/report_reader/couchdb_reader.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/__init__.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/const.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/dialog_box.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/exception.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/machineid.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/node_info.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/progress_calculator.py +0 -0
- {hardpy-0.9.0 → hardpy-0.10.1}/hardpy/pytest_hardpy/utils/singleton.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: hardpy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.1
|
|
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/
|
|
@@ -26,11 +26,17 @@ Classifier: Topic :: Utilities
|
|
|
26
26
|
Requires-Python: >=3.10
|
|
27
27
|
Requires-Dist: fastapi>=0.100.1
|
|
28
28
|
Requires-Dist: glom>=23.3.0
|
|
29
|
+
Requires-Dist: jinja2<4,>=3
|
|
30
|
+
Requires-Dist: keyring<26,>=25.0.0
|
|
29
31
|
Requires-Dist: natsort>=8.4.0
|
|
32
|
+
Requires-Dist: oauthlib<4,>=3.1.0
|
|
30
33
|
Requires-Dist: py-machineid~=0.6.0
|
|
31
34
|
Requires-Dist: pycouchdb<2,>=1.14.2
|
|
32
35
|
Requires-Dist: pydantic<3,>=2.4.0
|
|
33
36
|
Requires-Dist: pytest<9,>=7
|
|
37
|
+
Requires-Dist: requests-oauth2client<2,>=1.5.0
|
|
38
|
+
Requires-Dist: requests-oauthlib<3,>=2.0.0
|
|
39
|
+
Requires-Dist: requests<3,>=2.30.0
|
|
34
40
|
Requires-Dist: tomli-w<2,>=1.1.0
|
|
35
41
|
Requires-Dist: tomli<3,>=2.0.1
|
|
36
42
|
Requires-Dist: typer<1,>=0.12
|
|
@@ -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
|
+
from hardpy.common.stand_cloud import StandCloudError
|
|
4
5
|
from hardpy.pytest_hardpy.pytest_call import (
|
|
5
6
|
clear_operator_message,
|
|
6
7
|
get_current_attempt,
|
|
@@ -19,7 +20,7 @@ from hardpy.pytest_hardpy.pytest_call import (
|
|
|
19
20
|
set_stand_location,
|
|
20
21
|
set_stand_name,
|
|
21
22
|
)
|
|
22
|
-
from hardpy.pytest_hardpy.result import CouchdbLoader
|
|
23
|
+
from hardpy.pytest_hardpy.result import CouchdbLoader, StandCloudLoader
|
|
23
24
|
from hardpy.pytest_hardpy.result.couchdb_config import CouchdbConfig
|
|
24
25
|
from hardpy.pytest_hardpy.utils import (
|
|
25
26
|
BaseWidget,
|
|
@@ -51,6 +52,8 @@ __all__ = [
|
|
|
51
52
|
"MultistepWidget",
|
|
52
53
|
"NumericInputWidget",
|
|
53
54
|
"RadiobuttonWidget",
|
|
55
|
+
"StandCloudError",
|
|
56
|
+
"StandCloudLoader",
|
|
54
57
|
"StepWidget",
|
|
55
58
|
"TextInputWidget",
|
|
56
59
|
"clear_operator_message",
|
|
@@ -11,6 +11,18 @@ from uvicorn import run as uvicorn_run
|
|
|
11
11
|
|
|
12
12
|
from hardpy.cli.template import TemplateGenerator
|
|
13
13
|
from hardpy.common.config import ConfigManager
|
|
14
|
+
from hardpy.common.stand_cloud import (
|
|
15
|
+
StandCloudConnector,
|
|
16
|
+
StandCloudError,
|
|
17
|
+
login as auth_login,
|
|
18
|
+
logout as auth_logout,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if __debug__:
|
|
22
|
+
from urllib3 import disable_warnings
|
|
23
|
+
from urllib3.exceptions import InsecureRequestWarning
|
|
24
|
+
|
|
25
|
+
disable_warnings(InsecureRequestWarning)
|
|
14
26
|
|
|
15
27
|
cli = typer.Typer(add_completion=False)
|
|
16
28
|
default_config = ConfigManager().get_config()
|
|
@@ -18,43 +30,51 @@ default_config = ConfigManager().get_config()
|
|
|
18
30
|
|
|
19
31
|
@cli.command()
|
|
20
32
|
def init( # noqa: PLR0913
|
|
21
|
-
tests_dir: Annotated[Optional[str], typer.Argument()] = None,
|
|
33
|
+
tests_dir: Annotated[Optional[str], typer.Argument()] = None,
|
|
22
34
|
create_database: bool = typer.Option(
|
|
23
|
-
True,
|
|
35
|
+
default=True,
|
|
24
36
|
help="Create CouchDB database.",
|
|
25
37
|
),
|
|
26
38
|
database_user: str = typer.Option(
|
|
27
|
-
default_config.database.user,
|
|
39
|
+
default=default_config.database.user,
|
|
28
40
|
help="Specify a database user.",
|
|
29
41
|
),
|
|
30
42
|
database_password: str = typer.Option(
|
|
31
|
-
default_config.database.password,
|
|
43
|
+
default=default_config.database.password,
|
|
32
44
|
help="Specify a database user password.",
|
|
33
45
|
),
|
|
34
46
|
database_host: str = typer.Option(
|
|
35
|
-
default_config.database.host,
|
|
47
|
+
default=default_config.database.host,
|
|
36
48
|
help="Specify a database host.",
|
|
37
49
|
),
|
|
38
50
|
database_port: int = typer.Option(
|
|
39
|
-
default_config.database.port,
|
|
51
|
+
default=default_config.database.port,
|
|
40
52
|
help="Specify a database port.",
|
|
41
53
|
),
|
|
42
54
|
frontend_host: str = typer.Option(
|
|
43
|
-
default_config.frontend.host,
|
|
55
|
+
default=default_config.frontend.host,
|
|
44
56
|
help="Specify a frontend host.",
|
|
45
57
|
),
|
|
46
58
|
frontend_port: int = typer.Option(
|
|
47
|
-
default_config.frontend.port,
|
|
59
|
+
default=default_config.frontend.port,
|
|
48
60
|
help="Specify a frontend port.",
|
|
49
61
|
),
|
|
50
62
|
socket_host: str = typer.Option(
|
|
51
|
-
default_config.socket.host,
|
|
63
|
+
default=default_config.socket.host,
|
|
52
64
|
help="Specify a socket host.",
|
|
53
65
|
),
|
|
54
66
|
socket_port: int = typer.Option(
|
|
55
|
-
default_config.socket.port,
|
|
67
|
+
default=default_config.socket.port,
|
|
56
68
|
help="Specify a socket port.",
|
|
57
69
|
),
|
|
70
|
+
sc_address: str = typer.Option(
|
|
71
|
+
default="",
|
|
72
|
+
help="Specify a StandCloud address.",
|
|
73
|
+
),
|
|
74
|
+
sc_connection_only: bool = typer.Option(
|
|
75
|
+
default=False,
|
|
76
|
+
help="Check StandCloud service availability before start.",
|
|
77
|
+
),
|
|
58
78
|
) -> None:
|
|
59
79
|
"""Initialize HardPy tests directory.
|
|
60
80
|
|
|
@@ -69,6 +89,8 @@ def init( # noqa: PLR0913
|
|
|
69
89
|
frontend_port (int): Panel operator port
|
|
70
90
|
socket_host (str): Socket host
|
|
71
91
|
socket_port (int): Socket port
|
|
92
|
+
sc_address (str): StandCloud address
|
|
93
|
+
sc_connection_only (bool): Flag to check StandCloud service availability
|
|
72
94
|
"""
|
|
73
95
|
_tests_dir = tests_dir if tests_dir else default_config.tests_dir
|
|
74
96
|
ConfigManager().init_config(
|
|
@@ -81,6 +103,8 @@ def init( # noqa: PLR0913
|
|
|
81
103
|
frontend_port=frontend_port,
|
|
82
104
|
socket_host=socket_host,
|
|
83
105
|
socket_port=socket_port,
|
|
106
|
+
sc_address=sc_address,
|
|
107
|
+
sc_connection_only=sc_connection_only,
|
|
84
108
|
)
|
|
85
109
|
# create tests directory
|
|
86
110
|
dir_path = Path(Path.cwd() / _tests_dir)
|
|
@@ -116,7 +140,7 @@ def init( # noqa: PLR0913
|
|
|
116
140
|
|
|
117
141
|
|
|
118
142
|
@cli.command()
|
|
119
|
-
def run(tests_dir: Annotated[Optional[str], typer.Argument()] = None) -> None:
|
|
143
|
+
def run(tests_dir: Annotated[Optional[str], typer.Argument()] = None) -> None:
|
|
120
144
|
"""Run HardPy server.
|
|
121
145
|
|
|
122
146
|
Args:
|
|
@@ -140,5 +164,48 @@ def run(tests_dir: Annotated[Optional[str], typer.Argument()] = None) -> None:
|
|
|
140
164
|
)
|
|
141
165
|
|
|
142
166
|
|
|
167
|
+
@cli.command()
|
|
168
|
+
def sc_login(
|
|
169
|
+
address: Annotated[str, typer.Argument()],
|
|
170
|
+
check: bool = typer.Option(
|
|
171
|
+
False,
|
|
172
|
+
help="Check StandCloud connection.",
|
|
173
|
+
),
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Login HardPy in StandCloud.
|
|
176
|
+
|
|
177
|
+
The command opens an authentication and authorization portal of StandCloud
|
|
178
|
+
where you will be requested for your credentials and consents to authorize
|
|
179
|
+
HardPy to upload test reports from your identity.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
address (str): StandCloud address
|
|
183
|
+
check (bool): Check StandCloud connection
|
|
184
|
+
"""
|
|
185
|
+
if check:
|
|
186
|
+
try:
|
|
187
|
+
sc_connector = StandCloudConnector(address)
|
|
188
|
+
except StandCloudError as exc:
|
|
189
|
+
print(str(exc))
|
|
190
|
+
sys.exit()
|
|
191
|
+
try:
|
|
192
|
+
sc_connector.healthcheck()
|
|
193
|
+
except StandCloudError:
|
|
194
|
+
print("StandCloud connection failed")
|
|
195
|
+
sys.exit()
|
|
196
|
+
print("StandCloud connection success")
|
|
197
|
+
else:
|
|
198
|
+
auth_login(address)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@cli.command()
|
|
202
|
+
def sc_logout() -> None:
|
|
203
|
+
"""Logout HardPy from all StandCloud accounts."""
|
|
204
|
+
if auth_logout():
|
|
205
|
+
print("HardPy logout success")
|
|
206
|
+
else:
|
|
207
|
+
print("HardPy logout failed")
|
|
208
|
+
|
|
209
|
+
|
|
143
210
|
if __name__ == "__main__":
|
|
144
211
|
cli()
|
|
@@ -50,6 +50,13 @@ class SocketConfig(BaseModel):
|
|
|
50
50
|
host: str = "localhost"
|
|
51
51
|
port: int = 6525
|
|
52
52
|
|
|
53
|
+
class StandCloudConfig(BaseModel):
|
|
54
|
+
"""StandCloud configuration."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid")
|
|
57
|
+
|
|
58
|
+
address: str = ""
|
|
59
|
+
connection_only: bool = False
|
|
53
60
|
|
|
54
61
|
class HardpyConfig(BaseModel):
|
|
55
62
|
"""HardPy configuration."""
|
|
@@ -61,6 +68,7 @@ class HardpyConfig(BaseModel):
|
|
|
61
68
|
database: DatabaseConfig = DatabaseConfig()
|
|
62
69
|
frontend: FrontendConfig = FrontendConfig()
|
|
63
70
|
socket: SocketConfig = SocketConfig()
|
|
71
|
+
stand_cloud: StandCloudConfig = StandCloudConfig()
|
|
64
72
|
|
|
65
73
|
|
|
66
74
|
class ConfigManager:
|
|
@@ -81,6 +89,8 @@ class ConfigManager:
|
|
|
81
89
|
frontend_port: int,
|
|
82
90
|
socket_host: str,
|
|
83
91
|
socket_port: int,
|
|
92
|
+
sc_address: str = "",
|
|
93
|
+
sc_connection_only: bool = False,
|
|
84
94
|
) -> None:
|
|
85
95
|
"""Initialize HardPy configuration.
|
|
86
96
|
|
|
@@ -94,6 +104,8 @@ class ConfigManager:
|
|
|
94
104
|
frontend_port (int): Operator panel port.
|
|
95
105
|
socket_host (str): Socket host.
|
|
96
106
|
socket_port (int): Socket port.
|
|
107
|
+
sc_address (str): StandCloud address.
|
|
108
|
+
sc_connection_only (bool): StandCloud check availability.
|
|
97
109
|
"""
|
|
98
110
|
cls.obj.tests_dir = str(tests_dir)
|
|
99
111
|
cls.obj.database.user = database_user
|
|
@@ -104,6 +116,8 @@ class ConfigManager:
|
|
|
104
116
|
cls.obj.frontend.port = frontend_port
|
|
105
117
|
cls.obj.socket.host = socket_host
|
|
106
118
|
cls.obj.socket.port = socket_port
|
|
119
|
+
cls.obj.stand_cloud.address = sc_address
|
|
120
|
+
cls.obj.stand_cloud.connection_only = sc_connection_only
|
|
107
121
|
|
|
108
122
|
@classmethod
|
|
109
123
|
def create_config(cls, parent_dir: Path) -> None:
|
|
@@ -112,6 +126,8 @@ class ConfigManager:
|
|
|
112
126
|
Args:
|
|
113
127
|
parent_dir (Path): Configuration file parent directory.
|
|
114
128
|
"""
|
|
129
|
+
if not cls.obj.stand_cloud.address:
|
|
130
|
+
del cls.obj.stand_cloud
|
|
115
131
|
config_str = tomli_w.dumps(cls.obj.model_dump())
|
|
116
132
|
with Path.open(parent_dir / "hardpy.toml", "w") as file:
|
|
117
133
|
file.write(config_str)
|
|
@@ -0,0 +1,13 @@
|
|
|
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 hardpy.common.stand_cloud.connector import StandCloudConnector
|
|
5
|
+
from hardpy.common.stand_cloud.exception import StandCloudError
|
|
6
|
+
from hardpy.common.stand_cloud.registration import login, logout
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"StandCloudConnector",
|
|
10
|
+
"StandCloudError",
|
|
11
|
+
"login",
|
|
12
|
+
"logout",
|
|
13
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
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 logging import getLogger
|
|
8
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
9
|
+
|
|
10
|
+
from oauthlib.oauth2.rfc6749.errors import (
|
|
11
|
+
InvalidGrantError,
|
|
12
|
+
MissingTokenError,
|
|
13
|
+
TokenExpiredError,
|
|
14
|
+
)
|
|
15
|
+
from requests.exceptions import (
|
|
16
|
+
ConnectionError as RequestConnectionError,
|
|
17
|
+
HTTPError,
|
|
18
|
+
InvalidURL,
|
|
19
|
+
)
|
|
20
|
+
from requests_oauth2client import ApiClient, BearerToken
|
|
21
|
+
from requests_oauth2client.tokens import ExpiredAccessToken
|
|
22
|
+
from requests_oauthlib import OAuth2Session
|
|
23
|
+
|
|
24
|
+
from hardpy.common.stand_cloud.exception import StandCloudError
|
|
25
|
+
from hardpy.common.stand_cloud.token_storage import get_token_store
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from requests import Response
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StandCloudURL(NamedTuple):
|
|
32
|
+
"""URL.
|
|
33
|
+
|
|
34
|
+
api: API address
|
|
35
|
+
token: token address
|
|
36
|
+
par: pushed-authorization-request address
|
|
37
|
+
auth: auth address
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
api: str
|
|
41
|
+
token: str
|
|
42
|
+
par: str
|
|
43
|
+
auth: str
|
|
44
|
+
|
|
45
|
+
class StandCloudConnector:
|
|
46
|
+
"""StandCloud API connector."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
addr: str,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Create StandCLoud loader.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
addr (str | None, optional): StandCloud address.
|
|
56
|
+
The option only for development and debug. Defaults to True.
|
|
57
|
+
"""
|
|
58
|
+
https_prefix = "https://"
|
|
59
|
+
auth_addr = addr + "/auth"
|
|
60
|
+
|
|
61
|
+
self._url: StandCloudURL = StandCloudURL(
|
|
62
|
+
api=https_prefix + addr + self._get_service_name(addr) + "/api/v1",
|
|
63
|
+
token=https_prefix + auth_addr + "/api/oidc/token",
|
|
64
|
+
par=https_prefix + auth_addr + "/api/oidc/pushed-authorization-request",
|
|
65
|
+
auth=https_prefix + auth_addr + "/api/oidc/authorization",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self._verify_ssl = not __debug__
|
|
69
|
+
self._log = getLogger(__name__)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def url(self) -> StandCloudURL:
|
|
73
|
+
"""Get StandCloud URL."""
|
|
74
|
+
return self._url
|
|
75
|
+
|
|
76
|
+
def get_api(self, endpoint: str) -> ApiClient:
|
|
77
|
+
"""Get StandCloud API client.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
endpoint (str): endpoint address.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
ApiClient: API clinet
|
|
84
|
+
"""
|
|
85
|
+
return self._get_api(endpoint)
|
|
86
|
+
|
|
87
|
+
def healthcheck(self) -> Response:
|
|
88
|
+
"""Healthcheck of StandCloud API.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Response: healthcheck response
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
StandCloudError: if StandCloud is unavailable
|
|
95
|
+
"""
|
|
96
|
+
api = self._get_api("healthcheck")
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
resp = api.get(verify=self._verify_ssl)
|
|
100
|
+
except ExpiredAccessToken as exc:
|
|
101
|
+
raise StandCloudError(str(exc))
|
|
102
|
+
except TokenExpiredError as exc:
|
|
103
|
+
raise StandCloudError(exc.description)
|
|
104
|
+
except InvalidGrantError as exc:
|
|
105
|
+
raise StandCloudError(exc.description)
|
|
106
|
+
except HTTPError as exc:
|
|
107
|
+
raise StandCloudError(exc.strerror) # type: ignore
|
|
108
|
+
|
|
109
|
+
return resp
|
|
110
|
+
|
|
111
|
+
def _token_update(self, token: BearerToken) -> None:
|
|
112
|
+
storage_keyring, mem_keyring = get_token_store()
|
|
113
|
+
|
|
114
|
+
_access_token = "access_token" # noqa: S105
|
|
115
|
+
_expires_at = "expires_at"
|
|
116
|
+
_refresh_token = "refresh_token" # noqa: S105
|
|
117
|
+
_hardpy = "HardPy"
|
|
118
|
+
|
|
119
|
+
storage_keyring.set_password(_hardpy, _refresh_token, token[_refresh_token])
|
|
120
|
+
mem_keyring.set_password(_hardpy, _access_token, token[_access_token])
|
|
121
|
+
|
|
122
|
+
token_data = {
|
|
123
|
+
_access_token: token[_access_token],
|
|
124
|
+
_expires_at: token[_expires_at],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
mem_keyring.set_password(_hardpy, _access_token, json.dumps(token_data))
|
|
128
|
+
|
|
129
|
+
def _get_expires_in(self, expires_at: float | None) -> int:
|
|
130
|
+
if expires_at is None:
|
|
131
|
+
return -1
|
|
132
|
+
expires_at_datetime = datetime.fromtimestamp(expires_at, timezone.utc)
|
|
133
|
+
|
|
134
|
+
now_datetime = datetime.now(timezone.utc)
|
|
135
|
+
|
|
136
|
+
expires_in_datetime = expires_at_datetime - now_datetime
|
|
137
|
+
return int(expires_in_datetime.total_seconds())
|
|
138
|
+
|
|
139
|
+
def _get_access_token_info(self) -> tuple[str | None, float | None]:
|
|
140
|
+
_, mem_keyring = get_token_store()
|
|
141
|
+
|
|
142
|
+
_access_token = "access_token" # noqa: S105
|
|
143
|
+
_expires_at = "expires_at"
|
|
144
|
+
|
|
145
|
+
token_info = mem_keyring.get_password("HardPy", _access_token)
|
|
146
|
+
if token_info is None:
|
|
147
|
+
return None, None
|
|
148
|
+
token_dict = json.loads(token_info)
|
|
149
|
+
access_token = token_dict[_access_token]
|
|
150
|
+
expired_at = token_dict[_expires_at]
|
|
151
|
+
|
|
152
|
+
return access_token, expired_at
|
|
153
|
+
|
|
154
|
+
def _get_refresh_token(self) -> str | None:
|
|
155
|
+
(storage_keyring, _) = get_token_store()
|
|
156
|
+
|
|
157
|
+
refresh_token = storage_keyring.get_password("HardPy", "refresh_token")
|
|
158
|
+
self._log.debug("Got refresh token from the storage keyring")
|
|
159
|
+
return refresh_token
|
|
160
|
+
|
|
161
|
+
def _get_api(self, endpoint: str) -> ApiClient:
|
|
162
|
+
token = self._get_token()
|
|
163
|
+
client_id = "hardpy-report-uploader"
|
|
164
|
+
|
|
165
|
+
extra = {
|
|
166
|
+
"client_id": client_id,
|
|
167
|
+
"audience": self._url.api,
|
|
168
|
+
"redirect_uri": "http://localhost/oauth2/callback",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
session = OAuth2Session(
|
|
172
|
+
client_id,
|
|
173
|
+
token=token.as_dict(),
|
|
174
|
+
token_updater=self._token_update,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
is_need_refresh = False
|
|
178
|
+
early_refresh = timedelta(seconds=30)
|
|
179
|
+
|
|
180
|
+
if token.expires_in and token.expires_in < early_refresh.seconds:
|
|
181
|
+
is_need_refresh = True
|
|
182
|
+
|
|
183
|
+
if token.access_token is None:
|
|
184
|
+
self._log.debug("Want to refresh token since don't have access token")
|
|
185
|
+
is_need_refresh = True
|
|
186
|
+
|
|
187
|
+
if is_need_refresh:
|
|
188
|
+
try:
|
|
189
|
+
ret = session.refresh_token(
|
|
190
|
+
token_url=self._url.token,
|
|
191
|
+
refresh_token=self._get_refresh_token(),
|
|
192
|
+
verify=False,
|
|
193
|
+
**extra,
|
|
194
|
+
)
|
|
195
|
+
except InvalidGrantError as exc:
|
|
196
|
+
raise StandCloudError(exc.description)
|
|
197
|
+
except RequestConnectionError as exc:
|
|
198
|
+
raise StandCloudError(exc.strerror) # type: ignore
|
|
199
|
+
except MissingTokenError as exc:
|
|
200
|
+
raise StandCloudError(exc.description)
|
|
201
|
+
except InvalidURL:
|
|
202
|
+
msg = "Authentication URL is not available"
|
|
203
|
+
raise StandCloudError(msg)
|
|
204
|
+
self._token_update(ret) # type: ignore
|
|
205
|
+
|
|
206
|
+
return ApiClient(self._url.api + "/" + endpoint, session=session, timeout=10)
|
|
207
|
+
|
|
208
|
+
def _get_token(self) -> BearerToken:
|
|
209
|
+
access_token, expires_at = self._get_access_token_info()
|
|
210
|
+
expires_in = self._get_expires_in(expires_at)
|
|
211
|
+
|
|
212
|
+
return BearerToken(
|
|
213
|
+
scope=["authelia.bearer.authz", "offline_access"],
|
|
214
|
+
token_type="bearer", # noqa: S106
|
|
215
|
+
access_token=access_token,
|
|
216
|
+
expires_at=expires_at,
|
|
217
|
+
expires_in=expires_in,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _get_service_name(self, addr: str) -> str:
|
|
221
|
+
addr_parts = addr.split(".")
|
|
222
|
+
number_of_parts = 3
|
|
223
|
+
service_position_in_address = 1
|
|
224
|
+
if isinstance(addr_parts, list) and len(addr_parts) >= number_of_parts:
|
|
225
|
+
return "/" + addr_parts[service_position_in_address]
|
|
226
|
+
msg = f"Invalid StandCloud address: {addr}"
|
|
227
|
+
raise StandCloudError(msg)
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
|
|
5
|
+
class StandCloudError(Exception):
|
|
6
|
+
"""Base StandCloud error."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, msg: str) -> None:
|
|
9
|
+
super().__init__(f"StandCloud error: {msg}")
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
import json
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi.responses import HTMLResponse
|
|
9
|
+
from fastapi.templating import Jinja2Templates
|
|
10
|
+
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
|
|
13
|
+
templates = Jinja2Templates(directory="templates")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.route("/oauth2/callback")
|
|
17
|
+
async def index(request: Request) -> None:
|
|
18
|
+
"""OAuth2 callback page."""
|
|
19
|
+
print(json.dumps(dict(request.query_params))) # noqa: T201
|
|
20
|
+
success_template = """
|
|
21
|
+
<html><body>
|
|
22
|
+
<h1>😎 Success</h1>
|
|
23
|
+
<p>You have been redirected from the Authentication Portal<p>
|
|
24
|
+
<p>Your received response data:</p>
|
|
25
|
+
<ul>
|
|
26
|
+
<li><b>Authorization Code</b>: <i>{{code}}</i></li>
|
|
27
|
+
<li><b>Issuer</b>: <i>{{iss}}</i></li>
|
|
28
|
+
<li><b>Scope of the access request</b>: <i>{{scope}}</i></li>
|
|
29
|
+
<li><b>State data</b>: <i>{{state}}</i></li>
|
|
30
|
+
</ul>
|
|
31
|
+
<p>It is the technical data needed by the application that requests access token.</p>
|
|
32
|
+
<p>Probably, the application already received this information.</p>
|
|
33
|
+
<p><b>ℹ️ You can close the window</b></p>
|
|
34
|
+
</body></html>
|
|
35
|
+
""" # noqa: E501, RUF001
|
|
36
|
+
|
|
37
|
+
error_template = """
|
|
38
|
+
<html><body>
|
|
39
|
+
<h1>😞 Error: '{{error}}'</h1>
|
|
40
|
+
<p>You have been redirected from the Authentication Portal<p>
|
|
41
|
+
<p>Your received response data:</p>
|
|
42
|
+
<ul>
|
|
43
|
+
<li><b>Error description</b>: <i>{{error_description}}</i></li>
|
|
44
|
+
<li><b>Issuer</b>: <i>{{iss}}</i></li>
|
|
45
|
+
<li><b>State data</b>: <i>{{state}}</i></li>
|
|
46
|
+
</ul>
|
|
47
|
+
<p>It is the technical data needed by the application that requests access token.</p>
|
|
48
|
+
<p>Probably, the application already received this information.</p>
|
|
49
|
+
<p><b>ℹ️ You can close the window</b></p>
|
|
50
|
+
</body></html>
|
|
51
|
+
""" # noqa: E501, RUF001
|
|
52
|
+
|
|
53
|
+
if request.query_params.get("code") is not None:
|
|
54
|
+
return HTMLResponse(
|
|
55
|
+
content=success_template.replace(
|
|
56
|
+
"{{code}}",
|
|
57
|
+
request.query_params.get("code"), # type: ignore
|
|
58
|
+
)
|
|
59
|
+
.replace("{{iss}}", request.query_params.get("iss"))
|
|
60
|
+
.replace("{{scope}}", request.query_params.get("scope"))
|
|
61
|
+
.replace("{{state}}", request.query_params.get("state")),
|
|
62
|
+
status_code=HTTPStatus.OK,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if request.query_params.get("error") is not None:
|
|
66
|
+
return HTMLResponse(
|
|
67
|
+
content=error_template.replace(
|
|
68
|
+
"{{error}}",
|
|
69
|
+
request.query_params.get("error"), # type: ignore
|
|
70
|
+
)
|
|
71
|
+
.replace(
|
|
72
|
+
"{{error_description}}",
|
|
73
|
+
request.query_params.get("error_description"),
|
|
74
|
+
)
|
|
75
|
+
.replace("{{iss}}", request.query_params.get("iss"))
|
|
76
|
+
.replace("{{state}}", request.query_params.get("state")),
|
|
77
|
+
status_code=HTTPStatus.OK,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
server_error = """
|
|
81
|
+
<html><body>
|
|
82
|
+
<h1>Internal Server Error'</h1>
|
|
83
|
+
</body></html>
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
return HTMLResponse(
|
|
87
|
+
content=server_error,
|
|
88
|
+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
89
|
+
) # type: ignore
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
import uvicorn
|
|
94
|
+
|
|
95
|
+
uvicorn.run(app, host="127.0.0.1", port=8088)
|