hardpy 0.9.0__py3-none-any.whl → 0.10.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.
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 +209 -0
  9. hardpy/common/stand_cloud/token_storage.py +23 -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.0.dist-info}/METADATA +8 -2
  25. {hardpy-0.9.0.dist-info → hardpy-0.10.0.dist-info}/RECORD +29 -22
  26. {hardpy-0.9.0.dist-info → hardpy-0.10.0.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.0.dist-info}/entry_points.txt +0 -0
  31. {hardpy-0.9.0.dist-info → hardpy-0.10.0.dist-info}/licenses/LICENSE +0 -0
hardpy/__init__.py CHANGED
@@ -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",
hardpy/cli/cli.py CHANGED
@@ -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, # noqa: UP007
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: # noqa: UP007
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()
hardpy/common/config.py CHANGED
@@ -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)