hardpy 0.19.1__py3-none-any.whl → 0.20.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.
- hardpy/__init__.py +9 -0
- hardpy/cli/cli.py +29 -0
- hardpy/common/config.py +19 -0
- hardpy/hardpy_panel/api.py +62 -1
- hardpy/hardpy_panel/frontend/dist/assets/{allPaths-C_-7WXHD.js → allPaths-CLpOX_m7.js} +1 -1
- hardpy/hardpy_panel/frontend/dist/assets/{allPathsLoader-DgH0Xily.js → allPathsLoader-BZScMaOY.js} +2 -2
- hardpy/hardpy_panel/frontend/dist/assets/{browser-ponyfill-BbOvdqIF.js → browser-ponyfill-BTcpcLno.js} +1 -1
- hardpy/hardpy_panel/frontend/dist/assets/{index-DEJb2W0B.js → index-1i9Rm0C7.js} +189 -322
- hardpy/hardpy_panel/frontend/dist/assets/index-DN3Ur-pw.css +1 -0
- hardpy/hardpy_panel/frontend/dist/assets/{splitPathsBySizeLoader-o5HCcdVL.js → splitPathsBySizeLoader-CfM4kXSA.js} +1 -1
- hardpy/hardpy_panel/frontend/dist/index.html +2 -2
- hardpy/pytest_hardpy/db/__init__.py +0 -2
- hardpy/pytest_hardpy/db/runstore.py +378 -10
- hardpy/pytest_hardpy/db/statestore.py +390 -5
- hardpy/pytest_hardpy/db/tempstore.py +219 -17
- hardpy/pytest_hardpy/plugin.py +2 -2
- hardpy/pytest_hardpy/result/__init__.py +2 -0
- hardpy/pytest_hardpy/result/report_loader/json_loader.py +49 -0
- hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py +25 -9
- {hardpy-0.19.1.dist-info → hardpy-0.20.1.dist-info}/METADATA +32 -15
- {hardpy-0.19.1.dist-info → hardpy-0.20.1.dist-info}/RECORD +24 -24
- hardpy/hardpy_panel/frontend/dist/assets/index-B7T9xvaW.css +0 -1
- hardpy/pytest_hardpy/db/base_store.py +0 -179
- {hardpy-0.19.1.dist-info → hardpy-0.20.1.dist-info}/WHEEL +0 -0
- {hardpy-0.19.1.dist-info → hardpy-0.20.1.dist-info}/entry_points.txt +0 -0
- {hardpy-0.19.1.dist-info → hardpy-0.20.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,17 +1,402 @@
|
|
|
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 __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from json import dumps
|
|
4
9
|
from logging import getLogger
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from glom import PathAccessError, assign, glom
|
|
5
14
|
|
|
15
|
+
from hardpy.common.config import ConfigManager, StorageType
|
|
6
16
|
from hardpy.common.singleton import SingletonMeta
|
|
7
|
-
from hardpy.pytest_hardpy.db.
|
|
17
|
+
from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817
|
|
8
18
|
from hardpy.pytest_hardpy.db.schema import ResultStateStore
|
|
9
19
|
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pycouchdb.client import Database # type: ignore[import-untyped]
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _create_default_doc_structure(doc_id: str, doc_id_for_rev: str) -> dict:
|
|
26
|
+
"""Create default document structure with standard fields.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
doc_id (str): Document ID to use
|
|
30
|
+
doc_id_for_rev (str): Document ID for _rev field (for JSON compatibility)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
dict: Default document structure
|
|
34
|
+
"""
|
|
35
|
+
return {
|
|
36
|
+
"_id": doc_id,
|
|
37
|
+
"_rev": doc_id_for_rev,
|
|
38
|
+
DF.MODULES: {},
|
|
39
|
+
DF.DUT: {
|
|
40
|
+
DF.TYPE: None,
|
|
41
|
+
DF.NAME: None,
|
|
42
|
+
DF.REVISION: None,
|
|
43
|
+
DF.SERIAL_NUMBER: None,
|
|
44
|
+
DF.PART_NUMBER: None,
|
|
45
|
+
DF.SUB_UNITS: [],
|
|
46
|
+
DF.INFO: {},
|
|
47
|
+
},
|
|
48
|
+
DF.TEST_STAND: {
|
|
49
|
+
DF.HW_ID: None,
|
|
50
|
+
DF.NAME: None,
|
|
51
|
+
DF.REVISION: None,
|
|
52
|
+
DF.TIMEZONE: None,
|
|
53
|
+
DF.LOCATION: None,
|
|
54
|
+
DF.NUMBER: None,
|
|
55
|
+
DF.INSTRUMENTS: [],
|
|
56
|
+
DF.DRIVERS: {},
|
|
57
|
+
DF.INFO: {},
|
|
58
|
+
},
|
|
59
|
+
DF.PROCESS: {
|
|
60
|
+
DF.NAME: None,
|
|
61
|
+
DF.NUMBER: None,
|
|
62
|
+
DF.INFO: {},
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class StateStoreInterface(ABC):
|
|
68
|
+
"""Interface for state storage implementations."""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def get_field(self, key: str) -> Any: # noqa: ANN401
|
|
72
|
+
"""Get field from the state store.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
key (str): Field key, supports nested access with dots
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Any: Field value
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401
|
|
83
|
+
"""Update document value in memory (does not persist).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
key (str): Field key, supports nested access with dots
|
|
87
|
+
value (Any): Value to set
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def update_db(self) -> None:
|
|
92
|
+
"""Persist in-memory document to storage backend."""
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def update_doc(self) -> None:
|
|
96
|
+
"""Reload document from storage backend to memory."""
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def get_document(self) -> BaseModel:
|
|
100
|
+
"""Get full document with schema validation.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
BaseModel: Validated document model
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def clear(self) -> None:
|
|
108
|
+
"""Clear storage and reset to initial state."""
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def compact(self) -> None:
|
|
112
|
+
"""Optimize storage (implementation-specific, may be no-op)."""
|
|
10
113
|
|
|
11
|
-
|
|
12
|
-
|
|
114
|
+
|
|
115
|
+
class JsonStateStore(StateStoreInterface):
|
|
116
|
+
"""JSON file-based state storage implementation.
|
|
117
|
+
|
|
118
|
+
Stores test execution state using JSON files.
|
|
119
|
+
"""
|
|
13
120
|
|
|
14
121
|
def __init__(self) -> None:
|
|
15
|
-
|
|
122
|
+
config_manager = ConfigManager()
|
|
123
|
+
self._store_name = "statestore"
|
|
124
|
+
config_storage_path = Path(config_manager.config.database.storage_path)
|
|
125
|
+
if config_storage_path.is_absolute():
|
|
126
|
+
self._storage_dir = config_storage_path / "storage" / self._store_name
|
|
127
|
+
else:
|
|
128
|
+
self._storage_dir = Path(
|
|
129
|
+
config_manager.tests_path
|
|
130
|
+
/ config_manager.config.database.storage_path
|
|
131
|
+
/ "storage"
|
|
132
|
+
/ self._store_name,
|
|
133
|
+
)
|
|
134
|
+
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
self._doc_id = config_manager.config.database.doc_id
|
|
136
|
+
self._file_path = self._storage_dir / f"{self._doc_id}.json"
|
|
16
137
|
self._log = getLogger(__name__)
|
|
17
|
-
self._schema = ResultStateStore
|
|
138
|
+
self._schema: type[BaseModel] = ResultStateStore
|
|
139
|
+
self._doc: dict = self._init_doc()
|
|
140
|
+
|
|
141
|
+
def get_field(self, key: str) -> Any: # noqa: ANN401
|
|
142
|
+
"""Get field value from document using dot notation.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
key (str): Field key, supports nested access with dots
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Any: Field value, or None if path does not exist
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
return glom(self._doc, key)
|
|
152
|
+
except PathAccessError:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401
|
|
156
|
+
"""Update document value in memory (does not persist).
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
key (str): Field key, supports nested access with dots
|
|
160
|
+
value (Any): Value to set
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
dumps(value)
|
|
164
|
+
except Exception: # noqa: BLE001
|
|
165
|
+
value = dumps(value, default=str)
|
|
166
|
+
|
|
167
|
+
if "." in key:
|
|
168
|
+
assign(self._doc, key, value, missing=dict)
|
|
169
|
+
else:
|
|
170
|
+
self._doc[key] = value
|
|
171
|
+
|
|
172
|
+
def update_db(self) -> None:
|
|
173
|
+
"""Persist in-memory document to JSON file with atomic write."""
|
|
174
|
+
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
temp_file = self._file_path.with_suffix(".tmp")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
with temp_file.open("w") as f:
|
|
179
|
+
json.dump(self._doc, f, indent=2, default=str)
|
|
180
|
+
temp_file.replace(self._file_path)
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
self._log.error(f"Error writing to storage file: {exc}")
|
|
183
|
+
if temp_file.exists():
|
|
184
|
+
temp_file.unlink()
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
def update_doc(self) -> None:
|
|
188
|
+
"""Reload document from JSON file to memory."""
|
|
189
|
+
if self._file_path.exists():
|
|
190
|
+
try:
|
|
191
|
+
with self._file_path.open("r") as f:
|
|
192
|
+
self._doc = json.load(f)
|
|
193
|
+
except json.JSONDecodeError as exc:
|
|
194
|
+
self._log.error(f"Error reading storage file: {exc}")
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
self._log.error(f"Error reading storage file: {exc}")
|
|
197
|
+
raise
|
|
198
|
+
|
|
199
|
+
def get_document(self) -> BaseModel:
|
|
200
|
+
"""Get full document with schema validation.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
BaseModel: Validated document model
|
|
204
|
+
"""
|
|
205
|
+
self.update_doc()
|
|
206
|
+
return self._schema(**self._doc)
|
|
207
|
+
|
|
208
|
+
def clear(self) -> None:
|
|
209
|
+
"""Clear storage by resetting to initial state (in-memory only)."""
|
|
210
|
+
self._doc = _create_default_doc_structure(self._doc_id, self._doc_id)
|
|
211
|
+
|
|
212
|
+
def compact(self) -> None:
|
|
213
|
+
"""Optimize storage (no-op for JSON file storage)."""
|
|
214
|
+
|
|
215
|
+
def _init_doc(self) -> dict:
|
|
216
|
+
"""Initialize or load document structure."""
|
|
217
|
+
if self._file_path.exists():
|
|
218
|
+
try:
|
|
219
|
+
with self._file_path.open("r") as f:
|
|
220
|
+
doc = json.load(f)
|
|
221
|
+
|
|
222
|
+
if DF.MODULES not in doc:
|
|
223
|
+
doc[DF.MODULES] = {}
|
|
224
|
+
|
|
225
|
+
# Reset volatile fields for statestore
|
|
226
|
+
default_doc = _create_default_doc_structure(doc["_id"],
|
|
227
|
+
self._doc_id)
|
|
228
|
+
doc[DF.DUT] = default_doc[DF.DUT]
|
|
229
|
+
doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND]
|
|
230
|
+
doc[DF.PROCESS] = default_doc[DF.PROCESS]
|
|
231
|
+
|
|
232
|
+
return doc
|
|
233
|
+
except json.JSONDecodeError:
|
|
234
|
+
self._log.warning(
|
|
235
|
+
f"Corrupted storage file {self._file_path}, creating new",
|
|
236
|
+
)
|
|
237
|
+
except Exception as exc: # noqa: BLE001
|
|
238
|
+
self._log.warning(f"Error loading storage file: {exc}, creating new")
|
|
239
|
+
|
|
240
|
+
return _create_default_doc_structure(self._doc_id, self._doc_id)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class CouchDBStateStore(StateStoreInterface):
|
|
244
|
+
"""CouchDB-based state storage implementation.
|
|
245
|
+
|
|
246
|
+
Stores test execution state using CouchDB.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(self) -> None:
|
|
250
|
+
from pycouchdb import Server as DbServer # type: ignore[import-untyped]
|
|
251
|
+
from pycouchdb.exceptions import ( # type: ignore[import-untyped]
|
|
252
|
+
Conflict,
|
|
253
|
+
GenericError,
|
|
254
|
+
)
|
|
255
|
+
from requests.exceptions import ConnectionError # noqa: A004
|
|
256
|
+
|
|
257
|
+
config_manager = ConfigManager()
|
|
258
|
+
config = config_manager.config
|
|
259
|
+
self._db_srv = DbServer(config.database.url)
|
|
260
|
+
self._db_name = "statestore"
|
|
261
|
+
self._doc_id = config.database.doc_id
|
|
262
|
+
self._log = getLogger(__name__)
|
|
263
|
+
self._schema: type[BaseModel] = ResultStateStore
|
|
264
|
+
|
|
265
|
+
# Initialize database
|
|
266
|
+
try:
|
|
267
|
+
self._db: Database = self._db_srv.create(self._db_name) # type: ignore[name-defined]
|
|
268
|
+
except Conflict:
|
|
269
|
+
self._db = self._db_srv.database(self._db_name)
|
|
270
|
+
except GenericError as exc:
|
|
271
|
+
msg = f"Error initializing database {exc}"
|
|
272
|
+
raise RuntimeError(msg) from exc
|
|
273
|
+
except ConnectionError as exc:
|
|
274
|
+
msg = f"Error initializing database: {exc}"
|
|
275
|
+
raise RuntimeError(msg) from exc
|
|
276
|
+
|
|
277
|
+
self._doc: dict = self._init_doc()
|
|
278
|
+
|
|
279
|
+
def get_field(self, key: str) -> Any: # noqa: ANN401
|
|
280
|
+
"""Get field from the state store.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
key (str): Field key, supports nested access with dots
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Any: Field value, or None if path does not exist
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
return glom(self._doc, key)
|
|
290
|
+
except PathAccessError:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401
|
|
294
|
+
"""Update document value in memory (does not persist).
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
key (str): Field key, supports nested access with dots
|
|
298
|
+
value (Any): Value to set
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
dumps(value)
|
|
302
|
+
except Exception: # noqa: BLE001
|
|
303
|
+
value = dumps(value, default=str)
|
|
304
|
+
|
|
305
|
+
if "." in key:
|
|
306
|
+
assign(self._doc, key, value, missing=dict)
|
|
307
|
+
else:
|
|
308
|
+
self._doc[key] = value
|
|
309
|
+
|
|
310
|
+
def update_db(self) -> None:
|
|
311
|
+
"""Persist in-memory document to storage backend."""
|
|
312
|
+
from pycouchdb.exceptions import Conflict # type: ignore[import-untyped]
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
self._doc = self._db.save(self._doc)
|
|
316
|
+
except Conflict:
|
|
317
|
+
self._doc["_rev"] = self._db.get(self._doc_id)["_rev"]
|
|
318
|
+
self._doc = self._db.save(self._doc)
|
|
319
|
+
|
|
320
|
+
def update_doc(self) -> None:
|
|
321
|
+
"""Reload document from storage backend to memory."""
|
|
322
|
+
self._doc["_rev"] = self._db.get(self._doc_id)["_rev"]
|
|
323
|
+
self._doc = self._db.get(self._doc_id)
|
|
324
|
+
|
|
325
|
+
def get_document(self) -> BaseModel:
|
|
326
|
+
"""Get full document with schema validation.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
BaseModel: Validated document model
|
|
330
|
+
"""
|
|
331
|
+
self._doc = self._db.get(self._doc_id)
|
|
332
|
+
return self._schema(**self._doc)
|
|
333
|
+
|
|
334
|
+
def clear(self) -> None:
|
|
335
|
+
"""Clear storage and reset to initial state."""
|
|
336
|
+
from pycouchdb.exceptions import ( # type: ignore[import-untyped]
|
|
337
|
+
Conflict,
|
|
338
|
+
NotFound,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
self._db.delete(self._doc_id)
|
|
343
|
+
except (Conflict, NotFound):
|
|
344
|
+
self._log.debug("Database will be created for the first time")
|
|
345
|
+
self._doc = self._init_doc()
|
|
346
|
+
|
|
347
|
+
def compact(self) -> None:
|
|
348
|
+
"""Optimize storage (implementation-specific, may be no-op)."""
|
|
349
|
+
self._db.compact()
|
|
350
|
+
|
|
351
|
+
def _init_doc(self) -> dict:
|
|
352
|
+
"""Initialize or load document structure."""
|
|
353
|
+
from pycouchdb.exceptions import NotFound # type: ignore[import-untyped]
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
doc = self._db.get(self._doc_id)
|
|
357
|
+
except NotFound:
|
|
358
|
+
# CouchDB doesn't need _rev field in the default structure
|
|
359
|
+
default = _create_default_doc_structure(self._doc_id, self._doc_id)
|
|
360
|
+
del default["_rev"] # CouchDB manages _rev automatically
|
|
361
|
+
return default
|
|
362
|
+
|
|
363
|
+
if DF.MODULES not in doc:
|
|
364
|
+
doc[DF.MODULES] = {}
|
|
365
|
+
|
|
366
|
+
# Reset volatile fields
|
|
367
|
+
default_doc = _create_default_doc_structure(doc["_id"], self._doc_id)
|
|
368
|
+
doc[DF.DUT] = default_doc[DF.DUT]
|
|
369
|
+
doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND]
|
|
370
|
+
doc[DF.PROCESS] = default_doc[DF.PROCESS]
|
|
371
|
+
|
|
372
|
+
return doc
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class StateStore(metaclass=SingletonMeta):
|
|
376
|
+
"""HardPy state storage factory for test execution state.
|
|
377
|
+
|
|
378
|
+
Creates appropriate storage backend based on configuration:
|
|
379
|
+
- JSON file storage when storage_type is "json"
|
|
380
|
+
- CouchDB storage when storage_type is "couchdb"
|
|
381
|
+
|
|
382
|
+
This ensures state data is stored in the same backend as the main data.
|
|
383
|
+
|
|
384
|
+
Note: This class acts as a factory. When instantiated, it returns
|
|
385
|
+
the appropriate concrete implementation (JsonStateStore or CouchDBStateStore).
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
def __new__(cls) -> StateStoreInterface: # type: ignore[misc]
|
|
389
|
+
"""Create and return the appropriate storage implementation.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
StateStoreInterface: Concrete storage implementation based on config
|
|
393
|
+
"""
|
|
394
|
+
config = ConfigManager()
|
|
395
|
+
storage_type = config.config.database.storage_type
|
|
396
|
+
|
|
397
|
+
if storage_type == StorageType.JSON:
|
|
398
|
+
return JsonStateStore()
|
|
399
|
+
if storage_type == StorageType.COUCHDB:
|
|
400
|
+
return CouchDBStateStore()
|
|
401
|
+
msg = f"Unknown storage type: {storage_type}"
|
|
402
|
+
raise ValueError(msg)
|
|
@@ -3,52 +3,254 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import json
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
6
8
|
from logging import getLogger
|
|
9
|
+
from pathlib import Path
|
|
7
10
|
from typing import TYPE_CHECKING
|
|
8
11
|
|
|
9
|
-
from
|
|
12
|
+
from uuid6 import uuid7
|
|
10
13
|
|
|
14
|
+
from hardpy.common.config import ConfigManager, StorageType
|
|
11
15
|
from hardpy.common.singleton import SingletonMeta
|
|
12
|
-
from hardpy.pytest_hardpy.db.base_store import BaseStore
|
|
13
16
|
from hardpy.pytest_hardpy.db.schema import ResultRunStore
|
|
14
17
|
|
|
15
18
|
if TYPE_CHECKING:
|
|
16
19
|
from collections.abc import Generator
|
|
17
20
|
|
|
18
21
|
|
|
19
|
-
class
|
|
20
|
-
"""
|
|
22
|
+
class TempStoreInterface(ABC):
|
|
23
|
+
"""Interface for temporary storage implementations."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def push_report(self, report: ResultRunStore) -> bool:
|
|
27
|
+
"""Push report to the temporary storage.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
report (ResultRunStore): report to store
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
bool: True if successful, False otherwise
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def reports(self) -> Generator[dict]:
|
|
38
|
+
"""Get all reports from the temporary storage.
|
|
39
|
+
|
|
40
|
+
Yields:
|
|
41
|
+
dict: report from temporary storage
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def delete(self, report_id: str) -> bool:
|
|
46
|
+
"""Delete report from the temporary storage.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
report_id (str): report ID to delete
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
bool: True if successful, False otherwise
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def dict_to_schema(self, report: dict) -> ResultRunStore:
|
|
56
|
+
"""Convert report dict to report schema.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
report (dict): report dictionary
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
ResultRunStore: validated report schema
|
|
63
|
+
"""
|
|
64
|
+
return ResultRunStore(**report)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class JsonTempStore(TempStoreInterface):
|
|
68
|
+
"""JSON file-based temporary storage implementation.
|
|
69
|
+
|
|
70
|
+
Stores reports temporarily when StandCloud sync fails using JSON files.
|
|
71
|
+
"""
|
|
21
72
|
|
|
22
73
|
def __init__(self) -> None:
|
|
23
|
-
super().__init__("tempstore")
|
|
24
74
|
self._log = getLogger(__name__)
|
|
25
|
-
|
|
75
|
+
config = ConfigManager()
|
|
76
|
+
config_storage_path = Path(config.config.database.storage_path)
|
|
77
|
+
if config_storage_path.is_absolute():
|
|
78
|
+
self._storage_dir = config_storage_path / "storage" / "tempstore"
|
|
79
|
+
else:
|
|
80
|
+
self._storage_dir = Path(
|
|
81
|
+
config.tests_path
|
|
82
|
+
/ config.config.database.storage_path
|
|
83
|
+
/ "storage"
|
|
84
|
+
/ "tempstore",
|
|
85
|
+
)
|
|
86
|
+
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
|
26
87
|
self._schema = ResultRunStore
|
|
27
88
|
|
|
28
89
|
def push_report(self, report: ResultRunStore) -> bool:
|
|
29
|
-
"""Push report to the
|
|
90
|
+
"""Push report to the temporary storage.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
report (ResultRunStore): report to store
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
bool: True if successful, False otherwise
|
|
97
|
+
"""
|
|
30
98
|
report_dict = report.model_dump()
|
|
31
|
-
|
|
99
|
+
report_dict.pop("id", None)
|
|
100
|
+
report_id = str(uuid7())
|
|
101
|
+
report_dict["_id"] = report_id
|
|
102
|
+
report_dict["_rev"] = report_id
|
|
103
|
+
report_file = self._storage_dir / f"{report_id}.json"
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
with report_file.open("w") as f:
|
|
107
|
+
json.dump(report_dict, f, indent=2, default=str)
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
self._log.error(f"Error while saving report {report_id}: {exc}")
|
|
110
|
+
return False
|
|
111
|
+
else:
|
|
112
|
+
self._log.debug(f"Report saved with id: {report_id}")
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def reports(self) -> Generator[dict]:
|
|
116
|
+
"""Get all reports from the temporary storage.
|
|
117
|
+
|
|
118
|
+
Yields:
|
|
119
|
+
dict: report from temporary storage
|
|
120
|
+
"""
|
|
121
|
+
for report_file in self._storage_dir.glob("*.json"):
|
|
122
|
+
try:
|
|
123
|
+
with report_file.open("r") as f:
|
|
124
|
+
report_dict = json.load(f)
|
|
125
|
+
yield report_dict
|
|
126
|
+
except Exception as exc: # noqa: BLE001, PERF203
|
|
127
|
+
self._log.error(f"Error loading report from {report_file}: {exc}")
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
def delete(self, report_id: str) -> bool:
|
|
131
|
+
"""Delete report from the temporary storage.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
report_id (str): report ID to delete
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
bool: True if successful, False otherwise
|
|
138
|
+
"""
|
|
139
|
+
report_file = self._storage_dir / f"{report_id}.json"
|
|
140
|
+
try:
|
|
141
|
+
report_file.unlink()
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
self._log.warning(f"Report {report_id} not found in temporary storage")
|
|
144
|
+
return False
|
|
145
|
+
except Exception as exc: # noqa: BLE001
|
|
146
|
+
self._log.error(f"Error deleting report {report_id}: {exc}")
|
|
147
|
+
return False
|
|
148
|
+
else:
|
|
149
|
+
return True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CouchDBTempStore(TempStoreInterface):
|
|
153
|
+
"""CouchDB-based temporary storage implementation.
|
|
154
|
+
|
|
155
|
+
Stores reports temporarily when StandCloud sync fails using CouchDB.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self) -> None:
|
|
159
|
+
from pycouchdb import Server as DbServer # type: ignore[import-untyped]
|
|
160
|
+
from pycouchdb.exceptions import Conflict # type: ignore[import-untyped]
|
|
161
|
+
|
|
162
|
+
self._log = getLogger(__name__)
|
|
163
|
+
config = ConfigManager()
|
|
164
|
+
self._db_srv = DbServer(config.config.database.url)
|
|
165
|
+
self._db_name = "tempstore"
|
|
166
|
+
self._schema = ResultRunStore
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
self._db = self._db_srv.create(self._db_name)
|
|
170
|
+
except Conflict:
|
|
171
|
+
# database already exists
|
|
172
|
+
self._db = self._db_srv.database(self._db_name)
|
|
173
|
+
|
|
174
|
+
def push_report(self, report: ResultRunStore) -> bool:
|
|
175
|
+
"""Push report to the temporary storage.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
report (ResultRunStore): report to store
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
bool: True if successful, False otherwise
|
|
182
|
+
"""
|
|
183
|
+
from pycouchdb.exceptions import Conflict # type: ignore[import-untyped]
|
|
184
|
+
|
|
185
|
+
report_dict = report.model_dump()
|
|
186
|
+
report_id = report_dict.pop("id", None)
|
|
187
|
+
if not report_id:
|
|
188
|
+
self._log.error("Report missing required 'id' field")
|
|
189
|
+
return False
|
|
32
190
|
try:
|
|
33
191
|
self._db.save(report_dict)
|
|
34
192
|
except Conflict as exc:
|
|
35
193
|
self._log.error(f"Error while saving report {report_id}: {exc}")
|
|
36
194
|
return False
|
|
37
|
-
|
|
38
|
-
|
|
195
|
+
else:
|
|
196
|
+
self._log.debug(f"Report saved with id: {report_id}")
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
def reports(self) -> Generator[dict]:
|
|
200
|
+
"""Get all reports from the temporary storage.
|
|
39
201
|
|
|
40
|
-
|
|
41
|
-
|
|
202
|
+
Yields:
|
|
203
|
+
dict: report from temporary storage
|
|
204
|
+
"""
|
|
42
205
|
yield from self._db.all()
|
|
43
206
|
|
|
44
207
|
def delete(self, report_id: str) -> bool:
|
|
45
|
-
"""Delete report from the
|
|
208
|
+
"""Delete report from the temporary storage.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
report_id (str): report ID to delete
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
bool: True if successful, False otherwise
|
|
215
|
+
"""
|
|
216
|
+
from pycouchdb.exceptions import ( # type: ignore[import-untyped]
|
|
217
|
+
Conflict,
|
|
218
|
+
NotFound,
|
|
219
|
+
)
|
|
220
|
+
|
|
46
221
|
try:
|
|
47
222
|
self._db.delete(report_id)
|
|
48
223
|
except (NotFound, Conflict):
|
|
49
224
|
return False
|
|
50
|
-
|
|
225
|
+
else:
|
|
226
|
+
return True
|
|
51
227
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
228
|
+
|
|
229
|
+
class TempStore(metaclass=SingletonMeta):
|
|
230
|
+
"""HardPy temporary storage factory for data synchronization.
|
|
231
|
+
|
|
232
|
+
Creates appropriate storage backend based on configuration:
|
|
233
|
+
- JSON file storage when storage_type is "json"
|
|
234
|
+
- CouchDB storage when storage_type is "couchdb"
|
|
235
|
+
|
|
236
|
+
This ensures temporary reports are stored in the same backend as the main data.
|
|
237
|
+
|
|
238
|
+
Note: This class acts as a factory. When instantiated, it returns
|
|
239
|
+
the appropriate concrete implementation (JsonTempStore or CouchDBTempStore).
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __new__(cls) -> TempStoreInterface: # type: ignore[misc]
|
|
243
|
+
"""Create and return the appropriate storage implementation.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
TempStoreInterface: Concrete storage implementation based on config
|
|
247
|
+
"""
|
|
248
|
+
config = ConfigManager()
|
|
249
|
+
storage_type = config.config.database.storage_type
|
|
250
|
+
|
|
251
|
+
if storage_type == StorageType.JSON:
|
|
252
|
+
return JsonTempStore()
|
|
253
|
+
if storage_type == StorageType.COUCHDB:
|
|
254
|
+
return CouchDBTempStore()
|
|
255
|
+
msg = f"Unknown storage type: {storage_type}"
|
|
256
|
+
raise ValueError(msg)
|
hardpy/pytest_hardpy/plugin.py
CHANGED
|
@@ -496,7 +496,7 @@ class HardpyPlugin:
|
|
|
496
496
|
module_start_time = self._reporter.get_module_start_time(module_id)
|
|
497
497
|
module_stop_time = self._reporter.get_module_stop_time(module_id)
|
|
498
498
|
if module_start_time and not module_stop_time:
|
|
499
|
-
self._reporter.set_module_stop_time(
|
|
499
|
+
self._reporter.set_module_stop_time(module_id)
|
|
500
500
|
for module_data_key in module_data:
|
|
501
501
|
# skip module status
|
|
502
502
|
if module_data_key == "module_status":
|
|
@@ -505,7 +505,7 @@ class HardpyPlugin:
|
|
|
505
505
|
case_start_time = self._reporter.get_case_start_time(module_id, case_id)
|
|
506
506
|
case_stop_time = self._reporter.get_case_stop_time(module_id, case_id)
|
|
507
507
|
if case_start_time and not case_stop_time:
|
|
508
|
-
self._reporter.set_case_stop_time(
|
|
508
|
+
self._reporter.set_case_stop_time(module_id, case_id)
|
|
509
509
|
|
|
510
510
|
def _stop_tests(self) -> None:
|
|
511
511
|
"""Update module and case statuses to stopped and skipped."""
|