hardpy 0.7.0__py3-none-any.whl → 0.8.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 +25 -27
  2. hardpy/cli/cli.py +1 -2
  3. hardpy/common/config.py +8 -8
  4. hardpy/hardpy_panel/frontend/dist/asset-manifest.json +3 -3
  5. hardpy/hardpy_panel/frontend/dist/index.html +1 -1
  6. hardpy/hardpy_panel/frontend/dist/logo192.png +0 -0
  7. hardpy/hardpy_panel/frontend/dist/static/css/main.e8a862f1.css.map +1 -1
  8. hardpy/hardpy_panel/frontend/dist/static/js/main.6f09d61a.js +3 -0
  9. hardpy/hardpy_panel/frontend/dist/static/js/main.6f09d61a.js.map +1 -0
  10. hardpy/pytest_hardpy/db/__init__.py +1 -1
  11. hardpy/pytest_hardpy/db/base_connector.py +1 -1
  12. hardpy/pytest_hardpy/db/base_store.py +9 -0
  13. hardpy/pytest_hardpy/db/schema/v1.py +4 -4
  14. hardpy/pytest_hardpy/db/statestore.py +0 -11
  15. hardpy/pytest_hardpy/plugin.py +52 -64
  16. hardpy/pytest_hardpy/pytest_call.py +5 -2
  17. hardpy/pytest_hardpy/pytest_wrapper.py +0 -1
  18. hardpy/pytest_hardpy/reporter/__init__.py +1 -1
  19. hardpy/pytest_hardpy/reporter/hook_reporter.py +3 -2
  20. hardpy/pytest_hardpy/result/__init__.py +1 -1
  21. hardpy/pytest_hardpy/utils/__init__.py +16 -12
  22. hardpy/pytest_hardpy/utils/dialog_box.py +81 -54
  23. hardpy/pytest_hardpy/utils/exception.py +6 -0
  24. {hardpy-0.7.0.dist-info → hardpy-0.8.0.dist-info}/METADATA +10 -8
  25. {hardpy-0.7.0.dist-info → hardpy-0.8.0.dist-info}/RECORD +29 -28
  26. hardpy/hardpy_panel/frontend/dist/static/js/main.942e57d4.js +0 -3
  27. hardpy/hardpy_panel/frontend/dist/static/js/main.942e57d4.js.map +0 -1
  28. /hardpy/hardpy_panel/frontend/dist/static/js/{main.942e57d4.js.LICENSE.txt → main.6f09d61a.js.LICENSE.txt} +0 -0
  29. {hardpy-0.7.0.dist-info → hardpy-0.8.0.dist-info}/WHEEL +0 -0
  30. {hardpy-0.7.0.dist-info → hardpy-0.8.0.dist-info}/entry_points.txt +0 -0
  31. {hardpy-0.7.0.dist-info → hardpy-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -12,6 +12,6 @@ __all__ = [
12
12
  "DatabaseField",
13
13
  "ResultRunStore",
14
14
  "ResultStateStore",
15
- "StateStore",
16
15
  "RunStore",
16
+ "StateStore",
17
17
  ]
@@ -3,7 +3,7 @@
3
3
 
4
4
  from pycouchdb.client import Database
5
5
  from pycouchdb.exceptions import Conflict, GenericError
6
- from requests.exceptions import ConnectionError
6
+ from requests.exceptions import ConnectionError # noqa: A004
7
7
 
8
8
  from hardpy.pytest_hardpy.db.base_server import BaseServer
9
9
 
@@ -69,6 +69,15 @@ class BaseStore(BaseConnector):
69
69
  self._doc = self._db.get(self._doc_id)
70
70
  return self._schema(**self._doc)
71
71
 
72
+ def clear(self) -> None:
73
+ """Clear database."""
74
+ try:
75
+ # Clear statestore and runstore databases before each launch
76
+ self._db.delete(self._doc_id)
77
+ except (Conflict, NotFound):
78
+ self._log.debug("Database will be created for the first time")
79
+ self._doc: dict = self._init_doc()
80
+
72
81
  def _init_doc(self) -> dict:
73
82
  try:
74
83
  doc = self._db.get(self._doc_id)
@@ -6,7 +6,7 @@ from typing import ClassVar
6
6
 
7
7
  from pydantic import BaseModel, ConfigDict, Field
8
8
 
9
- from hardpy.pytest_hardpy.utils import TestStatus as Status # noqa: TCH001
9
+ from hardpy.pytest_hardpy.utils import TestStatus as Status # noqa: TC001
10
10
 
11
11
 
12
12
  class IBaseResult(BaseModel):
@@ -177,7 +177,7 @@ class TestStand(BaseModel):
177
177
  "info": {
178
178
  "geo": "Belgrade"
179
179
  },
180
- "timezone": "CET",
180
+ "timezone": "Europe/Belgrade",
181
181
  "drivers": {
182
182
  "driver_1": "driver info",
183
183
  "driver_2": {
@@ -228,7 +228,7 @@ class ResultStateStore(IBaseResult):
228
228
  "info": {
229
229
  "geo": "Belgrade"
230
230
  },
231
- "timezone": "CET",
231
+ "timezone": "Europe/Belgrade",
232
232
  "drivers": {
233
233
  "driver_1": "driver info",
234
234
  "driver_2": {
@@ -324,7 +324,7 @@ class ResultRunStore(IBaseResult):
324
324
  "info": {
325
325
  "geo": "Belgrade"
326
326
  },
327
- "timezone": "CET",
327
+ "timezone": "Europe/Belgrade",
328
328
  "drivers": {
329
329
  "driver_1": "driver info",
330
330
  "driver_2": {
@@ -3,8 +3,6 @@
3
3
 
4
4
  from logging import getLogger
5
5
 
6
- from pycouchdb.exceptions import Conflict, NotFound
7
-
8
6
  from hardpy.pytest_hardpy.db.base_store import BaseStore
9
7
  from hardpy.pytest_hardpy.db.schema import ResultStateStore
10
8
  from hardpy.pytest_hardpy.utils import SingletonMeta
@@ -17,12 +15,3 @@ class StateStore(BaseStore, metaclass=SingletonMeta):
17
15
  super().__init__("statestore")
18
16
  self._log = getLogger(__name__)
19
17
  self._schema = ResultStateStore
20
-
21
- def clear(self) -> None:
22
- """Clear database."""
23
- try:
24
- # Clear the statestore database before each launch
25
- self._db.delete(self._doc_id)
26
- except (Conflict, NotFound):
27
- self._log.debug("Statestore database will be created for the first time")
28
- self._doc: dict = self._init_doc()
@@ -25,7 +25,7 @@ from pytest import (
25
25
  Parser,
26
26
  Session,
27
27
  TestReport,
28
- exit,
28
+ exit, # noqa: A004
29
29
  fixture,
30
30
  skip,
31
31
  )
@@ -63,7 +63,7 @@ def pytest_addoption(parser: Parser) -> None:
63
63
  )
64
64
  parser.addoption(
65
65
  "--hardpy-clear-database",
66
- action="store",
66
+ action="store_true",
67
67
  default=False,
68
68
  help="clear hardpy local database",
69
69
  )
@@ -116,7 +116,6 @@ class HardpyPlugin:
116
116
  con_data.database_url = str(database_url) # type: ignore
117
117
 
118
118
  is_clear_database = config.getoption("--hardpy-clear-database")
119
- is_clear_statestore = is_clear_database == str(True)
120
119
 
121
120
  socket_port = config.getoption("--hardpy-sp")
122
121
  if socket_port:
@@ -133,7 +132,7 @@ class HardpyPlugin:
133
132
 
134
133
  # must be init after config data is set
135
134
  try:
136
- self._reporter = HookReporter(is_clear_statestore)
135
+ self._reporter = HookReporter(bool(is_clear_database))
137
136
  except RuntimeError as exc:
138
137
  exit(str(exc), 1)
139
138
 
@@ -215,21 +214,24 @@ class HardpyPlugin:
215
214
 
216
215
  node_info = NodeInfo(item)
217
216
 
218
- self._handle_dependency(node_info)
217
+ status = TestStatus.RUN
218
+ is_skip_test = self._is_skip_test(node_info)
219
+ if not is_skip_test:
220
+ self._reporter.set_module_start_time(node_info.module_id)
221
+ self._reporter.set_case_start_time(node_info.module_id, node_info.case_id)
222
+ else:
223
+ status = TestStatus.SKIPPED
224
+ self._results[node_info.module_id][node_info.case_id] = status
225
+ progress = self._progress.calculate(item.nodeid)
226
+ self._reporter.set_progress(progress)
219
227
 
220
- self._reporter.set_module_status(node_info.module_id, TestStatus.RUN)
221
- self._reporter.set_module_start_time(node_info.module_id)
222
- self._reporter.set_case_status(
223
- node_info.module_id,
224
- node_info.case_id,
225
- TestStatus.RUN,
226
- )
227
- self._reporter.set_case_start_time(
228
- node_info.module_id,
229
- node_info.case_id,
230
- )
228
+ self._reporter.set_module_status(node_info.module_id, status)
229
+ self._reporter.set_case_status(node_info.module_id, node_info.case_id, status)
231
230
  self._reporter.update_db_by_doc()
232
231
 
232
+ if is_skip_test:
233
+ skip(f"Test {item.nodeid} is skipped")
234
+
233
235
  def pytest_runtest_call(self, item: Item) -> None:
234
236
  """Call the test item."""
235
237
  node_info = NodeInfo(item)
@@ -242,7 +244,7 @@ class HardpyPlugin:
242
244
 
243
245
  def pytest_runtest_makereport(self, item: Item, call: CallInfo) -> None:
244
246
  """Call after call of each test item."""
245
- if call.when != "call":
247
+ if call.when != "call" or not call.excinfo:
246
248
  return
247
249
 
248
250
  node_info = NodeInfo(item)
@@ -250,25 +252,22 @@ class HardpyPlugin:
250
252
  module_id = node_info.module_id
251
253
  case_id = node_info.case_id
252
254
 
253
- if call.excinfo:
254
- # first attempt was in pytest_runtest_call
255
- for current_attempt in range(2, attempt + 1):
256
- self._reporter.set_module_status(module_id, TestStatus.RUN)
257
- self._reporter.set_case_status(module_id, case_id, TestStatus.RUN)
258
- self._reporter.set_case_attempt(module_id, case_id, current_attempt)
259
- self._reporter.update_db_by_doc()
260
-
261
- # fmt: off
262
- try:
263
- item.runtest()
264
- call.excinfo = None
265
- self._reporter.set_case_status(module_id, case_id, TestStatus.PASSED) # noqa: E501
266
- break
267
- except AssertionError:
268
- self._reporter.set_case_status(module_id, case_id, TestStatus.FAILED) # noqa: E501
269
- if current_attempt == attempt:
270
- return
271
- # fmt: on
255
+ # first attempt was in pytest_runtest_call
256
+ for current_attempt in range(2, attempt + 1):
257
+ self._reporter.set_module_status(module_id, TestStatus.RUN)
258
+ self._reporter.set_case_status(module_id, case_id, TestStatus.RUN)
259
+ self._reporter.set_case_attempt(module_id, case_id, current_attempt)
260
+ self._reporter.update_db_by_doc()
261
+
262
+ try:
263
+ item.runtest()
264
+ call.excinfo = None
265
+ self._reporter.set_case_status(module_id, case_id, TestStatus.PASSED)
266
+ break
267
+ except AssertionError:
268
+ self._reporter.set_case_status(module_id, case_id, TestStatus.FAILED)
269
+ if current_attempt == attempt:
270
+ return
272
271
 
273
272
  # Reporting hooks
274
273
 
@@ -422,36 +421,25 @@ class HardpyPlugin:
422
421
  case _:
423
422
  return None
424
423
 
425
- def _handle_dependency(self, node_info: NodeInfo) -> None:
426
- dependency = self._dependencies.get(
427
- TestDependencyInfo(
428
- node_info.module_id,
429
- node_info.case_id,
430
- ),
424
+ def _is_skip_test(self, node_info: NodeInfo) -> bool:
425
+ """Is need to skip a test because it depends on another test."""
426
+ is_dependency_test_exist = self._dependencies.get(
427
+ TestDependencyInfo(node_info.module_id, node_info.case_id),
431
428
  )
432
- if dependency and self._is_dependency_failed(dependency):
433
- self._log.debug(f"Skipping test due to dependency: {dependency}")
434
- self._results[node_info.module_id][node_info.case_id] = TestStatus.SKIPPED
435
- self._reporter.set_progress(
436
- self._progress.calculate(f"{node_info.module_id}::{node_info.case_id}"),
437
- )
438
- skip(f"Test {node_info.module_id}::{node_info.case_id} is skipped")
439
-
440
- def _is_dependency_failed(self, dependency: TestDependencyInfo) -> bool:
441
- if isinstance(dependency, TestDependencyInfo):
442
- incorrect_status = {
443
- TestStatus.FAILED,
444
- TestStatus.SKIPPED,
445
- TestStatus.ERROR,
446
- }
447
- module_id, case_id = dependency
429
+ is_dependency_test_failed = False
430
+ if is_dependency_test_exist:
431
+ wrong_status = {TestStatus.FAILED, TestStatus.SKIPPED, TestStatus.ERROR}
432
+ module_id, case_id = is_dependency_test_exist
448
433
  if case_id is not None:
449
- return self._results[module_id][case_id] in incorrect_status
450
- return any(
451
- status in incorrect_status
452
- for status in set(self._results[module_id].values())
453
- )
454
- return False
434
+ is_dependency_test_failed = (
435
+ self._results[module_id][case_id] in wrong_status
436
+ )
437
+ else:
438
+ result_set = set(self._results[module_id].values())
439
+ is_dependency_test_failed = any(
440
+ status in wrong_status for status in result_set
441
+ )
442
+ return bool(is_dependency_test_exist and is_dependency_test_failed)
455
443
 
456
444
  def _add_dependency(self, node_info: NodeInfo, nodes: dict) -> None:
457
445
  dependency = node_info.dependency
@@ -79,7 +79,10 @@ def set_dut_serial_number(serial_number: str) -> None:
79
79
  key = reporter.generate_key(DF.DUT, DF.SERIAL_NUMBER)
80
80
  if reporter.get_field(key):
81
81
  raise DuplicateSerialNumberError
82
- reporter.set_doc_value(key, serial_number)
82
+ reporter.set_doc_value(
83
+ key,
84
+ serial_number if isinstance(serial_number, str) else str(serial_number),
85
+ )
83
86
  reporter.update_db_by_doc()
84
87
 
85
88
 
@@ -274,6 +277,7 @@ def run_dialog_box(dialog_box_data: DialogBox) -> Any: # noqa: ANN401
274
277
  - title_bar (str | None): The title bar of the dialog box.
275
278
  If the title_bar field is missing, it is the case name.
276
279
  - widget (DialogBoxWidget | None): Widget information.
280
+ - image (ImageComponent | None): Image information.
277
281
 
278
282
  Returns:
279
283
  Any: An object containing the user's response.
@@ -285,7 +289,6 @@ def run_dialog_box(dialog_box_data: DialogBox) -> Any: # noqa: ANN401
285
289
  - NUMERIC_INPUT: float.
286
290
  - RADIOBUTTON: str.
287
291
  - CHECKBOX: list[str].
288
- - IMAGE: bool.
289
292
  - MULTISTEP: bool.
290
293
 
291
294
  Raises:
@@ -116,7 +116,6 @@ class PyTestWrapper:
116
116
 
117
117
  if is_clear_database:
118
118
  args.append("--hardpy-clear-database")
119
- args.append(str(is_clear_database))
120
119
 
121
120
  subprocess.Popen( # noqa: S603
122
121
  [self.python_executable, *args],
@@ -5,6 +5,6 @@ from hardpy.pytest_hardpy.reporter.hook_reporter import HookReporter
5
5
  from hardpy.pytest_hardpy.reporter.runner_reporter import RunnerReporter
6
6
 
7
7
  __all__ = [
8
- "RunnerReporter",
9
8
  "HookReporter",
9
+ "RunnerReporter",
10
10
  ]
@@ -3,11 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from copy import deepcopy
6
- from datetime import datetime
7
6
  from logging import getLogger
8
7
  from time import time
9
8
 
10
9
  from natsort import natsorted
10
+ from tzlocal import get_localzone
11
11
 
12
12
  from hardpy.pytest_hardpy.db import DatabaseField as DF # noqa: N817
13
13
  from hardpy.pytest_hardpy.reporter.base import BaseReporter
@@ -21,6 +21,7 @@ class HookReporter(BaseReporter):
21
21
  super().__init__()
22
22
  if is_clear_database:
23
23
  self._statestore.clear()
24
+ self._runstore.clear()
24
25
  self._log = getLogger(__name__)
25
26
 
26
27
  def init_doc(self, doc_name: str) -> None:
@@ -38,7 +39,7 @@ class HookReporter(BaseReporter):
38
39
  self.set_doc_value(DF.OPERATOR_MSG, {}, statestore_only=True)
39
40
 
40
41
  test_stand_tz = self.generate_key(DF.TEST_STAND, DF.TIMEZONE)
41
- self.set_doc_value(test_stand_tz, datetime.now().astimezone().tzname())
42
+ self.set_doc_value(test_stand_tz, str(get_localzone().key))
42
43
 
43
44
  test_stand_id_key = self.generate_key(DF.TEST_STAND, DF.HW_ID)
44
45
  self.set_doc_value(test_stand_id_key, machine_id())
@@ -5,6 +5,6 @@ from hardpy.pytest_hardpy.result.report_loader.couchdb_loader import CouchdbLoad
5
5
  from hardpy.pytest_hardpy.result.report_reader.couchdb_reader import CouchdbReader
6
6
 
7
7
  __all__ = [
8
- "CouchdbReader",
9
8
  "CouchdbLoader",
9
+ "CouchdbReader",
10
10
  ]
@@ -4,9 +4,10 @@
4
4
  from hardpy.pytest_hardpy.utils.connection_data import ConnectionData
5
5
  from hardpy.pytest_hardpy.utils.const import TestStatus
6
6
  from hardpy.pytest_hardpy.utils.dialog_box import (
7
+ BaseWidget,
7
8
  CheckboxWidget,
8
9
  DialogBox,
9
- ImageWidget,
10
+ ImageComponent,
10
11
  MultistepWidget,
11
12
  NumericInputWidget,
12
13
  RadiobuttonWidget,
@@ -18,6 +19,7 @@ from hardpy.pytest_hardpy.utils.exception import (
18
19
  DuplicateSerialNumberError,
19
20
  DuplicateTestStandLocationError,
20
21
  DuplicateTestStandNameError,
22
+ ImageError,
21
23
  WidgetInfoError,
22
24
  )
23
25
  from hardpy.pytest_hardpy.utils.machineid import machine_id
@@ -26,23 +28,25 @@ from hardpy.pytest_hardpy.utils.progress_calculator import ProgressCalculator
26
28
  from hardpy.pytest_hardpy.utils.singleton import SingletonMeta
27
29
 
28
30
  __all__ = [
29
- "NodeInfo",
30
- "ProgressCalculator",
31
- "TestStatus",
32
- "SingletonMeta",
31
+ "BaseWidget",
32
+ "CheckboxWidget",
33
33
  "ConnectionData",
34
- "DuplicateSerialNumberError",
34
+ "DialogBox",
35
35
  "DuplicatePartNumberError",
36
+ "DuplicateSerialNumberError",
36
37
  "DuplicateTestStandLocationError",
37
38
  "DuplicateTestStandNameError",
38
- "WidgetInfoError",
39
- "DialogBox",
40
- "TextInputWidget",
39
+ "ImageComponent",
40
+ "ImageError",
41
+ "MultistepWidget",
42
+ "NodeInfo",
41
43
  "NumericInputWidget",
42
- "CheckboxWidget",
44
+ "ProgressCalculator",
43
45
  "RadiobuttonWidget",
44
- "ImageWidget",
45
- "MultistepWidget",
46
+ "SingletonMeta",
46
47
  "StepWidget",
48
+ "TestStatus",
49
+ "TextInputWidget",
50
+ "WidgetInfoError",
47
51
  "machine_id",
48
52
  ]
@@ -10,7 +10,7 @@ from dataclasses import dataclass
10
10
  from enum import Enum
11
11
  from typing import Any, Final
12
12
 
13
- from hardpy.pytest_hardpy.utils.exception import WidgetInfoError
13
+ from hardpy.pytest_hardpy.utils.exception import ImageError, WidgetInfoError
14
14
 
15
15
 
16
16
  class WidgetType(Enum):
@@ -21,7 +21,6 @@ class WidgetType(Enum):
21
21
  NUMERIC_INPUT = "numericinput"
22
22
  RADIOBUTTON = "radiobutton"
23
23
  CHECKBOX = "checkbox"
24
- IMAGE = "image"
25
24
  STEP = "step"
26
25
  MULTISTEP = "multistep"
27
26
 
@@ -49,8 +48,11 @@ class IWidget(ABC):
49
48
  class BaseWidget(IWidget):
50
49
  """Widget info interface."""
51
50
 
52
- def __init__(self, widget_type: WidgetType = WidgetType.BASE) -> None: # noqa: ARG002
53
- super().__init__(WidgetType.BASE)
51
+ def __init__(
52
+ self,
53
+ widget_type: WidgetType = WidgetType.BASE,
54
+ ) -> None:
55
+ super().__init__(widget_type)
54
56
 
55
57
  def convert_data(self, input_data: str | None = None) -> bool: # noqa: ARG002
56
58
  """Get base widget data, i.e. None.
@@ -121,6 +123,9 @@ class RadiobuttonWidget(IWidget):
121
123
  if not fields:
122
124
  msg = "RadiobuttonWidget must have at least one field"
123
125
  raise ValueError(msg)
126
+ if len(fields) != len(set(fields)):
127
+ msg = "RadiobuttonWidget fields must be unique"
128
+ raise ValueError(msg)
124
129
  self.info["fields"] = fields
125
130
 
126
131
  def convert_data(self, input_data: str) -> str:
@@ -151,6 +156,9 @@ class CheckboxWidget(IWidget):
151
156
  if not fields:
152
157
  msg = "Checkbox must have at least one field"
153
158
  raise ValueError(msg)
159
+ if len(fields) != len(set(fields)):
160
+ msg = "CheckboxWidget fields must be unique"
161
+ raise ValueError(msg)
154
162
  self.info["fields"] = fields
155
163
 
156
164
  def convert_data(self, input_data: str) -> list[str] | None:
@@ -168,57 +176,13 @@ class CheckboxWidget(IWidget):
168
176
  return None
169
177
 
170
178
 
171
- class ImageWidget(IWidget):
172
- """Image widget."""
173
-
174
- def __init__(self, address: str, format: str = "image", width: int = 100) -> None: # noqa: A002
175
- """Validate the image fields and defines the base64 if it does not exist.
176
-
177
- Args:
178
- address (str): image address
179
- format (str): image format
180
- width (int): image width
181
-
182
- Raises:
183
- WidgetInfoError: If both address and base64 are specified.
184
- """
185
- super().__init__(WidgetType.IMAGE)
186
-
187
- if width < 1:
188
- msg = "Width must be positive"
189
- raise WidgetInfoError(msg)
190
-
191
- self.info["address"] = address
192
- self.info["format"] = format
193
- self.info["width"] = width
194
-
195
- try:
196
- with open(address, "rb") as file: # noqa: PTH123
197
- file_data = file.read()
198
- except FileNotFoundError:
199
- msg = "The image address is invalid"
200
- raise WidgetInfoError(msg) # noqa: B904
201
- self.info["base64"] = base64.b64encode(file_data).decode("utf-8")
202
-
203
- def convert_data(self, input_data: str | None = None) -> bool: # noqa: ARG002
204
- """Get the image widget data, i.e. None.
205
-
206
- Args:
207
- input_data (str | None): input string or nothing.
208
-
209
- Returns:
210
- bool: True if confirm button is pressed
211
- """
212
- return True
213
-
214
-
215
179
  class StepWidget(IWidget):
216
180
  """Step widget.
217
181
 
218
182
  Args:
219
183
  title (str): Step title
220
184
  text (str | None): Step text
221
- widget (ImageWidget | None): Step widget
185
+ image (ImageComponent | None): Step image
222
186
 
223
187
  Raises:
224
188
  WidgetInfoError: If the text or widget are not provided.
@@ -228,17 +192,17 @@ class StepWidget(IWidget):
228
192
  self,
229
193
  title: str,
230
194
  text: str | None,
231
- widget: ImageWidget | None,
195
+ image: ImageComponent | None = None,
232
196
  ) -> None:
233
197
  super().__init__(WidgetType.STEP)
234
- if text is None and widget is None:
235
- msg = "Text or widget must be provided"
198
+ if text is None and image is None:
199
+ msg = "Text or image must be provided"
236
200
  raise WidgetInfoError(msg)
237
201
  self.info["title"] = title
238
202
  if isinstance(text, str):
239
203
  self.info["text"] = text
240
- if isinstance(widget, ImageWidget):
241
- self.info["widget"] = widget.__dict__
204
+ if isinstance(image, ImageComponent):
205
+ self.info["image"] = image.__dict__
242
206
 
243
207
  def convert_data(self, input_data: str) -> bool: # noqa: ARG002
244
208
  """Get the step widget data in the correct format.
@@ -268,8 +232,14 @@ class MultistepWidget(IWidget):
268
232
  if not steps:
269
233
  msg = "MultistepWidget must have at least one step"
270
234
  raise ValueError(msg)
235
+ title_set = set()
271
236
  self.info["steps"] = []
272
237
  for step in steps:
238
+ title = step.info["title"]
239
+ if title in title_set:
240
+ msg = "MultistepWidget must have unique step titles"
241
+ raise ValueError(msg)
242
+ title_set.add(title)
273
243
  self.info["steps"].append(step.__dict__)
274
244
 
275
245
  def convert_data(self, input_data: str) -> bool: # noqa: ARG002
@@ -284,6 +254,58 @@ class MultistepWidget(IWidget):
284
254
  return True
285
255
 
286
256
 
257
+ class ImageComponent:
258
+ """Image component."""
259
+
260
+ def __init__(
261
+ self,
262
+ address: str,
263
+ width: int = 100,
264
+ border: int = 0,
265
+ ) -> None:
266
+ """Validate the image fields and defines the base64 if it does not exist.
267
+
268
+ Args:
269
+ address (str): image address
270
+ width (int): image width
271
+ border (int): image border
272
+
273
+ Raises:
274
+ ImageError: If both address and base64data are specified.
275
+ """
276
+ if width < 1:
277
+ msg = "Width must be positive"
278
+ raise WidgetInfoError(msg)
279
+
280
+ if border < 0:
281
+ msg = "Border must be non-negative"
282
+ raise WidgetInfoError(msg)
283
+
284
+ try:
285
+ with open(address, "rb") as file: # noqa: PTH123
286
+ file_data = file.read()
287
+ except FileNotFoundError:
288
+ msg = "The image address is invalid"
289
+ raise ImageError(msg) # noqa: B904
290
+ self.address = address
291
+ self.width = width
292
+ self.border = border
293
+ self.base64 = base64.b64encode(file_data).decode("utf-8")
294
+
295
+ def to_dict(self) -> dict:
296
+ """Convert ImageComponent to dictionary.
297
+
298
+ Returns:
299
+ dict: ImageComponent dictionary.
300
+ """
301
+ return {
302
+ "address": self.address,
303
+ "width": self.width,
304
+ "base64": self.base64,
305
+ "border": self.border,
306
+ }
307
+
308
+
287
309
  @dataclass
288
310
  class DialogBox:
289
311
  """Dialog box data.
@@ -292,6 +314,7 @@ class DialogBox:
292
314
  dialog_text (str): dialog text
293
315
  title_bar (str | None): title bar
294
316
  widget (IWidget | None): widget info
317
+ image (ImageComponent | None): image
295
318
  """
296
319
 
297
320
  def __init__(
@@ -299,8 +322,10 @@ class DialogBox:
299
322
  dialog_text: str,
300
323
  title_bar: str | None = None,
301
324
  widget: IWidget | None = None,
325
+ image: ImageComponent | None = None,
302
326
  ) -> None:
303
327
  self.widget: IWidget = BaseWidget() if widget is None else widget
328
+ self.image: ImageComponent | None = image
304
329
  self.dialog_text: str = dialog_text
305
330
  self.title_bar: str | None = title_bar
306
331
 
@@ -312,4 +337,6 @@ class DialogBox:
312
337
  """
313
338
  dbx_dict = deepcopy(self.__dict__)
314
339
  dbx_dict["widget"] = deepcopy(self.widget.__dict__)
340
+ if self.image:
341
+ dbx_dict["image"] = deepcopy(self.image.__dict__)
315
342
  return dbx_dict
@@ -42,3 +42,9 @@ class WidgetInfoError(HardpyError):
42
42
 
43
43
  def __init__(self, message: str) -> None:
44
44
  super().__init__(message)
45
+
46
+ class ImageError(HardpyError):
47
+ """The image info is not correct."""
48
+
49
+ def __init__(self, message: str) -> None:
50
+ super().__init__(message)