hardpy 0.4.0__py3-none-any.whl → 0.5.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.
@@ -6,6 +6,7 @@ from typing import Any, Callable
6
6
  from logging import getLogger
7
7
  from pathlib import Path, PurePath
8
8
  from platform import system
9
+ from re import compile as re_compile
9
10
 
10
11
  from natsort import natsorted
11
12
  from pytest import (
@@ -19,6 +20,13 @@ from pytest import (
19
20
  fixture,
20
21
  ExitCode,
21
22
  )
23
+ from _pytest._code.code import (
24
+ ExceptionRepr,
25
+ ReprFileLocation,
26
+ ExceptionInfo,
27
+ ReprExceptionInfo,
28
+ TerminalRepr,
29
+ )
22
30
 
23
31
  from hardpy.pytest_hardpy.reporter import HookReporter
24
32
  from hardpy.pytest_hardpy.utils import (
@@ -187,8 +195,9 @@ class HardpyPlugin(object):
187
195
 
188
196
  def pytest_runtest_logreport(self, report: TestReport):
189
197
  """Call after call of each test item."""
190
- if report.when != "call":
198
+ if report.when != "call" and report.failed is False:
191
199
  # ignore setup and teardown phase
200
+ # or continue processing setup and teardown failure (fixture exception handler)
192
201
  return True
193
202
 
194
203
  module_id = Path(report.fspath).stem
@@ -204,7 +213,7 @@ class HardpyPlugin(object):
204
213
  case_id,
205
214
  )
206
215
 
207
- assertion_msg = self._decode_assertion_msg(report.longreprtext)
216
+ assertion_msg = self._decode_assertion_msg(report.longrepr)
208
217
  self._reporter.set_assertion_msg(module_id, case_id, assertion_msg)
209
218
  self._reporter.set_progress(self._progress.calculate(report.nodeid))
210
219
  self._results[module_id][case_id] = report.outcome # noqa: WPS204
@@ -265,15 +274,41 @@ class HardpyPlugin(object):
265
274
  case _:
266
275
  return RunStatus.ERROR
267
276
 
268
- def _decode_assertion_msg(self, msg: str) -> str | None:
269
- assertion_str = "AssertionError: "
270
-
271
- if assertion_str in msg:
272
- index = msg.find(assertion_str)
273
- report = msg[index + len(assertion_str) :]
274
- index = report.find("\nE")
275
- return report[:index]
276
- return None
277
+ def _decode_assertion_msg(
278
+ self,
279
+ error: ( # noqa: WPS320
280
+ ExceptionInfo[BaseException] # noqa: DAR101,DAR201
281
+ | tuple[str, int, str]
282
+ | str
283
+ | TerminalRepr
284
+ | None
285
+ ),
286
+ ) -> str | None:
287
+ """Parse pytest assertion error message."""
288
+ if error is None:
289
+ return None
290
+
291
+ match error:
292
+ case str():
293
+ return error
294
+ case tuple() if len(error) == 3:
295
+ return error[2]
296
+ case ExceptionInfo():
297
+ error_repr = error.getrepr()
298
+ if isinstance(error_repr, ReprExceptionInfo) and error_repr.reprcrash:
299
+ return error_repr.reprcrash.message
300
+ case TerminalRepr():
301
+ if isinstance(error, ExceptionRepr) and isinstance( # noqa: WPS337
302
+ error.reprcrash, ReprFileLocation
303
+ ):
304
+ # remove ansi codes
305
+ ansi_pattern = re_compile(
306
+ r"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])" # noqa: E501
307
+ )
308
+ return ansi_pattern.sub("", error.reprcrash.message)
309
+ return str(error)
310
+ case _:
311
+ return None
277
312
 
278
313
  def _handle_dependency(self, node_info: NodeInfo):
279
314
  dependency = self._dependencies.get(
@@ -20,8 +20,6 @@ from hardpy.pytest_hardpy.utils import (
20
20
  DuplicateDialogBoxError,
21
21
  DialogBox,
22
22
  ConfigData,
23
- generate_dialog_box_dict,
24
- get_dialog_box_data,
25
23
  )
26
24
  from hardpy.pytest_hardpy.reporter import RunnerReporter
27
25
 
@@ -221,7 +219,7 @@ def run_dialog_box(dialog_box_data: DialogBox) -> Any:
221
219
 
222
220
  - dialog_text (str): The text of the dialog box.
223
221
  - title_bar (str | None): The title bar of the dialog box.
224
- If the title_bar field is missing, it is the case name.
222
+ If the title_bar field is missing, it is the case name.
225
223
  - widget (DialogBoxWidget | None): Widget information.
226
224
 
227
225
  Returns:
@@ -229,9 +227,13 @@ def run_dialog_box(dialog_box_data: DialogBox) -> Any:
229
227
 
230
228
  The type of the return value depends on the widget type:
231
229
 
232
- - Without widget: None.
230
+ - BASE: bool.
233
231
  - TEXT_INPUT: str.
234
232
  - NUMERIC_INPUT: float.
233
+ - RADIOBUTTON: str.
234
+ - CHECKBOX: list[str].
235
+ - IMAGE: bool.
236
+ - MULTISTEP: bool.
235
237
 
236
238
  Raises:
237
239
  ValueError: If the 'message' argument is empty.
@@ -251,14 +253,12 @@ def run_dialog_box(dialog_box_data: DialogBox) -> Any:
251
253
  )
252
254
  if reporter.get_field(key):
253
255
  raise DuplicateDialogBoxError
254
- data_dict = generate_dialog_box_dict(dialog_box_data)
255
256
 
256
- reporter.set_doc_value(key, data_dict, statestore_only=True)
257
+ reporter.set_doc_value(key, dialog_box_data.to_dict(), statestore_only=True)
257
258
  reporter.update_db_by_doc()
258
259
 
259
- dialog_raw_data = _get_socket_raw_data()
260
-
261
- return get_dialog_box_data(dialog_raw_data, dialog_box_data.widget)
260
+ input_dbx_data = _get_socket_raw_data()
261
+ return dialog_box_data.widget.convert_data(input_dbx_data)
262
262
 
263
263
 
264
264
  def _get_current_test() -> CurrentTestInfo:
@@ -1,22 +1,96 @@
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 ast
5
+ import requests
6
+ import socket
7
+
4
8
  from dataclasses import dataclass
9
+ from urllib3 import disable_warnings
5
10
 
6
11
 
7
12
  @dataclass
8
13
  class CouchdbConfig: # noqa: WPS306
9
- """CouchDB loader config."""
14
+ """CouchDB loader config.
15
+
16
+ If `connection_str` arg is not set, it will be created from other args.
17
+ """
10
18
 
11
19
  db_name: str = "report"
12
20
  user: str = "dev"
13
21
  password: str = "dev"
14
22
  host: str = "localhost"
15
23
  port: int = 5984
24
+ connection_str: str | None = None
25
+
26
+ def __post_init__(self):
27
+ """Disable urllib3 warnings.
28
+
29
+ More info: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
30
+ """
31
+ disable_warnings()
16
32
 
17
33
  @property
18
34
  def connection_string(self) -> str:
19
- """Get couchdb connection string."""
35
+ """Get couchdb connection string.
36
+
37
+ Raises:
38
+ RuntimeError: CouchDB server is not available
39
+
40
+ Returns:
41
+ str: Database connection string.
42
+ """
43
+ if self.connection_str:
44
+ return self.connection_str
45
+
46
+ # TODO: Modify connection string creating based on protocol.
47
+ # Some problems with http and https, different ports, local
48
+ # and cloud databases.
49
+ protocol = self._get_protocol()
50
+
51
+ if protocol == "http":
52
+ host_url = f"http://{self.host}:{self.port}"
53
+ uri = f"{self.host}:{str(self.port)}" # noqa: WPS237
54
+ elif protocol == "https":
55
+ host_url = f"https://{self.host}"
56
+ uri = f"{self.host}"
57
+
58
+ try:
59
+ response = requests.get(host_url, timeout=5)
60
+ except requests.exceptions.RequestException:
61
+ raise RuntimeError(f"Error CouchDB connecting to {host_url}.")
62
+
63
+ # fmt: off
64
+ try:
65
+ couchdb_dict = ast.literal_eval(response._content.decode("utf-8")) # noqa: WPS437,E501
66
+ couchdb_dict.get("couchdb", False)
67
+ except Exception:
68
+ raise RuntimeError(f"Address {host_url} does not provide CouchDB attributes.")
69
+ # fmt: on
70
+
20
71
  credentials = f"{self.user}:{self.password}"
21
- uri = f"{self.host}:{str(self.port)}"
22
- return f"http://{credentials}@{uri}/"
72
+ return f"{protocol}://{credentials}@{uri}/"
73
+
74
+ def _get_protocol(self) -> str: # noqa: WPS231
75
+ success = 200
76
+ try:
77
+ # HTTPS attempt
78
+ sock = socket.create_connection((self.host, self.port))
79
+ sock.close()
80
+ request = f"https://{self.host}"
81
+ if requests.get(request, timeout=5).status_code == success:
82
+ return "https"
83
+ raise OSError
84
+ except OSError:
85
+ try: # noqa: WPS505
86
+ # HTTP attempt
87
+ sock = socket.create_connection((self.host, self.port))
88
+ sock.close()
89
+ request = f"http://{self.host}:{self.port}"
90
+ if requests.get(request, timeout=5).status_code == success:
91
+ return "http"
92
+ raise OSError
93
+ except OSError:
94
+ raise RuntimeError(
95
+ f"Error connecting to couchdb server {self.host}:{self.port}."
96
+ )
@@ -9,15 +9,20 @@ from hardpy.pytest_hardpy.utils.config_data import ConfigData
9
9
  from hardpy.pytest_hardpy.utils.exception import (
10
10
  DuplicateSerialNumberError,
11
11
  DuplicateDialogBoxError,
12
+ WidgetInfoError,
12
13
  )
13
14
  from hardpy.pytest_hardpy.utils.dialog_box import (
14
15
  DialogBox,
15
- DialogBoxWidget,
16
- DialogBoxWidgetType,
17
- generate_dialog_box_dict,
18
- get_dialog_box_data,
16
+ TextInputWidget,
17
+ NumericInputWidget,
18
+ CheckboxWidget,
19
+ RadiobuttonWidget,
20
+ ImageWidget,
21
+ MultistepWidget,
22
+ StepWidget,
19
23
  )
20
24
 
25
+
21
26
  __all__ = [
22
27
  "NodeInfo",
23
28
  "ProgressCalculator",
@@ -27,9 +32,13 @@ __all__ = [
27
32
  "ConfigData",
28
33
  "DuplicateSerialNumberError",
29
34
  "DuplicateDialogBoxError",
35
+ "WidgetInfoError",
30
36
  "DialogBox",
31
- "DialogBoxWidget",
32
- "DialogBoxWidgetType",
33
- "generate_dialog_box_dict",
34
- "get_dialog_box_data",
37
+ "TextInputWidget",
38
+ "NumericInputWidget",
39
+ "CheckboxWidget",
40
+ "RadiobuttonWidget",
41
+ "ImageWidget",
42
+ "MultistepWidget",
43
+ "StepWidget"
35
44
  ]
@@ -1,31 +1,275 @@
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 enum import Enum
4
+ import base64
5
+ from abc import ABC, abstractmethod
6
+ from ast import literal_eval
7
+ from copy import deepcopy
5
8
  from dataclasses import dataclass
6
- from typing import Any
9
+ from enum import Enum
10
+ from typing import Any, Final
11
+
12
+ from hardpy.pytest_hardpy.utils.exception import WidgetInfoError
7
13
 
8
14
 
9
- class DialogBoxWidgetType(Enum):
15
+ class WidgetType(Enum):
10
16
  """Dialog box widget type."""
11
17
 
12
- RADIOBUTTON = "radiobutton"
13
- CHECKBOX = "checkbox"
18
+ BASE = "base"
14
19
  TEXT_INPUT = "textinput"
15
20
  NUMERIC_INPUT = "numericinput"
21
+ RADIOBUTTON = "radiobutton"
22
+ CHECKBOX = "checkbox"
23
+ IMAGE = "image"
24
+ STEP = "step"
25
+ MULTISTEP = "multistep"
16
26
 
17
27
 
18
- @dataclass
19
- class DialogBoxWidget:
20
- """Dialog box widget.
28
+ class IWidget(ABC):
29
+ """Dialog box widget interface."""
30
+
31
+ def __init__(self, widget_type: WidgetType):
32
+ self.type: Final[str] = widget_type.value
33
+ self.info: dict = {}
34
+
35
+ @abstractmethod
36
+ def convert_data(self, input_data: str | None) -> Any | None:
37
+ """Get the widget data in the correct format.
38
+
39
+ Args:
40
+ input_data (str | None): input string or nothing.
41
+
42
+ Returns:
43
+ Any: Widget data in the correct format
44
+ """
45
+ raise NotImplementedError
46
+
47
+
48
+ class BaseWidget(IWidget):
49
+ """Widget info interface."""
50
+
51
+ def __init__(self, widget_type: WidgetType = WidgetType.BASE):
52
+ super().__init__(WidgetType.BASE)
53
+
54
+ def convert_data(self, input_data: str | None = None) -> bool: # noqa: WPS324
55
+ """Get base widget data, i.e. None.
56
+
57
+ Args:
58
+ input_data (str): input string
59
+
60
+ Returns:
61
+ bool: True if confirm button is pressed
62
+ """
63
+ return True
64
+
65
+
66
+ class TextInputWidget(IWidget):
67
+ """Text input widget."""
68
+
69
+ def __init__(self):
70
+ """Initialize the TextInputWidget."""
71
+ super().__init__(WidgetType.TEXT_INPUT)
72
+
73
+ def convert_data(self, input_data: str) -> str:
74
+ """Get the text input data in the string format.
75
+
76
+ Args:
77
+ input_data (str): input string
78
+
79
+ Returns:
80
+ str: Text input string data
81
+ """
82
+ return input_data
83
+
84
+
85
+ class NumericInputWidget(IWidget):
86
+ """Numeric input widget."""
87
+
88
+ def __init__(self):
89
+ """Initialize the NumericInputWidget."""
90
+ super().__init__(WidgetType.NUMERIC_INPUT)
91
+
92
+ def convert_data(self, input_data: str) -> float | None:
93
+ """Get the numeric widget data in the correct format.
94
+
95
+ Args:
96
+ input_data (str): input string
97
+
98
+ Returns:
99
+ float | None: Numeric data or None if the input is not a number
100
+ """
101
+ try:
102
+ return float(input_data)
103
+ except ValueError:
104
+ return None
105
+
106
+
107
+ class RadiobuttonWidget(IWidget):
108
+ """Radiobutton widget."""
109
+
110
+ def __init__(self, fields: list[str]):
111
+ """Initialize the RadiobuttonWidget.
112
+
113
+ Args:
114
+ fields (list[str]): Radiobutton fields.
115
+
116
+ Raises:
117
+ ValueError: If the fields list is empty.
118
+ """
119
+ super().__init__(WidgetType.RADIOBUTTON)
120
+ if not fields:
121
+ raise ValueError("RadiobuttonWidget must have at least one field")
122
+ self.info["fields"] = fields
123
+
124
+ def convert_data(self, input_data: str) -> str:
125
+ """Get the radiobutton widget data in the correct format.
126
+
127
+ Args:
128
+ input_data (str): input string
129
+
130
+ Returns:
131
+ str: Radiobutton string data
132
+ """
133
+ return input_data
134
+
135
+
136
+ class CheckboxWidget(IWidget):
137
+ """Checkbox widget."""
138
+
139
+ def __init__(self, fields: list[str]):
140
+ """Initialize the CheckboxWidget.
141
+
142
+ Args:
143
+ fields (list[str]): Checkbox fields.
144
+
145
+ Raises:
146
+ ValueError: If the fields list is empty.
147
+ """
148
+ super().__init__(WidgetType.CHECKBOX)
149
+ if not fields:
150
+ raise ValueError("RadiobuttonWidget must have at least one field")
151
+ self.info["fields"] = fields
152
+
153
+ def convert_data(self, input_data: str) -> list[str] | None:
154
+ """Get the checkbox widget data in the correct format.
155
+
156
+ Args:
157
+ input_data (str): input string
158
+
159
+ Returns:
160
+ (list[str] | None): Checkbox string data or None if the input is not a list
161
+ """
162
+ try:
163
+ return literal_eval(input_data)
164
+ except ValueError:
165
+ return None
166
+
167
+
168
+ class ImageWidget(IWidget):
169
+ """Image widget."""
170
+
171
+ def __init__(self, address: str, format: str = "image", width: int = 100):
172
+ """Validate the image fields and defines the base64 if it does not exist.
173
+
174
+ Args:
175
+ address (str): image address
176
+ format (str): image format
177
+ width (int): image width
178
+
179
+ Raises:
180
+ WidgetInfoError: If both address and base64 are specified.
181
+ """
182
+ super().__init__(WidgetType.IMAGE)
183
+
184
+ if width < 1:
185
+ raise WidgetInfoError("Width must be positive")
186
+
187
+ self.info["address"] = address
188
+ self.info["format"] = format
189
+ self.info["width"] = width
190
+
191
+ try:
192
+ with open(address, "rb") as file:
193
+ file_data = file.read()
194
+ except FileNotFoundError:
195
+ raise WidgetInfoError("The image address is invalid")
196
+ self.info["base64"] = base64.b64encode(file_data).decode("utf-8")
197
+
198
+ def convert_data(self, input_data: str | None = None) -> bool:
199
+ """Get the image widget data, i.e. None.
200
+
201
+ Args:
202
+ input_data (str | None): input string or nothing.
203
+
204
+ Returns:
205
+ bool: True if confirm button is pressed
206
+ """
207
+ return True
208
+
209
+
210
+ class StepWidget(IWidget):
211
+ """Step widget.
21
212
 
22
213
  Args:
23
- type (DialogBoxWidgetType): widget type
24
- info (dict | None): widget info
214
+ title (str): Step title
215
+ text (str | None): Step text
216
+ widget (ImageWidget | None): Step widget
217
+
218
+ Raises:
219
+ WidgetInfoError: If the text or widget are not provided.
25
220
  """
26
221
 
27
- type: DialogBoxWidgetType
28
- info: dict | None = None
222
+ def __init__(self, title: str, text: str | None, widget: ImageWidget | None):
223
+ super().__init__(WidgetType.STEP)
224
+ if text is None and widget is None:
225
+ raise WidgetInfoError("Text or widget must be provided")
226
+ self.info["title"] = title
227
+ if isinstance(text, str):
228
+ self.info["text"] = text
229
+ if isinstance(widget, ImageWidget):
230
+ self.info["widget"] = widget.__dict__
231
+
232
+ def convert_data(self, input_data: str) -> bool:
233
+ """Get the step widget data in the correct format.
234
+
235
+ Args:
236
+ input_data (str): input string
237
+
238
+ Returns:
239
+ bool: True if confirm button is pressed
240
+ """
241
+ return True
242
+
243
+
244
+ class MultistepWidget(IWidget):
245
+ """Multistep widget."""
246
+
247
+ def __init__(self, steps: list[StepWidget]):
248
+ """Initialize the MultistepWidget.
249
+
250
+ Args:
251
+ steps (list[StepWidget]): A list with info about the steps.
252
+
253
+ Raises:
254
+ ValueError: If the provided list of steps is empty.
255
+ """
256
+ super().__init__(WidgetType.MULTISTEP)
257
+ if not steps:
258
+ raise ValueError("MultistepWidget must have at least one step")
259
+ self.info["steps"] = []
260
+ for step in steps:
261
+ self.info["steps"].append(step.__dict__)
262
+
263
+ def convert_data(self, input_data: str) -> bool:
264
+ """Get the multistep widget data in the correct format.
265
+
266
+ Args:
267
+ input_data (str): input string
268
+
269
+ Returns:
270
+ bool: True if confirm button is pressed
271
+ """
272
+ return True
29
273
 
30
274
 
31
275
  @dataclass
@@ -35,66 +279,25 @@ class DialogBox:
35
279
  Args:
36
280
  dialog_text (str): dialog text
37
281
  title_bar (str | None): title bar
38
- widget (DialogBoxWidget | None): widget info
282
+ widget (IWidget | None): widget info
39
283
  """
40
284
 
41
- dialog_text: str
42
- title_bar: str | None = None
43
- widget: DialogBoxWidget | None = None
44
-
45
-
46
- def generate_dialog_box_dict(dialog_box_data: DialogBox) -> dict:
47
- """Generate dialog box dictionary.
48
-
49
- Args:
50
- dialog_box_data (DialogBox): dialog box data
51
-
52
- Returns:
53
- dict: dialog box dictionary
54
- """
55
- if dialog_box_data.widget is None:
56
- data_dict = {
57
- "title_bar": dialog_box_data.title_bar,
58
- "dialog_text": dialog_box_data.dialog_text,
59
- "widget": None,
60
- }
61
- else:
62
- data_dict = {
63
- "title_bar": dialog_box_data.title_bar,
64
- "dialog_text": dialog_box_data.dialog_text,
65
- "widget": {
66
- "info": dialog_box_data.widget.info,
67
- "type": dialog_box_data.widget.type.value,
68
- },
69
- }
70
- return data_dict
71
-
72
-
73
- def get_dialog_box_data(
74
- input_data: str, widget: DialogBoxWidget | None
75
- ) -> Any:
76
- """Get the dialog box data in the correct format.
285
+ def __init__(
286
+ self,
287
+ dialog_text: str,
288
+ title_bar: str | None = None,
289
+ widget: IWidget | None = None,
290
+ ):
291
+ self.widget: IWidget = BaseWidget() if widget is None else widget
292
+ self.dialog_text: str = dialog_text
293
+ self.title_bar: str | None = title_bar
77
294
 
78
- Args:
79
- input_data (str): input string
80
- widget (DialogBoxWidget | None): widget info
295
+ def to_dict(self) -> dict:
296
+ """Convert DialogBox to dictionary.
81
297
 
82
- Returns:
83
- Any: Dialog box data in the correct format
84
- """
85
- if widget is None:
86
- return None
87
-
88
- if widget.type is None:
89
- raise ValueError("Widget type is `None`, but widget data is not empty")
90
-
91
- match widget.type:
92
- case DialogBoxWidgetType.NUMERIC_INPUT:
93
- try:
94
- return float(input_data)
95
- except ValueError:
96
- return None
97
- case DialogBoxWidgetType.TEXT_INPUT:
98
- return input_data
99
- case _:
100
- return None
298
+ Returns:
299
+ dict: DialogBox dictionary.
300
+ """
301
+ dbx_dict = deepcopy(self.__dict__)
302
+ dbx_dict["widget"] = deepcopy(self.widget.__dict__)
303
+ return dbx_dict
@@ -21,3 +21,10 @@ class DuplicateDialogBoxError(HardpyError):
21
21
 
22
22
  def __init__(self):
23
23
  super().__init__(self.__doc__) # type: ignore
24
+
25
+
26
+ class WidgetInfoError(HardpyError):
27
+ """The widget info is not correct."""
28
+
29
+ def __init__(self, message):
30
+ super().__init__(message)