From 307389cf60211f9e71f7e554719e0b7c52e8aead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:49:39 +0100 Subject: [PATCH 01/81] Add abstract storage interface to support multiple backends in HardPy --- hardpy/pytest_hardpy/db/storage_interface.py | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 hardpy/pytest_hardpy/db/storage_interface.py diff --git a/hardpy/pytest_hardpy/db/storage_interface.py b/hardpy/pytest_hardpy/db/storage_interface.py new file mode 100644 index 00000000..baed5f23 --- /dev/null +++ b/hardpy/pytest_hardpy/db/storage_interface.py @@ -0,0 +1,61 @@ +# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pydantic._internal._model_construction import ModelMetaclass + + +class IStorage(ABC): + """Abstract storage interface for HardPy data persistence. + + This interface defines the contract for storage implementations, + allowing HardPy to support multiple storage backends (CouchDB, JSON files, etc.). + """ + + @abstractmethod + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field value from document using dot notation. + + Args: + key (str): Field key, supports nested access with dots (e.g., "modules.test1.status") + + Returns: + Any: Field value + """ + + @abstractmethod + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value in memory (does not persist). + + Args: + key (str): Field key, supports nested access with dots + value (Any): Value to set + """ + + @abstractmethod + def update_db(self) -> None: + """Persist in-memory document to storage backend.""" + + @abstractmethod + def update_doc(self) -> None: + """Reload document from storage backend to memory.""" + + @abstractmethod + def get_document(self) -> ModelMetaclass: + """Get full document with schema validation. + + Returns: + ModelMetaclass: Validated document model + """ + + @abstractmethod + def clear(self) -> None: + """Clear storage and reset to initial state.""" + + @abstractmethod + def compact(self) -> None: + """Optimize storage (implementation-specific, may be no-op).""" From 4b5e48fb127e6e36f5684b5a85b54f214c80e72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:49:58 +0100 Subject: [PATCH 02/81] Add StorageFactory to support configurable storage backends (JSON, CouchDB) --- hardpy/pytest_hardpy/db/storage_factory.py | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 hardpy/pytest_hardpy/db/storage_factory.py diff --git a/hardpy/pytest_hardpy/db/storage_factory.py b/hardpy/pytest_hardpy/db/storage_factory.py new file mode 100644 index 00000000..71e6dd64 --- /dev/null +++ b/hardpy/pytest_hardpy/db/storage_factory.py @@ -0,0 +1,57 @@ +# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +from logging import getLogger + +from hardpy.common.config import ConfigManager +from hardpy.pytest_hardpy.db.storage_interface import IStorage + +logger = getLogger(__name__) + + +class StorageFactory: + """Factory for creating storage instances based on configuration. + + This factory pattern allows HardPy to support multiple storage backends + (CouchDB, JSON files, etc.) and switch between them based on configuration. + """ + + @staticmethod + def create_storage(store_name: str) -> IStorage: + """Create storage instance based on configuration. + + Args: + store_name (str): Name of the storage (e.g., "runstore", "statestore") + + Returns: + IStorage: Storage instance + + Raises: + ValueError: If storage type is unknown or unsupported + ImportError: If required dependencies for storage type are not installed + """ + config = ConfigManager().config + storage_type = getattr(config.database, "storage_type", "json") + + if storage_type == "json": + from hardpy.pytest_hardpy.db.json_file_store import JsonFileStore + + logger.debug(f"Creating JSON file storage for {store_name}") + return JsonFileStore(store_name) + + if storage_type == "couchdb": + try: + from hardpy.pytest_hardpy.db.base_store import CouchDBStore + except ImportError as exc: + msg = ( + "CouchDB storage requires pycouchdb. " + 'Install with: pip install hardpy[couchdb] or pip install "pycouchdb>=1.14.2"' + ) + raise ImportError(msg) from exc + + logger.debug(f"Creating CouchDB storage for {store_name}") + return CouchDBStore(store_name) + + msg = f"Unknown storage type: {storage_type}. Supported types: 'json', 'couchdb'" + raise ValueError(msg) From c71fac7fc44994b5d1a7ac62b98e23c112ad3c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:50:10 +0100 Subject: [PATCH 03/81] Add JSON file-based storage implementation for HardPy --- hardpy/pytest_hardpy/db/json_file_store.py | 242 +++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 hardpy/pytest_hardpy/db/json_file_store.py diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py new file mode 100644 index 00000000..376d25e5 --- /dev/null +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -0,0 +1,242 @@ +# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import json +from json import dumps +from logging import getLogger +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from glom import assign, glom + +from hardpy.common.config import ConfigManager +from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 +from hardpy.pytest_hardpy.db.storage_interface import IStorage + +if TYPE_CHECKING: + from pydantic._internal._model_construction import ModelMetaclass + + +class JsonFileStore(IStorage): + """JSON file-based storage implementation. + + Stores data in JSON files within the .hardpy/storage directory of the test project. + Provides atomic writes and file locking for concurrent access safety. + """ + + def __init__(self, store_name: str) -> None: + """Initialize JSON file storage. + + Args: + store_name (str): Name of the storage (e.g., "runstore", "statestore") + """ + config_manager = ConfigManager() + self._store_name = store_name + self._storage_dir = Path(config_manager.tests_path) / ".hardpy" / "storage" + self._storage_dir.mkdir(parents=True, exist_ok=True) + self._file_path = self._storage_dir / f"{store_name}.json" + self._doc_id = config_manager.config.database.doc_id + self._log = getLogger(__name__) + self._doc: dict = self._init_doc() + self._schema: ModelMetaclass + + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field value from document using dot notation. + + Args: + key (str): Field key, supports nested access with dots + + Returns: + Any: Field value, or None if path does not exist + """ + from glom import PathAccessError + + try: + return glom(self._doc, key) + except PathAccessError: + # Return None for missing paths (matches CouchDB behavior) + return None + + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value in memory (does not persist). + + Args: + key (str): Field key, supports nested access with dots + value (Any): Value to set + """ + try: + dumps(value) + except Exception: # noqa: BLE001 + # serialize non-serializable objects as string + value = dumps(value, default=str) + + if "." in key: + # Use glom's Assign with missing=dict to create intermediate paths + assign(self._doc, key, value, missing=dict) + else: + self._doc[key] = value + + def update_db(self) -> None: + """Persist in-memory document to JSON file with atomic write.""" + temp_file = self._file_path.with_suffix(".tmp") + + try: + # Write to temporary file first + with temp_file.open("w") as f: + json.dump(self._doc, f, indent=2, default=str) + + # Atomic rename (on most systems) + temp_file.replace(self._file_path) + + except Exception as exc: + self._log.error(f"Error writing to storage file: {exc}") + # Clean up temp file if it exists + if temp_file.exists(): + temp_file.unlink() + raise + + def update_doc(self) -> None: + """Reload document from JSON file to memory.""" + if self._file_path.exists(): + try: + with self._file_path.open("r") as f: + self._doc = json.load(f) + except json.JSONDecodeError as exc: + self._log.error(f"Error reading storage file: {exc}") + # Keep existing in-memory document if file is corrupted + except Exception as exc: + self._log.error(f"Error reading storage file: {exc}") + raise + + def get_document(self) -> ModelMetaclass: + """Get full document with schema validation. + + Returns: + ModelMetaclass: Validated document model + """ + self.update_doc() + return self._schema(**self._doc) + + def clear(self) -> None: + """Clear storage by resetting to initial state (in-memory only). + + Note: For JSON storage, clear() only resets the in-memory document. + Call update_db() explicitly to persist the cleared state. + This differs from CouchDB where clear() immediately affects the database. + """ + # Reset document to initial state (in-memory only) + self._doc = { + "_id": self._doc_id, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } + # NOTE: We do NOT call update_db() here to avoid persisting cleared state + # The caller should call update_db() when they want to persist changes + + def compact(self) -> None: + """Optimize storage (no-op for JSON file storage).""" + + def _init_doc(self) -> dict: + """Initialize or load document structure. + + Returns: + dict: Document structure + """ + if self._file_path.exists(): + try: + with self._file_path.open("r") as f: + doc = json.load(f) + + # Ensure required fields exist (for backward compatibility) + if DF.MODULES not in doc: + doc[DF.MODULES] = {} + + # Reset volatile fields for state-like stores + if self._store_name == "statestore": + doc[DF.DUT] = { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + } + doc[DF.TEST_STAND] = { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + } + doc[DF.PROCESS] = { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + } + + return doc + except json.JSONDecodeError: + self._log.warning(f"Corrupted storage file {self._file_path}, creating new") + except Exception as exc: + self._log.warning(f"Error loading storage file: {exc}, creating new") + + # Return default document structure + return { + "_id": self._doc_id, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } From 8e10875b3681ed16d22d044347bc612c39d592c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:50:48 +0100 Subject: [PATCH 04/81] Refactor storage system to support multiple backends (JSON, CouchDB) in TempStore, RunStore, and StateStore --- hardpy/common/config.py | 1 + hardpy/pytest_hardpy/db/base_store.py | 13 +++- hardpy/pytest_hardpy/db/runstore.py | 84 ++++++++++++++++++++---- hardpy/pytest_hardpy/db/statestore.py | 66 +++++++++++++++++-- hardpy/pytest_hardpy/db/tempstore.py | 93 +++++++++++++++++++++------ 5 files changed, 216 insertions(+), 41 deletions(-) diff --git a/hardpy/common/config.py b/hardpy/common/config.py index ddf2ea2f..ba243be8 100644 --- a/hardpy/common/config.py +++ b/hardpy/common/config.py @@ -19,6 +19,7 @@ class DatabaseConfig(BaseModel): model_config = ConfigDict(extra="forbid") + storage_type: str = "couchdb" # "json" or "couchdb" user: str = "dev" password: str = "dev" host: str = "localhost" diff --git a/hardpy/pytest_hardpy/db/base_store.py b/hardpy/pytest_hardpy/db/base_store.py index 484b05a7..169a0b6d 100644 --- a/hardpy/pytest_hardpy/db/base_store.py +++ b/hardpy/pytest_hardpy/db/base_store.py @@ -14,10 +14,15 @@ from hardpy.common.config import ConfigManager from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 +from hardpy.pytest_hardpy.db.storage_interface import IStorage -class BaseStore: - """HardPy base storage interface for CouchDB.""" +class CouchDBStore(IStorage): + """CouchDB-based storage implementation. + + This class provides storage using CouchDB as the backend. + Handles database connections, document revisions, and conflict resolution. + """ def __init__(self, db_name: str) -> None: config_manager = ConfigManager() @@ -177,3 +182,7 @@ def _init_doc(self) -> dict: } return doc + + +# Backward compatibility alias +BaseStore = CouchDBStore diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 1ad27075..3952dc3a 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -1,28 +1,84 @@ # Copyright (c) 2024 Everypin # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -from logging import getLogger +from __future__ import annotations -from pycouchdb.exceptions import Conflict, NotFound +from logging import getLogger +from typing import TYPE_CHECKING, Any from hardpy.common.singleton import SingletonMeta -from hardpy.pytest_hardpy.db.base_store import BaseStore from hardpy.pytest_hardpy.db.schema import ResultRunStore +from hardpy.pytest_hardpy.db.storage_factory import StorageFactory + +if TYPE_CHECKING: + from pydantic._internal._model_construction import ModelMetaclass + from hardpy.pytest_hardpy.db.storage_interface import IStorage -class RunStore(BaseStore, metaclass=SingletonMeta): - """HardPy run storage interface for CouchDB. - Save state and case artifact. +class RunStore(metaclass=SingletonMeta): + """HardPy run storage interface. + + Save state and case artifact. Supports multiple storage backends + (JSON files, CouchDB) through the storage factory pattern. """ def __init__(self) -> None: - super().__init__("runstore") self._log = getLogger(__name__) - try: - # Clear the runstore database before each launch - self._db.delete(self._doc_id) - except (Conflict, NotFound): - self._log.debug("Runstore database will be created for the first time") - self._doc: dict = self._init_doc() - self._schema = ResultRunStore + self._storage: IStorage = StorageFactory.create_storage("runstore") + + # For CouchDB: Clear the runstore on initialization + # For JSON: The JsonFileStore __init__ already loads existing data + # Only clear if explicitly requested via clear() method + from hardpy.pytest_hardpy.db.base_store import CouchDBStore + if isinstance(self._storage, CouchDBStore): + try: + self._storage.clear() + except Exception: # noqa: BLE001 + self._log.debug("Runstore storage will be created for the first time") + + self._storage._schema = ResultRunStore # type: ignore + + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the run store. + + Args: + key (str): field name + + Returns: + Any: field value + """ + return self._storage.get_field(key) + + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value. + + Args: + key (str): document key + value: document value + """ + self._storage.update_doc_value(key, value) + + def update_db(self) -> None: + """Update database by current document.""" + self._storage.update_db() + + def update_doc(self) -> None: + """Update current document by database.""" + self._storage.update_doc() + + def get_document(self) -> ModelMetaclass: + """Get document by schema. + + Returns: + ModelMetaclass: document by schema + """ + return self._storage.get_document() + + def clear(self) -> None: + """Clear database.""" + self._storage.clear() + + def compact(self) -> None: + """Compact database.""" + self._storage.compact() diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 5c8fa638..5000c0de 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -1,17 +1,73 @@ # Copyright (c) 2024 Everypin # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import annotations + from logging import getLogger +from typing import TYPE_CHECKING, Any from hardpy.common.singleton import SingletonMeta -from hardpy.pytest_hardpy.db.base_store import BaseStore from hardpy.pytest_hardpy.db.schema import ResultStateStore +from hardpy.pytest_hardpy.db.storage_factory import StorageFactory + +if TYPE_CHECKING: + from pydantic._internal._model_construction import ModelMetaclass + + from hardpy.pytest_hardpy.db.storage_interface import IStorage + +class StateStore(metaclass=SingletonMeta): + """HardPy state storage interface. -class StateStore(BaseStore, metaclass=SingletonMeta): - """HardPy state storage interface for CouchDB.""" + Stores current test execution state. Supports multiple storage backends + (JSON files, CouchDB) through the storage factory pattern. + """ def __init__(self) -> None: - super().__init__("statestore") self._log = getLogger(__name__) - self._schema = ResultStateStore + self._storage: IStorage = StorageFactory.create_storage("statestore") + self._storage._schema = ResultStateStore # type: ignore + + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the state store. + + Args: + key (str): field name + + Returns: + Any: field value + """ + return self._storage.get_field(key) + + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value. + + Args: + key (str): document key + value: document value + """ + self._storage.update_doc_value(key, value) + + def update_db(self) -> None: + """Update database by current document.""" + self._storage.update_db() + + def update_doc(self) -> None: + """Update current document by database.""" + self._storage.update_doc() + + def get_document(self) -> ModelMetaclass: + """Get document by schema. + + Returns: + ModelMetaclass: document by schema + """ + return self._storage.get_document() + + def clear(self) -> None: + """Clear database.""" + self._storage.clear() + + def compact(self) -> None: + """Compact database.""" + self._storage.compact() diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index 5930fe80..91c01d78 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -3,52 +3,105 @@ from __future__ import annotations +import json from logging import getLogger +from pathlib import Path from typing import TYPE_CHECKING +from uuid import uuid4 -from pycouchdb.exceptions import Conflict, NotFound - +from hardpy.common.config import ConfigManager from hardpy.common.singleton import SingletonMeta -from hardpy.pytest_hardpy.db.base_store import BaseStore from hardpy.pytest_hardpy.db.schema import ResultRunStore if TYPE_CHECKING: from collections.abc import Generator -class TempStore(BaseStore, metaclass=SingletonMeta): - """HardPy temporary storage for data syncronization.""" +class TempStore(metaclass=SingletonMeta): + """HardPy temporary storage for data syncronization. + + Stores reports temporarily when StandCloud sync fails. Uses JSON files + regardless of the configured storage type to ensure reports are not lost + even if the main storage backend is unavailable. + """ def __init__(self) -> None: - super().__init__("tempstore") self._log = getLogger(__name__) - self._doc: dict = self._init_doc() + config = ConfigManager() + self._storage_dir = Path(config.tests_path) / ".hardpy" / "tempstore" + + # Only create directory for JSON storage + # CouchDB stores data directly in the database, no local .hardpy needed + # JSON storage needs .hardpy/tempstore for StandCloud sync failures + if config.config.database.storage_type == "json": + self._storage_dir.mkdir(parents=True, exist_ok=True) + self._schema = ResultRunStore def push_report(self, report: ResultRunStore) -> bool: - """Push report to the report database.""" + """Push report to the temporary storage. + + Args: + report (ResultRunStore): report to store + + Returns: + bool: True if successful, False otherwise + """ report_dict = report.model_dump() - report_id = report_dict.pop("id") + report_id = report_dict.get("id", str(uuid4())) + report_file = self._storage_dir / f"{report_id}.json" + try: - self._db.save(report_dict) - except Conflict as exc: + with report_file.open("w") as f: + json.dump(report_dict, f, indent=2, default=str) + self._log.debug(f"Report saved with id: {report_id}") + return True + except Exception as exc: # noqa: BLE001 self._log.error(f"Error while saving report {report_id}: {exc}") return False - self._log.debug(f"Report saved with id: {report_id}") - return True def reports(self) -> Generator[ResultRunStore]: - """Get all reports from the report database.""" - yield from self._db.all() + """Get all reports from the temporary storage. + + Yields: + ResultRunStore: report from temporary storage + """ + for report_file in self._storage_dir.glob("*.json"): + try: + with report_file.open("r") as f: + report_dict = json.load(f) + yield self._schema(**report_dict) + except Exception as exc: # noqa: BLE001 + self._log.error(f"Error loading report from {report_file}: {exc}") + continue def delete(self, report_id: str) -> bool: - """Delete report from the report database.""" + """Delete report from the temporary storage. + + Args: + report_id (str): report ID to delete + + Returns: + bool: True if successful, False otherwise + """ + report_file = self._storage_dir / f"{report_id}.json" try: - self._db.delete(report_id) - except (NotFound, Conflict): + report_file.unlink() + return True + except FileNotFoundError: + self._log.warning(f"Report {report_id} not found in temporary storage") + return False + except Exception as exc: # noqa: BLE001 + self._log.error(f"Error deleting report {report_id}: {exc}") return False - return True def dict_to_schema(self, report: dict) -> ResultRunStore: - """Convert report dict to report schema.""" + """Convert report dict to report schema. + + Args: + report (dict): report dictionary + + Returns: + ResultRunStore: validated report schema + """ return self._schema(**report) From 5b06726b7bca6284effadf9ecd492a722b2184fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:50:56 +0100 Subject: [PATCH 05/81] Add API endpoints to fetch storage type and retrieve JSON storage data --- hardpy/hardpy_panel/api.py | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/hardpy/hardpy_panel/api.py b/hardpy/hardpy_panel/api.py index 1e69149b..5cbf046c 100644 --- a/hardpy/hardpy_panel/api.py +++ b/hardpy/hardpy_panel/api.py @@ -361,6 +361,57 @@ def set_manual_collect_mode(mode_data: dict) -> dict: return {"status": "success", "manual_collect_mode": enabled} +@app.get("/api/storage_type") +def get_storage_type() -> dict: + """Get the configured storage type. + + Returns: + dict[str, str]: storage type ("json" or "couchdb") + """ + config_manager = ConfigManager() + return {"storage_type": config_manager.config.database.storage_type} + + +@app.get("/api/json_data") +def get_json_data() -> dict: + """Get test run data from JSON storage. + + Returns: + dict: Test run data from JSON files + """ + config_manager = ConfigManager() + storage_type = config_manager.config.database.storage_type + + if storage_type != "json": + return {"error": "JSON storage not configured"} + + try: + storage_dir = Path(config_manager.tests_path) / ".hardpy" / "storage" + runstore_file = storage_dir / "runstore.json" + + if not runstore_file.exists(): + return {"rows": [], "total_rows": 0} + + with runstore_file.open("r") as f: + data = json.load(f) + + # Format data to match CouchDB's _all_docs format + return { + "rows": [ + { + "id": data.get("_id", ""), + "key": data.get("_id", ""), + "value": {"rev": data.get("_rev", "1-0")}, + "doc": data, + } + ], + "total_rows": 1, + } + except Exception as exc: # noqa: BLE001 + logger.error(f"Error reading JSON storage: {exc}") + return {"error": str(exc), "rows": [], "total_rows": 0} + + if "DEBUG_FRONTEND" not in os.environ: app.mount( "/", From 4c35cecfa93eb86cf9275a47361c0d60d91283cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:51:35 +0100 Subject: [PATCH 06/81] frontend: Switch to `useStorageData` hook and integrate backend-driven storage type selection (JSON or CouchDB) --- hardpy/hardpy_panel/frontend/src/App.tsx | 6 +- hardpy/hardpy_panel/frontend/src/index.tsx | 67 ++++++++++++++++------ 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/hardpy/hardpy_panel/frontend/src/App.tsx b/hardpy/hardpy_panel/frontend/src/App.tsx index ac1839ed..fc6c90fb 100644 --- a/hardpy/hardpy_panel/frontend/src/App.tsx +++ b/hardpy/hardpy_panel/frontend/src/App.tsx @@ -27,7 +27,7 @@ import PlaySound from "./hardpy_test_view/PlaySound"; import TestConfigOverlay from "./hardpy_test_view/TestConfigOverlay"; import TestCompletionModalResult from "./hardpy_test_view/TestCompletionModalResult"; -import { useAllDocs } from "use-pouchdb"; +import { useStorageData } from "./hooks/useStorageData"; import "./App.css"; @@ -479,9 +479,7 @@ function App({ syncDocumentId }: { syncDocumentId: string }): JSX.Element { return -1; } - const { rows, state, loading, error } = useAllDocs({ - include_docs: true, - }); + const { rows, state, loading, error } = useStorageData(); /** * Monitors database changes and updates application state accordingly diff --git a/hardpy/hardpy_panel/frontend/src/index.tsx b/hardpy/hardpy_panel/frontend/src/index.tsx index 2660c807..6dec2c00 100644 --- a/hardpy/hardpy_panel/frontend/src/index.tsx +++ b/hardpy/hardpy_panel/frontend/src/index.tsx @@ -26,6 +26,21 @@ function ErrorMessage() { ); } +/** + * Gets the storage type from the backend API. + * @returns {Promise} A promise that resolves to the storage type ("json" or "couchdb"). + */ +async function getStorageType(): Promise { + try { + const response = await fetch("/api/storage_type"); + const data = await response.json(); + return data.storage_type || "couchdb"; + } catch (error) { + console.error(error); + return "couchdb"; + } +} + /** * Fetches the synchronization URL for PouchDB from the backend API. * @@ -63,33 +78,49 @@ const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement ); -const syncURL = await getSyncURL(); -if (syncURL !== undefined) { - const db = new PouchDB(syncURL); +const storageType = await getStorageType(); +const syncDocumentId = await getDatabaseDocumentId(); - const syncDocumentId = await getDatabaseDocumentId(); +if (storageType === "json") { + // For JSON storage, create a dummy local PouchDB instance (not used but required by Provider) + // This creates an IndexedDB database that won't try to sync anywhere + const dummyDb = new PouchDB("hardpy-local-dummy"); - /** - * Renders the main application wrapped in a PouchDB Provider and React StrictMode. - * - * @param {PouchDB.Database} db - The PouchDB database instance to be provided to the application. - */ root.render( - + ); } else { - /** - * Renders an error message if the PouchDB sync URL could not be retrieved. - */ - root.render( - - - - ); + // For CouchDB storage, connect to the actual database + const syncURL = await getSyncURL(); + if (syncURL !== undefined) { + const db = new PouchDB(syncURL); + + /** + * Renders the main application wrapped in a PouchDB Provider and React StrictMode. + * + * @param {PouchDB.Database} db - The PouchDB database instance to be provided to the application. + */ + root.render( + + + + + + ); + } else { + /** + * Renders an error message if the PouchDB sync URL could not be retrieved. + */ + root.render( + + + + ); + } } if (process.env.NODE_ENV !== "development") { From 4708e24e64a5292405b2b38a6a3538faa4b89458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:52:01 +0100 Subject: [PATCH 07/81] frontend: Add `useStorageData` hook to support dynamic storage type detection and data fetching --- .../frontend/src/hooks/useStorageData.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 hardpy/hardpy_panel/frontend/src/hooks/useStorageData.ts diff --git a/hardpy/hardpy_panel/frontend/src/hooks/useStorageData.ts b/hardpy/hardpy_panel/frontend/src/hooks/useStorageData.ts new file mode 100644 index 00000000..ea6b342d --- /dev/null +++ b/hardpy/hardpy_panel/frontend/src/hooks/useStorageData.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2025 Everypin +// GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +import * as React from "react"; +import { useAllDocs } from "use-pouchdb"; + +type StorageState = "loading" | "done" | "error"; + +interface StorageRow { + id: string; + key: string; + value: { rev: string }; + doc: any; +} + +interface StorageData { + rows: StorageRow[]; + state: StorageState; + loading: boolean; + error: Error | null; +} + +interface StorageTypeResponse { + storage_type: string; +} + +interface JsonDataResponse { + rows: StorageRow[]; + total_rows: number; + error?: string; +} + +/** + * Custom hook to fetch data from either JSON storage or CouchDB + * Automatically detects storage type and uses appropriate method + */ +export const useStorageData = (): StorageData => { + const [storageType, setStorageType] = React.useState(null); + const [jsonData, setJsonData] = React.useState([]); + const [jsonLoading, setJsonLoading] = React.useState(true); + const [jsonError, setJsonError] = React.useState(null); + + // Fetch storage type on mount + React.useEffect(() => { + fetch("/api/storage_type") + .then((res) => res.json()) + .then((data: StorageTypeResponse) => { + setStorageType(data.storage_type); + }) + .catch((err) => { + console.error("Failed to fetch storage type:", err); + setStorageType("couchdb"); // Default to CouchDB + }); + }, []); + + // For JSON storage, poll the API endpoint + React.useEffect(() => { + if (storageType !== "json") return; + + const fetchJsonData = () => { + fetch("/api/json_data") + .then((res) => res.json()) + .then((data: JsonDataResponse) => { + if (data.error) { + setJsonError(new Error(data.error)); + setJsonLoading(false); + } else { + setJsonData(data.rows); + setJsonLoading(false); + setJsonError(null); + } + }) + .catch((err) => { + setJsonError(err); + setJsonLoading(false); + }); + }; + + // Initial fetch + fetchJsonData(); + + // Poll every 500ms for updates + const interval = setInterval(fetchJsonData, 500); + + return () => clearInterval(interval); + }, [storageType]); + + // For CouchDB, use the existing PouchDB hook + const pouchDbData = useAllDocs({ + include_docs: true, + }); + + // Return appropriate data based on storage type + if (storageType === null) { + // Still detecting storage type + return { + rows: [], + state: "loading", + loading: true, + error: null, + }; + } + + if (storageType === "json") { + return { + rows: jsonData, + state: jsonError ? "error" : jsonLoading ? "loading" : "done", + loading: jsonLoading, + error: jsonError, + }; + } + + // Default to CouchDB + return pouchDbData; +}; From e4522d223c43bb13111cd1c5fc34a4900ccc2fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:52:33 +0100 Subject: [PATCH 08/81] examples: Add demo for JSON storage usage --- examples/demo_json_storage/conftest.py | 44 +++++++ examples/demo_json_storage/hardpy.toml | 34 ++++++ examples/demo_json_storage/pytest.ini | 6 + examples/demo_json_storage/test_chart_demo.py | 98 ++++++++++++++++ .../demo_json_storage/test_communication.py | 111 ++++++++++++++++++ examples/demo_json_storage/test_voltage.py | 99 ++++++++++++++++ 6 files changed, 392 insertions(+) create mode 100644 examples/demo_json_storage/conftest.py create mode 100644 examples/demo_json_storage/hardpy.toml create mode 100644 examples/demo_json_storage/pytest.ini create mode 100644 examples/demo_json_storage/test_chart_demo.py create mode 100644 examples/demo_json_storage/test_communication.py create mode 100644 examples/demo_json_storage/test_voltage.py diff --git a/examples/demo_json_storage/conftest.py b/examples/demo_json_storage/conftest.py new file mode 100644 index 00000000..cd748df9 --- /dev/null +++ b/examples/demo_json_storage/conftest.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025 Demo +# Pytest configuration and fixtures for HardPy tests + +import pytest + + +@pytest.fixture(scope="session") +def setup_test_environment(): + """Set up test environment before all tests.""" + print("\n=== Setting up test environment ===") + # Add any global setup here + yield + print("\n=== Tearing down test environment ===") + # Add any global cleanup here + + +@pytest.fixture(scope="function") +def test_device(): + """Fixture providing simulated test device.""" + class TestDevice: + def __init__(self): + self.connected = False + self.voltage = 5.0 + self.current = 0.0 + + def connect(self): + self.connected = True + return True + + def disconnect(self): + self.connected = False + + def measure_voltage(self): + return self.voltage + + def measure_current(self): + return self.current + + device = TestDevice() + device.connect() + + yield device + + device.disconnect() diff --git a/examples/demo_json_storage/hardpy.toml b/examples/demo_json_storage/hardpy.toml new file mode 100644 index 00000000..5d3855c9 --- /dev/null +++ b/examples/demo_json_storage/hardpy.toml @@ -0,0 +1,34 @@ +# HardPy Configuration File +# This demo uses JSON file storage (no CouchDB required!) + +title = "HardPy JSON Storage Demo" +tests_name = "Device Test Suite" + +[database] +# Storage type: "json" (default, no external database) or "couchdb" +storage_type = "json" + +# CouchDB settings (only needed if storage_type = "couchdb") +# user = "dev" +# password = "dev" +# host = "localhost" +# port = 5984 + +[frontend] +host = "localhost" +port = 8000 +language = "en" +sound_on = false +full_size_button = false +manual_collect = false +measurement_display = true + +[frontend.modal_result] +enable = true +auto_dismiss_pass = true +auto_dismiss_timeout = 5 + +[stand_cloud] +address = "standcloud.io" +connection_only = false +autosync = false diff --git a/examples/demo_json_storage/pytest.ini b/examples/demo_json_storage/pytest.ini new file mode 100644 index 00000000..4491d9d2 --- /dev/null +++ b/examples/demo_json_storage/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +pythonpath = . +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/examples/demo_json_storage/test_chart_demo.py b/examples/demo_json_storage/test_chart_demo.py new file mode 100644 index 00000000..6ea7d793 --- /dev/null +++ b/examples/demo_json_storage/test_chart_demo.py @@ -0,0 +1,98 @@ +# Copyright (c) 2025 Demo +# Test module demonstrating chart functionality + +import hardpy +import pytest +import math + + +@pytest.mark.case_name("Sine Wave Analysis") +@pytest.mark.module_name("Chart Demonstrations") +def test_sine_wave(): + """Test generating and analyzing a sine wave.""" + hardpy.set_message("Generating sine wave data...") + + # Generate sine wave data + x_data = [] + y_data = [] + + for i in range(100): + x = i / 10.0 # 0 to 10 + y = math.sin(x) + x_data.append(x) + y_data.append(y) + + # Create chart + chart = hardpy.Chart( + title="Sine Wave", + x_label="Time", + y_label="Amplitude", + type=hardpy.ChartType.LINE, + ) + chart.add_series(x_data, y_data, "Sine Wave") + + hardpy.set_case_chart(chart) + + # Verify amplitude + max_amplitude = max(y_data) + min_amplitude = min(y_data) + peak_to_peak = max_amplitude - min_amplitude + + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Peak-to-Peak Amplitude", + value=peak_to_peak, + unit="V", + lower_limit=1.9, + upper_limit=2.1, + ) + ) + + hardpy.set_message(f"Peak-to-peak amplitude: {peak_to_peak:.3f}V") + + assert 1.9 <= peak_to_peak <= 2.1, "Amplitude out of range" + + +@pytest.mark.case_name("Temperature Curve") +@pytest.mark.module_name("Chart Demonstrations") +def test_temperature_curve(): + """Test temperature rise curve.""" + hardpy.set_message("Recording temperature curve...") + + # Simulate temperature rise + time_data = [] + temp_data = [] + + for i in range(50): + time = i * 2 # seconds + # Exponential rise to 80°C + temp = 25 + 55 * (1 - math.exp(-i / 20)) + time_data.append(time) + temp_data.append(temp) + + # Create chart + chart = hardpy.Chart( + title="Temperature Rise Curve", + x_label="Time (seconds)", + y_label="Temperature (°C)", + type=hardpy.ChartType.LINE, + ) + chart.add_series(time_data, temp_data, "Temperature") + + hardpy.set_case_chart(chart) + + # Check final temperature + final_temp = temp_data[-1] + + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Final Temperature", + value=final_temp, + unit="°C", + upper_limit=85, + ) + ) + + hardpy.set_message(f"Final temperature: {final_temp:.1f}°C") + + assert final_temp < 85, f"Temperature too high: {final_temp}°C" diff --git a/examples/demo_json_storage/test_communication.py b/examples/demo_json_storage/test_communication.py new file mode 100644 index 00000000..a47a9604 --- /dev/null +++ b/examples/demo_json_storage/test_communication.py @@ -0,0 +1,111 @@ +# Copyright (c) 2025 Demo +# Test module for communication tests + +import hardpy +import pytest +from time import sleep + + +@pytest.mark.case_name("Serial Port Connection") +@pytest.mark.module_name("Communication Tests") +def test_serial_connection(): + """Test serial port connection.""" + hardpy.set_message("Testing serial port connection...") + + # Simulate connection + port = "/dev/ttyUSB0" + baudrate = 115200 + + hardpy.set_instrument( + hardpy.Instrument( + name="Serial Port", + comment=f"{port} @ {baudrate} baud" + ) + ) + + # Simulate successful connection + connection_ok = True + + hardpy.set_message(f"Connected to {port} at {baudrate} baud") + + assert connection_ok, "Failed to establish serial connection" + + +@pytest.mark.case_name("Data Transfer Test") +@pytest.mark.module_name("Communication Tests") +@pytest.mark.attempt(3) # Allow 2 retries +def test_data_transfer(): + """Test data transfer over serial.""" + hardpy.set_message("Testing data transfer...") + + # Simulate sending and receiving data + sent_bytes = 1024 + received_bytes = 1024 + transfer_time = 0.5 # seconds + + # Calculate transfer rate + transfer_rate = (sent_bytes + received_bytes) / transfer_time + + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Transfer Rate", + value=transfer_rate, + unit="bytes/s", + lower_limit=1000, + ) + ) + + hardpy.set_message(f"Transfer rate: {transfer_rate:.0f} bytes/s") + + assert received_bytes == sent_bytes, "Data integrity error" + assert transfer_rate > 1000, "Transfer rate too slow" + + +@pytest.mark.case_name("Protocol Validation") +@pytest.mark.module_name("Communication Tests") +@pytest.mark.critical # Critical test - stops all if fails +def test_protocol_validation(): + """Test communication protocol validation.""" + hardpy.set_message("Validating communication protocol...") + + # Simulate protocol check + protocol_version = "v2.1" + expected_version = "v2.1" + + hardpy.set_case_measurement( + hardpy.StringMeasurement( + name="Protocol Version", + value=protocol_version, + comparison_value=expected_version, + ) + ) + + hardpy.set_message(f"Protocol version: {protocol_version}") + + assert protocol_version == expected_version, \ + f"Protocol mismatch: got {protocol_version}, expected {expected_version}" + + +@pytest.mark.case_name("Error Handling Test") +@pytest.mark.module_name("Communication Tests") +@pytest.mark.dependency("test_communication::test_protocol_validation") +def test_error_handling(): + """Test error handling (depends on protocol validation).""" + hardpy.set_message("Testing error handling...") + + # Simulate error injection and recovery + errors_injected = 5 + errors_handled = 5 + + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Errors Handled", + value=errors_handled, + unit="count", + comparison_value=errors_injected, + ) + ) + + hardpy.set_message(f"Handled {errors_handled}/{errors_injected} errors") + + assert errors_handled == errors_injected, "Some errors not handled correctly" diff --git a/examples/demo_json_storage/test_voltage.py b/examples/demo_json_storage/test_voltage.py new file mode 100644 index 00000000..01ca4804 --- /dev/null +++ b/examples/demo_json_storage/test_voltage.py @@ -0,0 +1,99 @@ +# Copyright (c) 2025 Demo +# Test module for voltage measurements + +import hardpy +import pytest +from time import sleep + + +@pytest.mark.case_name("Check Power Supply Voltage") +@pytest.mark.module_name("Power Supply Tests") +def test_power_supply_voltage(): + """Test that power supply outputs correct voltage.""" + # Set test stand information + hardpy.set_stand_name("Test Bench #1") + hardpy.set_stand_location("Lab A") + + # Set device under test information + hardpy.set_dut_serial_number("PSU-12345") + hardpy.set_dut_name("Power Supply Unit") + hardpy.set_dut_type("DC Power Supply") + + # Simulate voltage measurement + expected_voltage = 5.0 + measured_voltage = 5.02 # Simulated measurement + tolerance = 0.1 + + # Record measurement + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Output Voltage", + value=measured_voltage, + unit="V", + lower_limit=expected_voltage - tolerance, + upper_limit=expected_voltage + tolerance, + ) + ) + + # Add message + hardpy.set_message(f"Measured voltage: {measured_voltage}V (expected: {expected_voltage}V)") + + # Verify voltage is within tolerance + assert abs(measured_voltage - expected_voltage) <= tolerance, \ + f"Voltage out of tolerance: {measured_voltage}V" + + +@pytest.mark.case_name("Check Current Limit") +@pytest.mark.module_name("Power Supply Tests") +def test_current_limit(): + """Test that power supply has correct current limit.""" + # Simulate current limit test + expected_limit = 3.0 + measured_limit = 3.05 # Simulated measurement + tolerance = 0.2 + + # Record measurement + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Current Limit", + value=measured_limit, + unit="A", + lower_limit=expected_limit - tolerance, + upper_limit=expected_limit + tolerance, + ) + ) + + hardpy.set_message(f"Current limit: {measured_limit}A") + + assert abs(measured_limit - expected_limit) <= tolerance + + +@pytest.mark.case_name("Voltage Stability Test") +@pytest.mark.module_name("Power Supply Tests") +@pytest.mark.attempt(2) # Retry once if fails +def test_voltage_stability(): + """Test voltage stability over time.""" + hardpy.set_message("Testing voltage stability over 5 seconds...") + + voltage_readings = [] + for i in range(5): + # Simulate reading voltage + voltage = 5.0 + (i * 0.01) # Slight increase + voltage_readings.append(voltage) + sleep(0.1) # Simulate measurement delay + + max_variation = max(voltage_readings) - min(voltage_readings) + + # Record measurement + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Voltage Variation", + value=max_variation, + unit="V", + upper_limit=0.1, + ) + ) + + hardpy.set_message(f"Max voltage variation: {max_variation:.3f}V") + + assert max_variation < 0.1, f"Voltage not stable: {max_variation}V variation" From 7164911ca40d4f539649365145ca565584568ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 21:54:16 +0100 Subject: [PATCH 09/81] examples: Simplify comment on storage type in demo config --- examples/demo_json_storage/hardpy.toml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/examples/demo_json_storage/hardpy.toml b/examples/demo_json_storage/hardpy.toml index 5d3855c9..ed01212a 100644 --- a/examples/demo_json_storage/hardpy.toml +++ b/examples/demo_json_storage/hardpy.toml @@ -5,7 +5,7 @@ title = "HardPy JSON Storage Demo" tests_name = "Device Test Suite" [database] -# Storage type: "json" (default, no external database) or "couchdb" +# Storage type: "json" storage_type = "json" # CouchDB settings (only needed if storage_type = "couchdb") @@ -27,8 +27,3 @@ measurement_display = true enable = true auto_dismiss_pass = true auto_dismiss_timeout = 5 - -[stand_cloud] -address = "standcloud.io" -connection_only = false -autosync = false From 5d4b3928fbe9ed5fa37c4dc5c59638e4b89cf8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:01:52 +0100 Subject: [PATCH 10/81] docs: Update hardpy_config.md to document `storage_type` option for JSON storage --- docs/documentation/hardpy_config.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/documentation/hardpy_config.md b/docs/documentation/hardpy_config.md index 3eee1649..5519b91a 100644 --- a/docs/documentation/hardpy_config.md +++ b/docs/documentation/hardpy_config.md @@ -30,6 +30,7 @@ tests_name = "My tests" current_test_config = "" [database] +storage_type = "json" user = "dev" password = "dev" host = "localhost" @@ -81,6 +82,14 @@ An example of its use can be found on page [Multiple configs](./../examples/mult Database settings. +#### storage_type + +Storage type. The default is `couchdb`. + +- `couchdb`: Stores test results and measurements in a CouchDB database. Requires a running CouchDB instance. +- `json`: Stores test results and measurements in local JSON files. No external database required. +Files are stored in the `.hardpy/storage/` directory in the root of the project. + #### user Database user name. The default is `dev`. From 01826434871be7b3cb07173ca36334be46dfd938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:11:37 +0100 Subject: [PATCH 11/81] examples: Remove unnecessary comments from demo_json_storage conftest setup --- examples/demo_json_storage/conftest.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/demo_json_storage/conftest.py b/examples/demo_json_storage/conftest.py index cd748df9..4c60935a 100644 --- a/examples/demo_json_storage/conftest.py +++ b/examples/demo_json_storage/conftest.py @@ -1,6 +1,3 @@ -# Copyright (c) 2025 Demo -# Pytest configuration and fixtures for HardPy tests - import pytest From 1d4d6af312d1ede081f707e10bd3b09836c33495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:12:06 +0100 Subject: [PATCH 12/81] examples: Remove copyright comments from test modules in demo_json_storage --- examples/demo_json_storage/test_chart_demo.py | 3 --- examples/demo_json_storage/test_communication.py | 3 --- examples/demo_json_storage/test_voltage.py | 3 --- 3 files changed, 9 deletions(-) diff --git a/examples/demo_json_storage/test_chart_demo.py b/examples/demo_json_storage/test_chart_demo.py index 6ea7d793..3c519036 100644 --- a/examples/demo_json_storage/test_chart_demo.py +++ b/examples/demo_json_storage/test_chart_demo.py @@ -1,6 +1,3 @@ -# Copyright (c) 2025 Demo -# Test module demonstrating chart functionality - import hardpy import pytest import math diff --git a/examples/demo_json_storage/test_communication.py b/examples/demo_json_storage/test_communication.py index a47a9604..97fb7a16 100644 --- a/examples/demo_json_storage/test_communication.py +++ b/examples/demo_json_storage/test_communication.py @@ -1,6 +1,3 @@ -# Copyright (c) 2025 Demo -# Test module for communication tests - import hardpy import pytest from time import sleep diff --git a/examples/demo_json_storage/test_voltage.py b/examples/demo_json_storage/test_voltage.py index 01ca4804..2d73164a 100644 --- a/examples/demo_json_storage/test_voltage.py +++ b/examples/demo_json_storage/test_voltage.py @@ -1,6 +1,3 @@ -# Copyright (c) 2025 Demo -# Test module for voltage measurements - import hardpy import pytest from time import sleep From 898a8df491645924d7ecde51cadefecdffa04c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:20:25 +0100 Subject: [PATCH 13/81] api: Use `logger.exception` for better error trace in JSON storage handling --- hardpy/hardpy_panel/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hardpy/hardpy_panel/api.py b/hardpy/hardpy_panel/api.py index 5cbf046c..e07ea4c2 100644 --- a/hardpy/hardpy_panel/api.py +++ b/hardpy/hardpy_panel/api.py @@ -403,12 +403,12 @@ def get_json_data() -> dict: "key": data.get("_id", ""), "value": {"rev": data.get("_rev", "1-0")}, "doc": data, - } + }, ], "total_rows": 1, } - except Exception as exc: # noqa: BLE001 - logger.error(f"Error reading JSON storage: {exc}") + except Exception as exc: + logger.exception("Error reading JSON storage") return {"error": str(exc), "rows": [], "total_rows": 0} From 76fe2b05f191c9d8166f4a3cc2fe07aa5a4bd7b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:20:36 +0100 Subject: [PATCH 14/81] db: Improve logging for JSONDecodeError and suppress noqa warning --- hardpy/pytest_hardpy/db/json_file_store.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 376d25e5..595c37e7 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -206,8 +206,10 @@ def _init_doc(self) -> dict: return doc except json.JSONDecodeError: - self._log.warning(f"Corrupted storage file {self._file_path}, creating new") - except Exception as exc: + self._log.warning( + f"Corrupted storage file {self._file_path}, creating new", + ) + except Exception as exc: # noqa: BLE001 self._log.warning(f"Error loading storage file: {exc}, creating new") # Return default document structure From 8a67a97c285fc58c2641ad3f3709107a8d4a6c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:20:43 +0100 Subject: [PATCH 15/81] db: Suppress specific noqa warning in runstore schema assignment --- hardpy/pytest_hardpy/db/runstore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 3952dc3a..f6ae7c73 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -37,7 +37,7 @@ def __init__(self) -> None: except Exception: # noqa: BLE001 self._log.debug("Runstore storage will be created for the first time") - self._storage._schema = ResultRunStore # type: ignore + self._storage._schema = ResultRunStore # type: ignore # noqa: SLF001 def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the run store. From 78593a4e55811c348e17a6bf40d2c8714dc00f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:20:55 +0100 Subject: [PATCH 16/81] db: Suppress specific noqa warning in runstore schema assignment --- hardpy/pytest_hardpy/db/statestore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 5000c0de..c5ee8a9e 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -26,7 +26,7 @@ class StateStore(metaclass=SingletonMeta): def __init__(self) -> None: self._log = getLogger(__name__) self._storage: IStorage = StorageFactory.create_storage("statestore") - self._storage._schema = ResultStateStore # type: ignore + self._storage._schema = ResultStateStore # type: ignore # noqa: SLF001 def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the state store. From a6e7ae35abb343ea9f5d1484b0662c210c2cf7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:21:06 +0100 Subject: [PATCH 17/81] db: Add type checking for IStorage import, improve exception messages formatting --- hardpy/pytest_hardpy/db/storage_factory.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/hardpy/pytest_hardpy/db/storage_factory.py b/hardpy/pytest_hardpy/db/storage_factory.py index 71e6dd64..5ac12517 100644 --- a/hardpy/pytest_hardpy/db/storage_factory.py +++ b/hardpy/pytest_hardpy/db/storage_factory.py @@ -3,9 +3,12 @@ from __future__ import annotations from logging import getLogger +from typing import TYPE_CHECKING from hardpy.common.config import ConfigManager -from hardpy.pytest_hardpy.db.storage_interface import IStorage + +if TYPE_CHECKING: + from hardpy.pytest_hardpy.db.storage_interface import IStorage logger = getLogger(__name__) @@ -46,12 +49,16 @@ def create_storage(store_name: str) -> IStorage: except ImportError as exc: msg = ( "CouchDB storage requires pycouchdb. " - 'Install with: pip install hardpy[couchdb] or pip install "pycouchdb>=1.14.2"' + "Install with: pip install hardpy[couchdb] or " + 'pip install "pycouchdb>=1.14.2"' ) raise ImportError(msg) from exc logger.debug(f"Creating CouchDB storage for {store_name}") return CouchDBStore(store_name) - msg = f"Unknown storage type: {storage_type}. Supported types: 'json', 'couchdb'" + msg = ( + f"Unknown storage type: {storage_type}. " + "Supported types: 'json', 'couchdb'" + ) raise ValueError(msg) From 677c8298e564212ec2598c8d9669d60583147806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:21:12 +0100 Subject: [PATCH 18/81] docs: Adjust storage_interface docstring formatting for clarity --- hardpy/pytest_hardpy/db/storage_interface.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hardpy/pytest_hardpy/db/storage_interface.py b/hardpy/pytest_hardpy/db/storage_interface.py index baed5f23..60d3931c 100644 --- a/hardpy/pytest_hardpy/db/storage_interface.py +++ b/hardpy/pytest_hardpy/db/storage_interface.py @@ -21,7 +21,8 @@ def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field value from document using dot notation. Args: - key (str): Field key, supports nested access with dots (e.g., "modules.test1.status") + key (str): Field key, supports nested access with dots + (e.g., "modules.test1.status") Returns: Any: Field value From 751baa9e061a630f00a613b517d2207bdbc8b1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 22:21:33 +0100 Subject: [PATCH 19/81] db: Refactor logging and return paths in tempstore; suppress additional noqa warning --- hardpy/pytest_hardpy/db/tempstore.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index 91c01d78..05020b8a 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -54,11 +54,12 @@ def push_report(self, report: ResultRunStore) -> bool: try: with report_file.open("w") as f: json.dump(report_dict, f, indent=2, default=str) - self._log.debug(f"Report saved with id: {report_id}") - return True except Exception as exc: # noqa: BLE001 self._log.error(f"Error while saving report {report_id}: {exc}") return False + else: + self._log.debug(f"Report saved with id: {report_id}") + return True def reports(self) -> Generator[ResultRunStore]: """Get all reports from the temporary storage. @@ -71,7 +72,7 @@ def reports(self) -> Generator[ResultRunStore]: with report_file.open("r") as f: report_dict = json.load(f) yield self._schema(**report_dict) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # noqa: BLE001, PERF203 self._log.error(f"Error loading report from {report_file}: {exc}") continue @@ -87,13 +88,14 @@ def delete(self, report_id: str) -> bool: report_file = self._storage_dir / f"{report_id}.json" try: report_file.unlink() - return True except FileNotFoundError: self._log.warning(f"Report {report_id} not found in temporary storage") return False except Exception as exc: # noqa: BLE001 self._log.error(f"Error deleting report {report_id}: {exc}") return False + else: + return True def dict_to_schema(self, report: dict) -> ResultRunStore: """Convert report dict to report schema. From 0d680a1c62703d32ccc94d1fa56742f47d467562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 09:33:52 +0100 Subject: [PATCH 20/81] config: Add `storage_path` attribute for JSON storage type --- hardpy/common/config.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/hardpy/common/config.py b/hardpy/common/config.py index ba243be8..aa7b5a1a 100644 --- a/hardpy/common/config.py +++ b/hardpy/common/config.py @@ -26,6 +26,8 @@ class DatabaseConfig(BaseModel): port: int = 5984 doc_id: str = Field(exclude=True, default="") url: str = Field(exclude=True, default="") + # have any function when storage_type is JSON + storage_path: str = Field(exclude=True, default=".hardpy") def model_post_init(self, __context) -> None: # noqa: ANN001,PYI063 """Get database connection url.""" @@ -145,19 +147,19 @@ def tests_path(self) -> Path: return self._tests_path def init_config( # noqa: PLR0913 - self, - tests_name: str, - database_user: str, - database_password: str, - database_host: str, - database_port: int, - frontend_host: str, - frontend_port: int, - frontend_language: str, - sc_address: str, - sc_connection_only: bool, - sc_autosync: bool, - sc_api_key: str, + self, + tests_name: str, + database_user: str, + database_password: str, + database_host: str, + database_port: int, + frontend_host: str, + frontend_port: int, + frontend_language: str, + sc_address: str, + sc_connection_only: bool, + sc_autosync: bool, + sc_api_key: str, ) -> None: """Initialize the HardPy configuration. From 0c766855a8c8de3c22261f0e0d17db52751a01ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:24:51 +0100 Subject: [PATCH 21/81] Revert "Auxiliary commit to revert individual files from 0d680a1c62703d32ccc94d1fa56742f47d467562" This reverts commit f4b61893ae25cd220d294ff1f5e0e1a2c3e480ff. --- hardpy/common/config.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/hardpy/common/config.py b/hardpy/common/config.py index aa7b5a1a..8ec4e67e 100644 --- a/hardpy/common/config.py +++ b/hardpy/common/config.py @@ -147,19 +147,19 @@ def tests_path(self) -> Path: return self._tests_path def init_config( # noqa: PLR0913 - self, - tests_name: str, - database_user: str, - database_password: str, - database_host: str, - database_port: int, - frontend_host: str, - frontend_port: int, - frontend_language: str, - sc_address: str, - sc_connection_only: bool, - sc_autosync: bool, - sc_api_key: str, + self, + tests_name: str, + database_user: str, + database_password: str, + database_host: str, + database_port: int, + frontend_host: str, + frontend_port: int, + frontend_language: str, + sc_address: str, + sc_connection_only: bool, + sc_autosync: bool, + sc_api_key: str, ) -> None: """Initialize the HardPy configuration. From 667a4f6b677199c0eafdb39754997b04880ebe1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 09:34:01 +0100 Subject: [PATCH 22/81] db: Update JSON storage directory to use `storage_path` from config --- hardpy/hardpy_panel/api.py | 2 +- hardpy/pytest_hardpy/db/json_file_store.py | 2 +- hardpy/pytest_hardpy/db/tempstore.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hardpy/hardpy_panel/api.py b/hardpy/hardpy_panel/api.py index e07ea4c2..3309b6a4 100644 --- a/hardpy/hardpy_panel/api.py +++ b/hardpy/hardpy_panel/api.py @@ -386,7 +386,7 @@ def get_json_data() -> dict: return {"error": "JSON storage not configured"} try: - storage_dir = Path(config_manager.tests_path) / ".hardpy" / "storage" + storage_dir = Path(config_manager.config.database.storage_path) / "storage" runstore_file = storage_dir / "runstore.json" if not runstore_file.exists(): diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 595c37e7..043e5268 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -33,7 +33,7 @@ def __init__(self, store_name: str) -> None: """ config_manager = ConfigManager() self._store_name = store_name - self._storage_dir = Path(config_manager.tests_path) / ".hardpy" / "storage" + self._storage_dir = Path(config_manager.config.database.storage_path) / "storage" self._storage_dir.mkdir(parents=True, exist_ok=True) self._file_path = self._storage_dir / f"{store_name}.json" self._doc_id = config_manager.config.database.doc_id diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index 05020b8a..ad24b8eb 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -28,7 +28,7 @@ class TempStore(metaclass=SingletonMeta): def __init__(self) -> None: self._log = getLogger(__name__) config = ConfigManager() - self._storage_dir = Path(config.tests_path) / ".hardpy" / "tempstore" + self._storage_dir = Path(config.config.database.storage_path) / "tempstore" # Only create directory for JSON storage # CouchDB stores data directly in the database, no local .hardpy needed From 58c9a00d6a6c53a32a0055217241ddad35d7ec7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 09:34:06 +0100 Subject: [PATCH 23/81] examples: Add `storage_path` to hardpy.toml in demo_json_storage --- examples/demo_json_storage/hardpy.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/demo_json_storage/hardpy.toml b/examples/demo_json_storage/hardpy.toml index ed01212a..24cd7165 100644 --- a/examples/demo_json_storage/hardpy.toml +++ b/examples/demo_json_storage/hardpy.toml @@ -7,6 +7,7 @@ tests_name = "Device Test Suite" [database] # Storage type: "json" storage_type = "json" +storage_path = "result" # CouchDB settings (only needed if storage_type = "couchdb") # user = "dev" From a8cb417ff6934f659dc7174a72eeb2df6e96eaca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 09:35:55 +0100 Subject: [PATCH 24/81] docs: Update hardpy_config with `storage_path` details and usage --- docs/documentation/hardpy_config.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/documentation/hardpy_config.md b/docs/documentation/hardpy_config.md index 5519b91a..b5edf11b 100644 --- a/docs/documentation/hardpy_config.md +++ b/docs/documentation/hardpy_config.md @@ -88,7 +88,13 @@ Storage type. The default is `couchdb`. - `couchdb`: Stores test results and measurements in a CouchDB database. Requires a running CouchDB instance. - `json`: Stores test results and measurements in local JSON files. No external database required. -Files are stored in the `.hardpy/storage/` directory in the root of the project. +Files are stored in the `.hardpy` directory in the root of the project. +The user can change this value with the `hardpy init --storage-type` option. + +#### storage_path + +Path to the storage directory. The default is `.hardpy` int the root of the project. +The user can change this value with the `hardpy init --storage-path` option. Relative and absolute paths are supported. #### user From 2bfc2820504745919ebd3e01350f5848a2428c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 13:04:38 +0100 Subject: [PATCH 25/81] docs: Update JsonFileStore docstring to reflect `storage_path` usage --- hardpy/pytest_hardpy/db/json_file_store.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 043e5268..d307a088 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -21,8 +21,8 @@ class JsonFileStore(IStorage): """JSON file-based storage implementation. - Stores data in JSON files within the .hardpy/storage directory of the test project. - Provides atomic writes and file locking for concurrent access safety. + Stores data in JSON files within the 'storage_path' directory of the test project. + Provides atomic writes for safer file operations. """ def __init__(self, store_name: str) -> None: From 14aec641027797f9a2aacc128e918117f518c61a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 13:05:17 +0100 Subject: [PATCH 26/81] db: Reformat `_storage_dir` assignment for improved readability --- hardpy/pytest_hardpy/db/json_file_store.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index d307a088..d74ec912 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -33,7 +33,8 @@ def __init__(self, store_name: str) -> None: """ config_manager = ConfigManager() self._store_name = store_name - self._storage_dir = Path(config_manager.config.database.storage_path) / "storage" + storage_path = config_manager.config.database.storage_path + self._storage_dir = Path(storage_path) / "storage" self._storage_dir.mkdir(parents=True, exist_ok=True) self._file_path = self._storage_dir / f"{store_name}.json" self._doc_id = config_manager.config.database.doc_id From 0db3faa1691c1a5ae7561075d3812106dacb3dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 13:12:26 +0100 Subject: [PATCH 27/81] db: Replace `ModelMetaclass` with `BaseModel` across database modules --- hardpy/common/config.py | 2 +- hardpy/pytest_hardpy/db/base_store.py | 12 +++++++----- hardpy/pytest_hardpy/db/json_file_store.py | 10 ++++------ hardpy/pytest_hardpy/db/runstore.py | 6 +++--- hardpy/pytest_hardpy/db/storage_interface.py | 6 +++--- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/hardpy/common/config.py b/hardpy/common/config.py index 8ec4e67e..5624e459 100644 --- a/hardpy/common/config.py +++ b/hardpy/common/config.py @@ -26,7 +26,7 @@ class DatabaseConfig(BaseModel): port: int = 5984 doc_id: str = Field(exclude=True, default="") url: str = Field(exclude=True, default="") - # have any function when storage_type is JSON + # This field is relevant only when storage_type is "json" storage_path: str = Field(exclude=True, default=".hardpy") def model_post_init(self, __context) -> None: # noqa: ANN001,PYI063 diff --git a/hardpy/pytest_hardpy/db/base_store.py b/hardpy/pytest_hardpy/db/base_store.py index 169a0b6d..669b5fb2 100644 --- a/hardpy/pytest_hardpy/db/base_store.py +++ b/hardpy/pytest_hardpy/db/base_store.py @@ -3,19 +3,21 @@ from json import dumps from logging import getLogger -from typing import Any +from typing import Any, TYPE_CHECKING from glom import assign, glom from pycouchdb import Server as DbServer from pycouchdb.client import Database from pycouchdb.exceptions import Conflict, GenericError, NotFound -from pydantic._internal._model_construction import ModelMetaclass from requests.exceptions import ConnectionError # noqa: A004 from hardpy.common.config import ConfigManager from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 from hardpy.pytest_hardpy.db.storage_interface import IStorage +if TYPE_CHECKING: + from pydantic import BaseModel + class CouchDBStore(IStorage): """CouchDB-based storage implementation. @@ -33,7 +35,7 @@ def __init__(self, db_name: str) -> None: self._doc_id = config.database.doc_id self._log = getLogger(__name__) self._doc: dict = self._init_doc() - self._schema: ModelMetaclass + self._schema: BaseModel def compact(self) -> None: """Compact database.""" @@ -84,11 +86,11 @@ def update_doc(self) -> None: self._doc["_rev"] = self._db.get(self._doc_id)["_rev"] self._doc = self._db.get(self._doc_id) - def get_document(self) -> ModelMetaclass: + def get_document(self) -> BaseModel: """Get document by schema. Returns: - ModelMetaclass: document by schema + BaseModel: document by schema """ self._doc = self._db.get(self._doc_id) return self._schema(**self._doc) diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index d74ec912..eb1aa306 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -9,14 +9,12 @@ from typing import TYPE_CHECKING, Any from glom import assign, glom +from pydantic import BaseModel from hardpy.common.config import ConfigManager from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 from hardpy.pytest_hardpy.db.storage_interface import IStorage -if TYPE_CHECKING: - from pydantic._internal._model_construction import ModelMetaclass - class JsonFileStore(IStorage): """JSON file-based storage implementation. @@ -40,7 +38,7 @@ def __init__(self, store_name: str) -> None: self._doc_id = config_manager.config.database.doc_id self._log = getLogger(__name__) self._doc: dict = self._init_doc() - self._schema: ModelMetaclass + self._schema: BaseModel def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field value from document using dot notation. @@ -110,11 +108,11 @@ def update_doc(self) -> None: self._log.error(f"Error reading storage file: {exc}") raise - def get_document(self) -> ModelMetaclass: + def get_document(self) -> BaseModel: """Get full document with schema validation. Returns: - ModelMetaclass: Validated document model + BaseModel: Validated document model """ self.update_doc() return self._schema(**self._doc) diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index f6ae7c73..20502c1a 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -11,7 +11,7 @@ from hardpy.pytest_hardpy.db.storage_factory import StorageFactory if TYPE_CHECKING: - from pydantic._internal._model_construction import ModelMetaclass + from pydantic import BaseModel from hardpy.pytest_hardpy.db.storage_interface import IStorage @@ -67,11 +67,11 @@ def update_doc(self) -> None: """Update current document by database.""" self._storage.update_doc() - def get_document(self) -> ModelMetaclass: + def get_document(self) -> BaseModel: """Get document by schema. Returns: - ModelMetaclass: document by schema + BaseModel: document by schema """ return self._storage.get_document() diff --git a/hardpy/pytest_hardpy/db/storage_interface.py b/hardpy/pytest_hardpy/db/storage_interface.py index 60d3931c..531bf5cb 100644 --- a/hardpy/pytest_hardpy/db/storage_interface.py +++ b/hardpy/pytest_hardpy/db/storage_interface.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from pydantic._internal._model_construction import ModelMetaclass + from pydantic import BaseModel class IStorage(ABC): @@ -46,11 +46,11 @@ def update_doc(self) -> None: """Reload document from storage backend to memory.""" @abstractmethod - def get_document(self) -> ModelMetaclass: + def get_document(self) -> BaseModel: """Get full document with schema validation. Returns: - ModelMetaclass: Validated document model + BaseModel: Validated document model """ @abstractmethod From 8c2a194b6b7e280ea1ec4dbe7b1f0c4ad3fbee8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 13:41:19 +0100 Subject: [PATCH 28/81] db: Introduce TempStoreInterface and implement JsonTempStore and CouchDBTempStore --- hardpy/pytest_hardpy/db/base_store.py | 4 +- hardpy/pytest_hardpy/db/tempstore.py | 195 ++++++++++++++++-- .../report_synchronizer/synchronizer.py | 27 ++- 3 files changed, 199 insertions(+), 27 deletions(-) diff --git a/hardpy/pytest_hardpy/db/base_store.py b/hardpy/pytest_hardpy/db/base_store.py index 669b5fb2..0f7e0a5b 100644 --- a/hardpy/pytest_hardpy/db/base_store.py +++ b/hardpy/pytest_hardpy/db/base_store.py @@ -9,15 +9,13 @@ from pycouchdb import Server as DbServer from pycouchdb.client import Database from pycouchdb.exceptions import Conflict, GenericError, NotFound +from pydantic import BaseModel from requests.exceptions import ConnectionError # noqa: A004 from hardpy.common.config import ConfigManager from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 from hardpy.pytest_hardpy.db.storage_interface import IStorage -if TYPE_CHECKING: - from pydantic import BaseModel - class CouchDBStore(IStorage): """CouchDB-based storage implementation. diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index ad24b8eb..09345c75 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +from abc import ABC, abstractmethod from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING @@ -17,25 +18,62 @@ from collections.abc import Generator -class TempStore(metaclass=SingletonMeta): - """HardPy temporary storage for data syncronization. +class TempStoreInterface(ABC): + """Interface for temporary storage implementations.""" + + @abstractmethod + def push_report(self, report: ResultRunStore) -> bool: + """Push report to the temporary storage. + + Args: + report (ResultRunStore): report to store + + Returns: + bool: True if successful, False otherwise + """ + + @abstractmethod + def reports(self) -> Generator[dict]: + """Get all reports from the temporary storage. + + Yields: + dict: report from temporary storage + """ + + @abstractmethod + def delete(self, report_id: str) -> bool: + """Delete report from the temporary storage. + + Args: + report_id (str): report ID to delete + + Returns: + bool: True if successful, False otherwise + """ + + def dict_to_schema(self, report: dict) -> ResultRunStore: + """Convert report dict to report schema. + + Args: + report (dict): report dictionary - Stores reports temporarily when StandCloud sync fails. Uses JSON files - regardless of the configured storage type to ensure reports are not lost - even if the main storage backend is unavailable. + Returns: + ResultRunStore: validated report schema + """ + return ResultRunStore(**report) + + +class JsonTempStore(TempStoreInterface): + """JSON file-based temporary storage implementation. + + Stores reports temporarily when StandCloud sync fails using JSON files. """ def __init__(self) -> None: self._log = getLogger(__name__) config = ConfigManager() self._storage_dir = Path(config.config.database.storage_path) / "tempstore" - - # Only create directory for JSON storage - # CouchDB stores data directly in the database, no local .hardpy needed - # JSON storage needs .hardpy/tempstore for StandCloud sync failures - if config.config.database.storage_type == "json": - self._storage_dir.mkdir(parents=True, exist_ok=True) - + self._storage_dir.mkdir(parents=True, exist_ok=True) self._schema = ResultRunStore def push_report(self, report: ResultRunStore) -> bool: @@ -61,17 +99,17 @@ def push_report(self, report: ResultRunStore) -> bool: self._log.debug(f"Report saved with id: {report_id}") return True - def reports(self) -> Generator[ResultRunStore]: + def reports(self) -> Generator[dict]: """Get all reports from the temporary storage. Yields: - ResultRunStore: report from temporary storage + dict: report from temporary storage """ for report_file in self._storage_dir.glob("*.json"): try: with report_file.open("r") as f: report_dict = json.load(f) - yield self._schema(**report_dict) + yield report_dict except Exception as exc: # noqa: BLE001, PERF203 self._log.error(f"Error loading report from {report_file}: {exc}") continue @@ -97,6 +135,131 @@ def delete(self, report_id: str) -> bool: else: return True + +class CouchDBTempStore(TempStoreInterface): + """CouchDB-based temporary storage implementation. + + Stores reports temporarily when StandCloud sync fails using CouchDB. + """ + + def __init__(self) -> None: + from pycouchdb import Server as DbServer # type: ignore[import-untyped] + from pycouchdb.exceptions import Conflict # type: ignore[import-untyped] + + self._log = getLogger(__name__) + config = ConfigManager() + self._db_srv = DbServer(config.config.database.url) + self._db_name = "tempstore" + self._schema = ResultRunStore + + try: + self._db = self._db_srv.create(self._db_name) + except Conflict: + # database already exists + self._db = self._db_srv.database(self._db_name) + + def push_report(self, report: ResultRunStore) -> bool: + """Push report to the temporary storage. + + Args: + report (ResultRunStore): report to store + + Returns: + bool: True if successful, False otherwise + """ + from pycouchdb.exceptions import Conflict # type: ignore[import-untyped] + + report_dict = report.model_dump() + report_id = report_dict.pop("id") + try: + self._db.save(report_dict) + except Conflict as exc: + self._log.error(f"Error while saving report {report_id}: {exc}") + return False + else: + self._log.debug(f"Report saved with id: {report_id}") + return True + + def reports(self) -> Generator[dict]: + """Get all reports from the temporary storage. + + Yields: + dict: report from temporary storage + """ + yield from self._db.all() + + def delete(self, report_id: str) -> bool: + """Delete report from the temporary storage. + + Args: + report_id (str): report ID to delete + + Returns: + bool: True if successful, False otherwise + """ + from pycouchdb.exceptions import Conflict, NotFound # type: ignore[import-untyped] + + try: + self._db.delete(report_id) + except (NotFound, Conflict): + return False + else: + return True + + +class TempStore(metaclass=SingletonMeta): + """HardPy temporary storage factory for data synchronization. + + Creates appropriate storage backend based on configuration: + - JSON file storage when storage_type is "json" + - CouchDB storage when storage_type is "couchdb" + + This ensures temporary reports are stored in the same backend as the main data. + """ + + def __init__(self) -> None: + config = ConfigManager() + storage_type = config.config.database.storage_type + + self._impl: TempStoreInterface + if storage_type == "json": + self._impl = JsonTempStore() + elif storage_type == "couchdb": + self._impl = CouchDBTempStore() + else: + msg = f"Unknown storage type: {storage_type}" + raise ValueError(msg) + + def push_report(self, report: ResultRunStore) -> bool: + """Push report to the temporary storage. + + Args: + report (ResultRunStore): report to store + + Returns: + bool: True if successful, False otherwise + """ + return self._impl.push_report(report) + + def reports(self) -> Generator[dict]: + """Get all reports from the temporary storage. + + Yields: + dict: report from temporary storage + """ + yield from self._impl.reports() + + def delete(self, report_id: str) -> bool: + """Delete report from the temporary storage. + + Args: + report_id (str): report ID to delete + + Returns: + bool: True if successful, False otherwise + """ + return self._impl.delete(report_id) + def dict_to_schema(self, report: dict) -> ResultRunStore: """Convert report dict to report schema. @@ -106,4 +269,4 @@ def dict_to_schema(self, report: dict) -> ResultRunStore: Returns: ResultRunStore: validated report schema """ - return self._schema(**report) + return self._impl.dict_to_schema(report) diff --git a/hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py b/hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py index de3cea4a..6d89b450 100644 --- a/hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py +++ b/hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py @@ -21,7 +21,18 @@ class StandCloudSynchronizer: """Synchronize reports with StandCloud.""" def __init__(self) -> None: - self._tempstore = TempStore() + self._tempstore: TempStore | None = None + + @property + def _get_tempstore(self) -> TempStore: + """Get TempStore instance lazily. + + Returns: + TempStore: TempStore singleton instance + """ + if self._tempstore is None: + self._tempstore = TempStore() + return self._tempstore def sync(self) -> str: """Sync reports with StandCloud. @@ -29,16 +40,16 @@ def sync(self) -> str: Returns: str: Synchronization message """ - if not self._tempstore.reports(): + if not self._get_tempstore.reports(): return "All reports are synchronized with StandCloud" loader = self._create_sc_loader() invalid_reports = [] success_report_counter = 0 - for _report in self._tempstore.reports(): + for _report in self._get_tempstore.reports(): try: - report_id = _report.get("id") - document: dict = _report.get("doc") + report_id: str = _report.get("id") # type: ignore[assignment] + document: dict = _report.get("doc") # type: ignore[assignment] document.pop("rev") except KeyError: try: @@ -50,7 +61,7 @@ def sync(self) -> str: invalid_reports.append({report_id: reason}) continue try: - schema_report = self._tempstore.dict_to_schema(document) + schema_report = self._get_tempstore.dict_to_schema(document) except ValidationError as exc: reason = f"Report has invalid format: {exc}" invalid_reports.append({report_id: reason}) @@ -65,7 +76,7 @@ def sync(self) -> str: reason = f"Staus code: {response.status_code}, text: {response.text}" invalid_reports.append({report_id: reason}) continue - if not self._tempstore.delete(report_id): + if not self._get_tempstore.delete(report_id): reason = f"Report {report_id} not deleted from the temporary storage" invalid_reports.append({report_id: reason}) success_report_counter += 1 @@ -80,7 +91,7 @@ def push_to_tempstore(self, report: ResultRunStore) -> bool: Returns: bool: True if success, else False """ - return self._tempstore.push_report(report) + return self._get_tempstore.push_report(report) def push_to_sc(self, report: ResultRunStore) -> bool: """Push report to the StandCloud. From 04ef08519c5abc4f407dae9f1a81ab33519cd5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 13:42:29 +0100 Subject: [PATCH 29/81] db: Rename `base_store` to `couchdb_store` for clarity and consistency --- hardpy/pytest_hardpy/db/{base_store.py => couchdb_store.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename hardpy/pytest_hardpy/db/{base_store.py => couchdb_store.py} (100%) diff --git a/hardpy/pytest_hardpy/db/base_store.py b/hardpy/pytest_hardpy/db/couchdb_store.py similarity index 100% rename from hardpy/pytest_hardpy/db/base_store.py rename to hardpy/pytest_hardpy/db/couchdb_store.py From c6cde7ae08774c402f741aa2852d363305eaf414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 13:44:00 +0100 Subject: [PATCH 30/81] db: Replace BaseStore with CouchDBStore and remove backward compatibility alias --- hardpy/pytest_hardpy/db/__init__.py | 4 ++-- hardpy/pytest_hardpy/db/couchdb_store.py | 4 ---- hardpy/pytest_hardpy/db/runstore.py | 2 +- hardpy/pytest_hardpy/db/storage_factory.py | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/hardpy/pytest_hardpy/db/__init__.py b/hardpy/pytest_hardpy/db/__init__.py index c267a673..361f26e6 100644 --- a/hardpy/pytest_hardpy/db/__init__.py +++ b/hardpy/pytest_hardpy/db/__init__.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Everypin # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -from hardpy.pytest_hardpy.db.base_store import BaseStore +from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore from hardpy.pytest_hardpy.db.const import DatabaseField from hardpy.pytest_hardpy.db.runstore import RunStore from hardpy.pytest_hardpy.db.schema import ResultRunStore, ResultStateStore @@ -16,8 +16,8 @@ from hardpy.pytest_hardpy.db.tempstore import TempStore __all__ = [ - "BaseStore", "Chart", + "CouchDBStore", "DatabaseField", "Instrument", "NumericMeasurement", diff --git a/hardpy/pytest_hardpy/db/couchdb_store.py b/hardpy/pytest_hardpy/db/couchdb_store.py index 0f7e0a5b..76588e4b 100644 --- a/hardpy/pytest_hardpy/db/couchdb_store.py +++ b/hardpy/pytest_hardpy/db/couchdb_store.py @@ -182,7 +182,3 @@ def _init_doc(self) -> dict: } return doc - - -# Backward compatibility alias -BaseStore = CouchDBStore diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 20502c1a..3a4ce804 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -30,7 +30,7 @@ def __init__(self) -> None: # For CouchDB: Clear the runstore on initialization # For JSON: The JsonFileStore __init__ already loads existing data # Only clear if explicitly requested via clear() method - from hardpy.pytest_hardpy.db.base_store import CouchDBStore + from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore if isinstance(self._storage, CouchDBStore): try: self._storage.clear() diff --git a/hardpy/pytest_hardpy/db/storage_factory.py b/hardpy/pytest_hardpy/db/storage_factory.py index 5ac12517..7ffe6ba8 100644 --- a/hardpy/pytest_hardpy/db/storage_factory.py +++ b/hardpy/pytest_hardpy/db/storage_factory.py @@ -45,7 +45,7 @@ def create_storage(store_name: str) -> IStorage: if storage_type == "couchdb": try: - from hardpy.pytest_hardpy.db.base_store import CouchDBStore + from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore except ImportError as exc: msg = ( "CouchDB storage requires pycouchdb. " From bcb33590cf66aad25d5c77fbadfd311d97311887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 13:51:37 +0100 Subject: [PATCH 31/81] docs: Fix typo in hardpy_config `storage_path` section --- docs/documentation/hardpy_config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/documentation/hardpy_config.md b/docs/documentation/hardpy_config.md index b5edf11b..a509afec 100644 --- a/docs/documentation/hardpy_config.md +++ b/docs/documentation/hardpy_config.md @@ -93,7 +93,7 @@ The user can change this value with the `hardpy init --storage-type` option. #### storage_path -Path to the storage directory. The default is `.hardpy` int the root of the project. +Path to the storage directory. The default is `.hardpy` in the root of the project. The user can change this value with the `hardpy init --storage-path` option. Relative and absolute paths are supported. #### user From a44804bdc9cc390cd9603db18014d1ed298a4cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 13:56:04 +0100 Subject: [PATCH 32/81] db: Rename `IStorage` to `Storage` across database modules for clarity and consistency --- hardpy/pytest_hardpy/db/couchdb_store.py | 4 ++-- hardpy/pytest_hardpy/db/json_file_store.py | 4 ++-- hardpy/pytest_hardpy/db/runstore.py | 4 ++-- hardpy/pytest_hardpy/db/statestore.py | 4 ++-- hardpy/pytest_hardpy/db/storage_factory.py | 6 +++--- hardpy/pytest_hardpy/db/storage_interface.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/hardpy/pytest_hardpy/db/couchdb_store.py b/hardpy/pytest_hardpy/db/couchdb_store.py index 76588e4b..d88f5fdd 100644 --- a/hardpy/pytest_hardpy/db/couchdb_store.py +++ b/hardpy/pytest_hardpy/db/couchdb_store.py @@ -14,10 +14,10 @@ from hardpy.common.config import ConfigManager from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 -from hardpy.pytest_hardpy.db.storage_interface import IStorage +from hardpy.pytest_hardpy.db.storage_interface import Storage -class CouchDBStore(IStorage): +class CouchDBStore(Storage): """CouchDB-based storage implementation. This class provides storage using CouchDB as the backend. diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index eb1aa306..919f03e2 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -13,10 +13,10 @@ from hardpy.common.config import ConfigManager from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 -from hardpy.pytest_hardpy.db.storage_interface import IStorage +from hardpy.pytest_hardpy.db.storage_interface import Storage -class JsonFileStore(IStorage): +class JsonFileStore(Storage): """JSON file-based storage implementation. Stores data in JSON files within the 'storage_path' directory of the test project. diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 3a4ce804..cd10a5db 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from pydantic import BaseModel - from hardpy.pytest_hardpy.db.storage_interface import IStorage + from hardpy.pytest_hardpy.db.storage_interface import Storage class RunStore(metaclass=SingletonMeta): @@ -25,7 +25,7 @@ class RunStore(metaclass=SingletonMeta): def __init__(self) -> None: self._log = getLogger(__name__) - self._storage: IStorage = StorageFactory.create_storage("runstore") + self._storage: Storage = StorageFactory.create_storage("runstore") # For CouchDB: Clear the runstore on initialization # For JSON: The JsonFileStore __init__ already loads existing data diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index c5ee8a9e..b568a58f 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from pydantic._internal._model_construction import ModelMetaclass - from hardpy.pytest_hardpy.db.storage_interface import IStorage + from hardpy.pytest_hardpy.db.storage_interface import Storage class StateStore(metaclass=SingletonMeta): @@ -25,7 +25,7 @@ class StateStore(metaclass=SingletonMeta): def __init__(self) -> None: self._log = getLogger(__name__) - self._storage: IStorage = StorageFactory.create_storage("statestore") + self._storage: Storage = StorageFactory.create_storage("statestore") self._storage._schema = ResultStateStore # type: ignore # noqa: SLF001 def get_field(self, key: str) -> Any: # noqa: ANN401 diff --git a/hardpy/pytest_hardpy/db/storage_factory.py b/hardpy/pytest_hardpy/db/storage_factory.py index 7ffe6ba8..bf9194a4 100644 --- a/hardpy/pytest_hardpy/db/storage_factory.py +++ b/hardpy/pytest_hardpy/db/storage_factory.py @@ -8,7 +8,7 @@ from hardpy.common.config import ConfigManager if TYPE_CHECKING: - from hardpy.pytest_hardpy.db.storage_interface import IStorage + from hardpy.pytest_hardpy.db.storage_interface import Storage logger = getLogger(__name__) @@ -21,14 +21,14 @@ class StorageFactory: """ @staticmethod - def create_storage(store_name: str) -> IStorage: + def create_storage(store_name: str) -> Storage: """Create storage instance based on configuration. Args: store_name (str): Name of the storage (e.g., "runstore", "statestore") Returns: - IStorage: Storage instance + Storage: Storage instance Raises: ValueError: If storage type is unknown or unsupported diff --git a/hardpy/pytest_hardpy/db/storage_interface.py b/hardpy/pytest_hardpy/db/storage_interface.py index 531bf5cb..84fb5f15 100644 --- a/hardpy/pytest_hardpy/db/storage_interface.py +++ b/hardpy/pytest_hardpy/db/storage_interface.py @@ -9,7 +9,7 @@ from pydantic import BaseModel -class IStorage(ABC): +class Storage(ABC): """Abstract storage interface for HardPy data persistence. This interface defines the contract for storage implementations, From 51b40c4b54379cfcab789b80c0b34e0234a0bd70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:00:55 +0100 Subject: [PATCH 33/81] db: Replace `ModelMetaclass` with `BaseModel` in `statestore` module for consistency --- hardpy/pytest_hardpy/db/statestore.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index b568a58f..6baf1e09 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -11,7 +11,7 @@ from hardpy.pytest_hardpy.db.storage_factory import StorageFactory if TYPE_CHECKING: - from pydantic._internal._model_construction import ModelMetaclass + from pydantic import BaseModel from hardpy.pytest_hardpy.db.storage_interface import Storage @@ -56,11 +56,11 @@ def update_doc(self) -> None: """Update current document by database.""" self._storage.update_doc() - def get_document(self) -> ModelMetaclass: + def get_document(self) -> BaseModel: """Get document by schema. Returns: - ModelMetaclass: document by schema + BaseModel: document by schema """ return self._storage.get_document() From 6faecc99237b2a15b7fe95528dfec1e5a216ae74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:03:36 +0100 Subject: [PATCH 34/81] db: Introduce `RunStoreInterface` and implement `JsonRunStore`, `CouchDBRunStore`, and factory logic --- hardpy/pytest_hardpy/db/runstore.py | 201 +++++++++++++++++++++++++--- 1 file changed, 186 insertions(+), 15 deletions(-) diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index cd10a5db..df44a847 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -3,39 +3,141 @@ from __future__ import annotations +from abc import ABC, abstractmethod from logging import getLogger from typing import TYPE_CHECKING, Any +from hardpy.common.config import ConfigManager from hardpy.common.singleton import SingletonMeta from hardpy.pytest_hardpy.db.schema import ResultRunStore -from hardpy.pytest_hardpy.db.storage_factory import StorageFactory if TYPE_CHECKING: from pydantic import BaseModel - from hardpy.pytest_hardpy.db.storage_interface import Storage +class RunStoreInterface(ABC): + """Interface for run storage implementations.""" -class RunStore(metaclass=SingletonMeta): - """HardPy run storage interface. + @abstractmethod + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the run store. - Save state and case artifact. Supports multiple storage backends - (JSON files, CouchDB) through the storage factory pattern. + Args: + key (str): field name + + Returns: + Any: field value + """ + + @abstractmethod + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value. + + Args: + key (str): document key + value: document value + """ + + @abstractmethod + def update_db(self) -> None: + """Update database by current document.""" + + @abstractmethod + def update_doc(self) -> None: + """Update current document by database.""" + + @abstractmethod + def get_document(self) -> BaseModel: + """Get document by schema. + + Returns: + BaseModel: document by schema + """ + + @abstractmethod + def clear(self) -> None: + """Clear database.""" + + @abstractmethod + def compact(self) -> None: + """Compact database.""" + + +class JsonRunStore(RunStoreInterface): + """JSON file-based run storage implementation. + + Stores test run data using JSON files on the local filesystem. """ def __init__(self) -> None: + from hardpy.pytest_hardpy.db.json_file_store import JsonFileStore + self._log = getLogger(__name__) - self._storage: Storage = StorageFactory.create_storage("runstore") + self._storage = JsonFileStore("runstore") + self._storage._schema = ResultRunStore # type: ignore # noqa: SLF001 + + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the run store. + + Args: + key (str): field name + + Returns: + Any: field value + """ + return self._storage.get_field(key) + + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value. + + Args: + key (str): document key + value: document value + """ + self._storage.update_doc_value(key, value) + + def update_db(self) -> None: + """Update database by current document.""" + self._storage.update_db() + + def update_doc(self) -> None: + """Update current document by database.""" + self._storage.update_doc() - # For CouchDB: Clear the runstore on initialization - # For JSON: The JsonFileStore __init__ already loads existing data - # Only clear if explicitly requested via clear() method + def get_document(self) -> BaseModel: + """Get document by schema. + + Returns: + BaseModel: document by schema + """ + return self._storage.get_document() + + def clear(self) -> None: + """Clear database.""" + self._storage.clear() + + def compact(self) -> None: + """Compact database.""" + self._storage.compact() + + +class CouchDBRunStore(RunStoreInterface): + """CouchDB-based run storage implementation. + + Stores test run data in CouchDB database. + """ + + def __init__(self) -> None: from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore - if isinstance(self._storage, CouchDBStore): - try: - self._storage.clear() - except Exception: # noqa: BLE001 - self._log.debug("Runstore storage will be created for the first time") + + self._log = getLogger(__name__) + self._storage = CouchDBStore("runstore") + + # Clear the runstore on initialization for CouchDB + try: + self._storage.clear() + except Exception: # noqa: BLE001 + self._log.debug("Runstore storage will be created for the first time") self._storage._schema = ResultRunStore # type: ignore # noqa: SLF001 @@ -82,3 +184,72 @@ def clear(self) -> None: def compact(self) -> None: """Compact database.""" self._storage.compact() + + +class RunStore(metaclass=SingletonMeta): + """HardPy run storage factory for test run data. + + Creates appropriate storage backend based on configuration: + - JSON file storage when storage_type is "json" + - CouchDB storage when storage_type is "couchdb" + + Save state and case artifact. Supports multiple storage backends + through the factory pattern. + """ + + def __init__(self) -> None: + config = ConfigManager() + storage_type = config.config.database.storage_type + + self._impl: RunStoreInterface + if storage_type == "json": + self._impl = JsonRunStore() + elif storage_type == "couchdb": + self._impl = CouchDBRunStore() + else: + msg = f"Unknown storage type: {storage_type}" + raise ValueError(msg) + + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the run store. + + Args: + key (str): field name + + Returns: + Any: field value + """ + return self._impl.get_field(key) + + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value. + + Args: + key (str): document key + value: document value + """ + self._impl.update_doc_value(key, value) + + def update_db(self) -> None: + """Update database by current document.""" + self._impl.update_db() + + def update_doc(self) -> None: + """Update current document by database.""" + self._impl.update_doc() + + def get_document(self) -> BaseModel: + """Get document by schema. + + Returns: + BaseModel: document by schema + """ + return self._impl.get_document() + + def clear(self) -> None: + """Clear database.""" + self._impl.clear() + + def compact(self) -> None: + """Compact database.""" + self._impl.compact() From d4c220df4e01451a202fd93c01dcbc6491752107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:10:19 +0100 Subject: [PATCH 35/81] db: Replace string-based storage type with `StorageType` enum across modules --- hardpy/common/config.py | 15 ++++++++++++++- hardpy/hardpy_panel/api.py | 4 ++-- hardpy/pytest_hardpy/db/runstore.py | 6 +++--- hardpy/pytest_hardpy/db/storage_factory.py | 8 ++++---- hardpy/pytest_hardpy/db/tempstore.py | 11 +++++++---- 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/hardpy/common/config.py b/hardpy/common/config.py index 5624e459..cf997190 100644 --- a/hardpy/common/config.py +++ b/hardpy/common/config.py @@ -2,6 +2,7 @@ # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import annotations +from enum import Enum from logging import getLogger from pathlib import Path @@ -14,12 +15,24 @@ logger = getLogger(__name__) +class StorageType(str, Enum): + """Storage backend types for HardPy data persistence. + + Attributes: + JSON: JSON file-based storage on local filesystem + COUCHDB: CouchDB database storage + """ + + JSON = "json" + COUCHDB = "couchdb" + + class DatabaseConfig(BaseModel): """Database configuration.""" model_config = ConfigDict(extra="forbid") - storage_type: str = "couchdb" # "json" or "couchdb" + storage_type: StorageType = StorageType.COUCHDB user: str = "dev" password: str = "dev" host: str = "localhost" diff --git a/hardpy/hardpy_panel/api.py b/hardpy/hardpy_panel/api.py index 3309b6a4..b00295d8 100644 --- a/hardpy/hardpy_panel/api.py +++ b/hardpy/hardpy_panel/api.py @@ -17,7 +17,7 @@ from fastapi import FastAPI, Query, Request from fastapi.staticfiles import StaticFiles -from hardpy.common.config import ConfigManager +from hardpy.common.config import ConfigManager, StorageType from hardpy.pytest_hardpy.pytest_wrapper import PyTestWrapper from hardpy.pytest_hardpy.result.report_synchronizer import StandCloudSynchronizer @@ -382,7 +382,7 @@ def get_json_data() -> dict: config_manager = ConfigManager() storage_type = config_manager.config.database.storage_type - if storage_type != "json": + if storage_type != StorageType.JSON: return {"error": "JSON storage not configured"} try: diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index df44a847..cef73107 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -7,7 +7,7 @@ from logging import getLogger from typing import TYPE_CHECKING, Any -from hardpy.common.config import ConfigManager +from hardpy.common.config import ConfigManager, StorageType from hardpy.common.singleton import SingletonMeta from hardpy.pytest_hardpy.db.schema import ResultRunStore @@ -202,9 +202,9 @@ def __init__(self) -> None: storage_type = config.config.database.storage_type self._impl: RunStoreInterface - if storage_type == "json": + if storage_type == StorageType.JSON: self._impl = JsonRunStore() - elif storage_type == "couchdb": + elif storage_type == StorageType.COUCHDB: self._impl = CouchDBRunStore() else: msg = f"Unknown storage type: {storage_type}" diff --git a/hardpy/pytest_hardpy/db/storage_factory.py b/hardpy/pytest_hardpy/db/storage_factory.py index bf9194a4..d67c6cf2 100644 --- a/hardpy/pytest_hardpy/db/storage_factory.py +++ b/hardpy/pytest_hardpy/db/storage_factory.py @@ -5,7 +5,7 @@ from logging import getLogger from typing import TYPE_CHECKING -from hardpy.common.config import ConfigManager +from hardpy.common.config import ConfigManager, StorageType if TYPE_CHECKING: from hardpy.pytest_hardpy.db.storage_interface import Storage @@ -35,15 +35,15 @@ def create_storage(store_name: str) -> Storage: ImportError: If required dependencies for storage type are not installed """ config = ConfigManager().config - storage_type = getattr(config.database, "storage_type", "json") + storage_type = config.database.storage_type - if storage_type == "json": + if storage_type == StorageType.JSON: from hardpy.pytest_hardpy.db.json_file_store import JsonFileStore logger.debug(f"Creating JSON file storage for {store_name}") return JsonFileStore(store_name) - if storage_type == "couchdb": + if storage_type == StorageType.COUCHDB: try: from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore except ImportError as exc: diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index 09345c75..ae5d43f3 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING from uuid import uuid4 -from hardpy.common.config import ConfigManager +from hardpy.common.config import ConfigManager, StorageType from hardpy.common.singleton import SingletonMeta from hardpy.pytest_hardpy.db.schema import ResultRunStore @@ -197,7 +197,10 @@ def delete(self, report_id: str) -> bool: Returns: bool: True if successful, False otherwise """ - from pycouchdb.exceptions import Conflict, NotFound # type: ignore[import-untyped] + from pycouchdb.exceptions import ( # type: ignore[import-untyped] + Conflict, + NotFound, + ) try: self._db.delete(report_id) @@ -222,9 +225,9 @@ def __init__(self) -> None: storage_type = config.config.database.storage_type self._impl: TempStoreInterface - if storage_type == "json": + if storage_type == StorageType.JSON: self._impl = JsonTempStore() - elif storage_type == "couchdb": + elif storage_type == StorageType.COUCHDB: self._impl = CouchDBTempStore() else: msg = f"Unknown storage type: {storage_type}" From 538b9b26e4a7566ce4d7bdd30b05d6c44a9e6589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:14:29 +0100 Subject: [PATCH 36/81] db: Update imports for improved type-checking compatibility and remove unused imports --- hardpy/pytest_hardpy/db/__init__.py | 2 +- hardpy/pytest_hardpy/db/couchdb_store.py | 2 +- hardpy/pytest_hardpy/db/json_file_store.py | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/hardpy/pytest_hardpy/db/__init__.py b/hardpy/pytest_hardpy/db/__init__.py index 361f26e6..12f1f877 100644 --- a/hardpy/pytest_hardpy/db/__init__.py +++ b/hardpy/pytest_hardpy/db/__init__.py @@ -1,8 +1,8 @@ # Copyright (c) 2025 Everypin # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore from hardpy.pytest_hardpy.db.const import DatabaseField +from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore from hardpy.pytest_hardpy.db.runstore import RunStore from hardpy.pytest_hardpy.db.schema import ResultRunStore, ResultStateStore from hardpy.pytest_hardpy.db.stand_type import ( diff --git a/hardpy/pytest_hardpy/db/couchdb_store.py b/hardpy/pytest_hardpy/db/couchdb_store.py index d88f5fdd..12b7e953 100644 --- a/hardpy/pytest_hardpy/db/couchdb_store.py +++ b/hardpy/pytest_hardpy/db/couchdb_store.py @@ -3,7 +3,7 @@ from json import dumps from logging import getLogger -from typing import Any, TYPE_CHECKING +from typing import Any from glom import assign, glom from pycouchdb import Server as DbServer diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 919f03e2..11bdd8bc 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -9,12 +9,14 @@ from typing import TYPE_CHECKING, Any from glom import assign, glom -from pydantic import BaseModel from hardpy.common.config import ConfigManager from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 from hardpy.pytest_hardpy.db.storage_interface import Storage +if TYPE_CHECKING: + from pydantic import BaseModel + class JsonFileStore(Storage): """JSON file-based storage implementation. From 466ecc9aeed639bb9176181f73076b6739de6114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:40:10 +0100 Subject: [PATCH 37/81] db: Add schema-based validation to storage modules and refactor initialization methods --- hardpy/pytest_hardpy/db/couchdb_store.py | 4 ++-- hardpy/pytest_hardpy/db/json_file_store.py | 5 +++-- hardpy/pytest_hardpy/db/runstore.py | 7 ++----- hardpy/pytest_hardpy/db/statestore.py | 5 +++-- hardpy/pytest_hardpy/db/storage_factory.py | 9 ++++++--- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/hardpy/pytest_hardpy/db/couchdb_store.py b/hardpy/pytest_hardpy/db/couchdb_store.py index 12b7e953..7709577a 100644 --- a/hardpy/pytest_hardpy/db/couchdb_store.py +++ b/hardpy/pytest_hardpy/db/couchdb_store.py @@ -24,7 +24,7 @@ class CouchDBStore(Storage): Handles database connections, document revisions, and conflict resolution. """ - def __init__(self, db_name: str) -> None: + def __init__(self, db_name: str, schema: type[BaseModel]) -> None: config_manager = ConfigManager() config = config_manager.config self._db_srv = DbServer(config.database.url) @@ -33,7 +33,7 @@ def __init__(self, db_name: str) -> None: self._doc_id = config.database.doc_id self._log = getLogger(__name__) self._doc: dict = self._init_doc() - self._schema: BaseModel + self._schema: type[BaseModel] = schema def compact(self) -> None: """Compact database.""" diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 11bdd8bc..28a095bb 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -25,11 +25,12 @@ class JsonFileStore(Storage): Provides atomic writes for safer file operations. """ - def __init__(self, store_name: str) -> None: + def __init__(self, store_name: str, schema: type[BaseModel]) -> None: """Initialize JSON file storage. Args: store_name (str): Name of the storage (e.g., "runstore", "statestore") + schema (type[BaseModel]): Pydantic model class for document validation """ config_manager = ConfigManager() self._store_name = store_name @@ -40,7 +41,7 @@ def __init__(self, store_name: str) -> None: self._doc_id = config_manager.config.database.doc_id self._log = getLogger(__name__) self._doc: dict = self._init_doc() - self._schema: BaseModel + self._schema: type[BaseModel] = schema def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field value from document using dot notation. diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index cef73107..294b442b 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -73,8 +73,7 @@ def __init__(self) -> None: from hardpy.pytest_hardpy.db.json_file_store import JsonFileStore self._log = getLogger(__name__) - self._storage = JsonFileStore("runstore") - self._storage._schema = ResultRunStore # type: ignore # noqa: SLF001 + self._storage = JsonFileStore("runstore", ResultRunStore) def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the run store. @@ -131,7 +130,7 @@ def __init__(self) -> None: from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore self._log = getLogger(__name__) - self._storage = CouchDBStore("runstore") + self._storage = CouchDBStore("runstore", ResultRunStore) # Clear the runstore on initialization for CouchDB try: @@ -139,8 +138,6 @@ def __init__(self) -> None: except Exception: # noqa: BLE001 self._log.debug("Runstore storage will be created for the first time") - self._storage._schema = ResultRunStore # type: ignore # noqa: SLF001 - def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the run store. diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 6baf1e09..62ca57e1 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -25,8 +25,9 @@ class StateStore(metaclass=SingletonMeta): def __init__(self) -> None: self._log = getLogger(__name__) - self._storage: Storage = StorageFactory.create_storage("statestore") - self._storage._schema = ResultStateStore # type: ignore # noqa: SLF001 + self._storage: Storage = StorageFactory.create_storage( + "statestore", ResultStateStore, + ) def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the state store. diff --git a/hardpy/pytest_hardpy/db/storage_factory.py b/hardpy/pytest_hardpy/db/storage_factory.py index d67c6cf2..1d9072e9 100644 --- a/hardpy/pytest_hardpy/db/storage_factory.py +++ b/hardpy/pytest_hardpy/db/storage_factory.py @@ -8,6 +8,8 @@ from hardpy.common.config import ConfigManager, StorageType if TYPE_CHECKING: + from pydantic import BaseModel + from hardpy.pytest_hardpy.db.storage_interface import Storage logger = getLogger(__name__) @@ -21,11 +23,12 @@ class StorageFactory: """ @staticmethod - def create_storage(store_name: str) -> Storage: + def create_storage(store_name: str, schema: type[BaseModel]) -> Storage: """Create storage instance based on configuration. Args: store_name (str): Name of the storage (e.g., "runstore", "statestore") + schema (type[BaseModel]): Pydantic model class for document validation Returns: Storage: Storage instance @@ -41,7 +44,7 @@ def create_storage(store_name: str) -> Storage: from hardpy.pytest_hardpy.db.json_file_store import JsonFileStore logger.debug(f"Creating JSON file storage for {store_name}") - return JsonFileStore(store_name) + return JsonFileStore(store_name, schema) if storage_type == StorageType.COUCHDB: try: @@ -55,7 +58,7 @@ def create_storage(store_name: str) -> Storage: raise ImportError(msg) from exc logger.debug(f"Creating CouchDB storage for {store_name}") - return CouchDBStore(store_name) + return CouchDBStore(store_name, schema) msg = ( f"Unknown storage type: {storage_type}. " From 1c03e840a20762476610682008c091a0b04885f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:44:04 +0100 Subject: [PATCH 38/81] db: Ensure `id` field is always set in temp store reports --- hardpy/pytest_hardpy/db/tempstore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index ae5d43f3..506337ae 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -87,6 +87,7 @@ def push_report(self, report: ResultRunStore) -> bool: """ report_dict = report.model_dump() report_id = report_dict.get("id", str(uuid4())) + report_dict["id"] = report_id # Ensure ID is in the document report_file = self._storage_dir / f"{report_id}.json" try: From feb43903b58da0b0706cffaedfd7a0706c06b846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:48:37 +0100 Subject: [PATCH 39/81] db: Handle missing `id` field in temp store reports gracefully --- hardpy/pytest_hardpy/db/tempstore.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index 506337ae..bf45df3d 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -171,7 +171,10 @@ def push_report(self, report: ResultRunStore) -> bool: from pycouchdb.exceptions import Conflict # type: ignore[import-untyped] report_dict = report.model_dump() - report_id = report_dict.pop("id") + report_id = report_dict.pop("id", None) + if not report_id: + self._log.error("Report missing required 'id' field") + return False try: self._db.save(report_dict) except Conflict as exc: From 9fb1098d8850f969ceef6f8de634f05edbdae201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Tue, 9 Dec 2025 14:57:06 +0100 Subject: [PATCH 40/81] db: Consolidate document initialization logic with `_create_default_doc_structure` method --- hardpy/pytest_hardpy/db/couchdb_store.py | 62 ++------------ hardpy/pytest_hardpy/db/json_file_store.py | 89 ++------------------ hardpy/pytest_hardpy/db/storage_interface.py | 41 +++++++++ 3 files changed, 53 insertions(+), 139 deletions(-) diff --git a/hardpy/pytest_hardpy/db/couchdb_store.py b/hardpy/pytest_hardpy/db/couchdb_store.py index 7709577a..3199f57f 100644 --- a/hardpy/pytest_hardpy/db/couchdb_store.py +++ b/hardpy/pytest_hardpy/db/couchdb_store.py @@ -119,66 +119,16 @@ def _init_doc(self) -> dict: try: doc = self._db.get(self._doc_id) except NotFound: - return { - "_id": self._doc_id, - DF.MODULES: {}, - DF.DUT: { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - }, - DF.TEST_STAND: { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - }, - DF.PROCESS: { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - }, - } + return self._create_default_doc_structure(self._doc_id) # init document if DF.MODULES not in doc: doc[DF.MODULES] = {} - doc[DF.DUT] = { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - } - - doc[DF.TEST_STAND] = { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - } - - doc[DF.PROCESS] = { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - } + # Reset volatile fields + default_doc = self._create_default_doc_structure(doc["_id"]) + doc[DF.DUT] = default_doc[DF.DUT] + doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] + doc[DF.PROCESS] = default_doc[DF.PROCESS] return doc diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 28a095bb..21cd7f29 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -128,35 +128,7 @@ def clear(self) -> None: This differs from CouchDB where clear() immediately affects the database. """ # Reset document to initial state (in-memory only) - self._doc = { - "_id": self._doc_id, - DF.MODULES: {}, - DF.DUT: { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - }, - DF.TEST_STAND: { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - }, - DF.PROCESS: { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - }, - } + self._doc = self._create_default_doc_structure(self._doc_id) # NOTE: We do NOT call update_db() here to avoid persisting cleared state # The caller should call update_db() when they want to persist changes @@ -180,31 +152,10 @@ def _init_doc(self) -> dict: # Reset volatile fields for state-like stores if self._store_name == "statestore": - doc[DF.DUT] = { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - } - doc[DF.TEST_STAND] = { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - } - doc[DF.PROCESS] = { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - } + default_doc = self._create_default_doc_structure(doc["_id"]) + doc[DF.DUT] = default_doc[DF.DUT] + doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] + doc[DF.PROCESS] = default_doc[DF.PROCESS] return doc except json.JSONDecodeError: @@ -215,32 +166,4 @@ def _init_doc(self) -> dict: self._log.warning(f"Error loading storage file: {exc}, creating new") # Return default document structure - return { - "_id": self._doc_id, - DF.MODULES: {}, - DF.DUT: { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - }, - DF.TEST_STAND: { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - }, - DF.PROCESS: { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - }, - } + return self._create_default_doc_structure(self._doc_id) diff --git a/hardpy/pytest_hardpy/db/storage_interface.py b/hardpy/pytest_hardpy/db/storage_interface.py index 84fb5f15..718dd509 100644 --- a/hardpy/pytest_hardpy/db/storage_interface.py +++ b/hardpy/pytest_hardpy/db/storage_interface.py @@ -5,6 +5,8 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any +from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 + if TYPE_CHECKING: from pydantic import BaseModel @@ -60,3 +62,42 @@ def clear(self) -> None: @abstractmethod def compact(self) -> None: """Optimize storage (implementation-specific, may be no-op).""" + + def _create_default_doc_structure(self, doc_id: str) -> dict: + """Create default document structure with standard fields. + + Args: + doc_id (str): Document ID to use + + Returns: + dict: Default document structure with DUT, TEST_STAND, and PROCESS fields + """ + return { + "_id": doc_id, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } From df29ae26f835010f30104c2db7dc2a46481d8cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Thu, 11 Dec 2025 13:40:46 +0100 Subject: [PATCH 41/81] Apply suggestions from code review Co-authored-by: Ilya <163293136+xorialexandrov@users.noreply.github.com> --- hardpy/pytest_hardpy/db/json_file_store.py | 1 + hardpy/pytest_hardpy/db/storage_factory.py | 1 + hardpy/pytest_hardpy/db/storage_interface.py | 1 + 3 files changed, 3 insertions(+) diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 21cd7f29..24945c20 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025 Everypin # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import annotations diff --git a/hardpy/pytest_hardpy/db/storage_factory.py b/hardpy/pytest_hardpy/db/storage_factory.py index 1d9072e9..99acccba 100644 --- a/hardpy/pytest_hardpy/db/storage_factory.py +++ b/hardpy/pytest_hardpy/db/storage_factory.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025 Everypin # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import annotations diff --git a/hardpy/pytest_hardpy/db/storage_interface.py b/hardpy/pytest_hardpy/db/storage_interface.py index 718dd509..2baefadd 100644 --- a/hardpy/pytest_hardpy/db/storage_interface.py +++ b/hardpy/pytest_hardpy/db/storage_interface.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025 Everypin # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import annotations From 98761dd7136754d2441402958ce82898eed97166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 16:58:11 +0100 Subject: [PATCH 42/81] gitignore: Add `.hardpy` to ignored files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 05a71a84..510fa63a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ dist .mypy_cache __pycache__/ node_modules/ +.hardpy \ No newline at end of file From 70d818427f443b25d774abc4c8636941eda8faee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 17:31:04 +0100 Subject: [PATCH 43/81] db: Handle missing paths in document access and ensure safe updates with glom --- hardpy/pytest_hardpy/db/couchdb_store.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/hardpy/pytest_hardpy/db/couchdb_store.py b/hardpy/pytest_hardpy/db/couchdb_store.py index 3199f57f..f1058a7b 100644 --- a/hardpy/pytest_hardpy/db/couchdb_store.py +++ b/hardpy/pytest_hardpy/db/couchdb_store.py @@ -5,7 +5,7 @@ from logging import getLogger from typing import Any -from glom import assign, glom +from glom import PathAccessError, assign, glom from pycouchdb import Server as DbServer from pycouchdb.client import Database from pycouchdb.exceptions import Conflict, GenericError, NotFound @@ -46,9 +46,13 @@ def get_field(self, key: str) -> Any: # noqa: ANN401 key (str): field name Returns: - Any: field value + Any: field value, or None if path does not exist """ - return glom(self._doc, key) + try: + return glom(self._doc, key) + except PathAccessError: + # Return None for missing paths + return None def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 """Update document value. @@ -67,7 +71,8 @@ def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 # serialize non-serializable objects as string value = dumps(value, default=str) if "." in key: - assign(self._doc, key, value) + # Use glom's Assign with missing=dict to create intermediate paths + assign(self._doc, key, value, missing=dict) else: self._doc[key] = value From 05aa0e996c655e367d74a354d4bfd29e7c7376ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 17:31:16 +0100 Subject: [PATCH 44/81] db: Add `_rev` field to default document structure for CouchDB compatibility --- hardpy/pytest_hardpy/db/json_file_store.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 24945c20..568decdc 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -168,3 +168,9 @@ def _init_doc(self) -> dict: # Return default document structure return self._create_default_doc_structure(self._doc_id) + + def _create_default_doc_structure(self, doc_id: str) -> dict: + # CouchDB compatibility + doc = super()._create_default_doc_structure(doc_id) + doc["_rev"] = self._doc_id + return doc \ No newline at end of file From ba2a981f3fc5d3dd4d33c4fb2a62f200e76c0d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 17:46:29 +0100 Subject: [PATCH 45/81] db: Ensure storage directory exists before persisting JSON file --- hardpy/pytest_hardpy/db/json_file_store.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index 568decdc..b2fa556c 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -82,6 +82,9 @@ def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 def update_db(self) -> None: """Persist in-memory document to JSON file with atomic write.""" + # Ensure storage directory exists + self._storage_dir.mkdir(parents=True, exist_ok=True) + temp_file = self._file_path.with_suffix(".tmp") try: From 93888829e6be2bd812873b4be9444433efd2244b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 17:46:55 +0100 Subject: [PATCH 46/81] reporter: Use `set_doc_value` for setting stop times in modules and cases --- hardpy/pytest_hardpy/plugin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/hardpy/pytest_hardpy/plugin.py b/hardpy/pytest_hardpy/plugin.py index 373f999f..71b9376e 100644 --- a/hardpy/pytest_hardpy/plugin.py +++ b/hardpy/pytest_hardpy/plugin.py @@ -34,6 +34,7 @@ from hardpy.common.config import ConfigManager, HardpyConfig from hardpy.common.stand_cloud.connector import StandCloudConnector, StandCloudError +from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 from hardpy.pytest_hardpy.reporter import HookReporter from hardpy.pytest_hardpy.result.report_synchronizer.synchronizer import ( StandCloudSynchronizer, @@ -496,7 +497,9 @@ def _validate_stop_time(self) -> None: module_start_time = self._reporter.get_module_start_time(module_id) module_stop_time = self._reporter.get_module_stop_time(module_id) if module_start_time and not module_stop_time: - self._reporter.set_module_stop_time(module_start_time) + # Set stop time equal to start time + key = self._reporter.generate_key(DF.MODULES, module_id, DF.STOP_TIME) + self._reporter.set_doc_value(key, module_start_time) for module_data_key in module_data: # skip module status if module_data_key == "module_status": @@ -505,7 +508,9 @@ def _validate_stop_time(self) -> None: case_start_time = self._reporter.get_case_start_time(module_id, case_id) case_stop_time = self._reporter.get_case_stop_time(module_id, case_id) if case_start_time and not case_stop_time: - self._reporter.set_case_stop_time(case_start_time) + # Set stop time equal to start time + key = self._reporter.generate_key(DF.MODULES, module_id, DF.CASES, case_id, DF.STOP_TIME) + self._reporter.set_doc_value(key, case_start_time) def _stop_tests(self) -> None: """Update module and case statuses to stopped and skipped.""" From e926403d5e1c8db438741857d1fba2626c9c4411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 17:54:18 +0100 Subject: [PATCH 47/81] reporter: Fix stop time key generation formatting for consistency --- hardpy/pytest_hardpy/db/json_file_store.py | 2 +- hardpy/pytest_hardpy/plugin.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py index b2fa556c..7a76cd54 100644 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ b/hardpy/pytest_hardpy/db/json_file_store.py @@ -176,4 +176,4 @@ def _create_default_doc_structure(self, doc_id: str) -> dict: # CouchDB compatibility doc = super()._create_default_doc_structure(doc_id) doc["_rev"] = self._doc_id - return doc \ No newline at end of file + return doc diff --git a/hardpy/pytest_hardpy/plugin.py b/hardpy/pytest_hardpy/plugin.py index 71b9376e..a95d0c32 100644 --- a/hardpy/pytest_hardpy/plugin.py +++ b/hardpy/pytest_hardpy/plugin.py @@ -509,7 +509,8 @@ def _validate_stop_time(self) -> None: case_stop_time = self._reporter.get_case_stop_time(module_id, case_id) if case_start_time and not case_stop_time: # Set stop time equal to start time - key = self._reporter.generate_key(DF.MODULES, module_id, DF.CASES, case_id, DF.STOP_TIME) + key = self._reporter.generate_key(DF.MODULES, module_id, DF.CASES, + case_id, DF.STOP_TIME) self._reporter.set_doc_value(key, case_start_time) def _stop_tests(self) -> None: From 0046b3632ad846d510ce8094925c905c9024e0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 18:12:55 +0100 Subject: [PATCH 48/81] db: Replace interface-based run store implementation with storage factory pattern --- hardpy/pytest_hardpy/db/runstore.py | 200 +++------------------------- 1 file changed, 18 insertions(+), 182 deletions(-) diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 294b442b..150a07ce 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -3,140 +3,45 @@ from __future__ import annotations -from abc import ABC, abstractmethod from logging import getLogger from typing import TYPE_CHECKING, Any from hardpy.common.config import ConfigManager, StorageType from hardpy.common.singleton import SingletonMeta from hardpy.pytest_hardpy.db.schema import ResultRunStore +from hardpy.pytest_hardpy.db.storage_factory import StorageFactory if TYPE_CHECKING: from pydantic import BaseModel + from hardpy.pytest_hardpy.db.storage_interface import Storage -class RunStoreInterface(ABC): - """Interface for run storage implementations.""" - - @abstractmethod - def get_field(self, key: str) -> Any: # noqa: ANN401 - """Get field from the run store. - - Args: - key (str): field name - - Returns: - Any: field value - """ - - @abstractmethod - def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value. - - Args: - key (str): document key - value: document value - """ - - @abstractmethod - def update_db(self) -> None: - """Update database by current document.""" - - @abstractmethod - def update_doc(self) -> None: - """Update current document by database.""" - - @abstractmethod - def get_document(self) -> BaseModel: - """Get document by schema. - - Returns: - BaseModel: document by schema - """ - - @abstractmethod - def clear(self) -> None: - """Clear database.""" - - @abstractmethod - def compact(self) -> None: - """Compact database.""" +class RunStore(metaclass=SingletonMeta): + """HardPy run storage for test run data. -class JsonRunStore(RunStoreInterface): - """JSON file-based run storage implementation. + Creates appropriate storage backend based on configuration: + - JSON file storage when storage_type is "json" + - CouchDB storage when storage_type is "couchdb" - Stores test run data using JSON files on the local filesystem. + Save state and case artifact. Supports multiple storage backends + through the factory pattern. """ def __init__(self) -> None: - from hardpy.pytest_hardpy.db.json_file_store import JsonFileStore - self._log = getLogger(__name__) - self._storage = JsonFileStore("runstore", ResultRunStore) - - def get_field(self, key: str) -> Any: # noqa: ANN401 - """Get field from the run store. - - Args: - key (str): field name - - Returns: - Any: field value - """ - return self._storage.get_field(key) - - def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value. - - Args: - key (str): document key - value: document value - """ - self._storage.update_doc_value(key, value) - - def update_db(self) -> None: - """Update database by current document.""" - self._storage.update_db() - - def update_doc(self) -> None: - """Update current document by database.""" - self._storage.update_doc() - - def get_document(self) -> BaseModel: - """Get document by schema. - - Returns: - BaseModel: document by schema - """ - return self._storage.get_document() - - def clear(self) -> None: - """Clear database.""" - self._storage.clear() - - def compact(self) -> None: - """Compact database.""" - self._storage.compact() - - -class CouchDBRunStore(RunStoreInterface): - """CouchDB-based run storage implementation. - - Stores test run data in CouchDB database. - """ - - def __init__(self) -> None: - from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore + config = ConfigManager() - self._log = getLogger(__name__) - self._storage = CouchDBStore("runstore", ResultRunStore) + self._storage: Storage = StorageFactory.create_storage( + "runstore", ResultRunStore, + ) # Clear the runstore on initialization for CouchDB - try: - self._storage.clear() - except Exception: # noqa: BLE001 - self._log.debug("Runstore storage will be created for the first time") + if config.config.database.storage_type == StorageType.COUCHDB: + try: + self._storage.clear() + except Exception: # noqa: BLE001 + self._log.debug("Runstore storage will be created for the first time") def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the run store. @@ -181,72 +86,3 @@ def clear(self) -> None: def compact(self) -> None: """Compact database.""" self._storage.compact() - - -class RunStore(metaclass=SingletonMeta): - """HardPy run storage factory for test run data. - - Creates appropriate storage backend based on configuration: - - JSON file storage when storage_type is "json" - - CouchDB storage when storage_type is "couchdb" - - Save state and case artifact. Supports multiple storage backends - through the factory pattern. - """ - - def __init__(self) -> None: - config = ConfigManager() - storage_type = config.config.database.storage_type - - self._impl: RunStoreInterface - if storage_type == StorageType.JSON: - self._impl = JsonRunStore() - elif storage_type == StorageType.COUCHDB: - self._impl = CouchDBRunStore() - else: - msg = f"Unknown storage type: {storage_type}" - raise ValueError(msg) - - def get_field(self, key: str) -> Any: # noqa: ANN401 - """Get field from the run store. - - Args: - key (str): field name - - Returns: - Any: field value - """ - return self._impl.get_field(key) - - def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value. - - Args: - key (str): document key - value: document value - """ - self._impl.update_doc_value(key, value) - - def update_db(self) -> None: - """Update database by current document.""" - self._impl.update_db() - - def update_doc(self) -> None: - """Update current document by database.""" - self._impl.update_doc() - - def get_document(self) -> BaseModel: - """Get document by schema. - - Returns: - BaseModel: document by schema - """ - return self._impl.get_document() - - def clear(self) -> None: - """Clear database.""" - self._impl.clear() - - def compact(self) -> None: - """Compact database.""" - self._impl.compact() From 15296d7f92c05fd6f79905922356bd9051b68e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 18:31:58 +0100 Subject: [PATCH 49/81] db: Introduce abstract state store interface with JSON and CouchDB implementations --- hardpy/pytest_hardpy/db/statestore.py | 204 ++++++++++++++++++++++++-- 1 file changed, 189 insertions(+), 15 deletions(-) diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 62ca57e1..6ae53553 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -3,9 +3,11 @@ from __future__ import annotations +from abc import ABC, abstractmethod from logging import getLogger from typing import TYPE_CHECKING, Any +from hardpy.common.config import ConfigManager, StorageType from hardpy.common.singleton import SingletonMeta from hardpy.pytest_hardpy.db.schema import ResultStateStore from hardpy.pytest_hardpy.db.storage_factory import StorageFactory @@ -16,11 +18,115 @@ from hardpy.pytest_hardpy.db.storage_interface import Storage -class StateStore(metaclass=SingletonMeta): - """HardPy state storage interface. +class StateStoreInterface(ABC): + """Interface for state storage implementations.""" + + @abstractmethod + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the state store. + + Args: + key (str): Field key, supports nested access with dots + + Returns: + Any: Field value + """ + + @abstractmethod + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value in memory (does not persist). + + Args: + key (str): Field key, supports nested access with dots + value (Any): Value to set + """ + + @abstractmethod + def update_db(self) -> None: + """Persist in-memory document to storage backend.""" + + @abstractmethod + def update_doc(self) -> None: + """Reload document from storage backend to memory.""" + + @abstractmethod + def get_document(self) -> BaseModel: + """Get full document with schema validation. + + Returns: + BaseModel: Validated document model + """ + + @abstractmethod + def clear(self) -> None: + """Clear storage and reset to initial state.""" + + @abstractmethod + def compact(self) -> None: + """Optimize storage (implementation-specific, may be no-op).""" + + +class JsonStateStore(StateStoreInterface): + """JSON file-based state storage implementation. + + Stores test execution state using JSON files. + """ + + def __init__(self) -> None: + self._log = getLogger(__name__) + self._storage: Storage = StorageFactory.create_storage( + "statestore", ResultStateStore, + ) + + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the state store. + + Args: + key (str): Field key, supports nested access with dots + + Returns: + Any: Field value + """ + return self._storage.get_field(key) + + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value in memory (does not persist). + + Args: + key (str): Field key, supports nested access with dots + value (Any): Value to set + """ + self._storage.update_doc_value(key, value) + + def update_db(self) -> None: + """Persist in-memory document to storage backend.""" + self._storage.update_db() + + def update_doc(self) -> None: + """Reload document from storage backend to memory.""" + self._storage.update_doc() + + def get_document(self) -> BaseModel: + """Get full document with schema validation. + + Returns: + BaseModel: Validated document model + """ + return self._storage.get_document() + + def clear(self) -> None: + """Clear storage and reset to initial state.""" + self._storage.clear() - Stores current test execution state. Supports multiple storage backends - (JSON files, CouchDB) through the storage factory pattern. + def compact(self) -> None: + """Optimize storage (implementation-specific, may be no-op).""" + self._storage.compact() + + +class CouchDBStateStore(StateStoreInterface): + """CouchDB-based state storage implementation. + + Stores test execution state using CouchDB. """ def __init__(self) -> None: @@ -33,42 +139,110 @@ def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the state store. Args: - key (str): field name + key (str): Field key, supports nested access with dots Returns: - Any: field value + Any: Field value """ return self._storage.get_field(key) def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value. + """Update document value in memory (does not persist). Args: - key (str): document key - value: document value + key (str): Field key, supports nested access with dots + value (Any): Value to set """ self._storage.update_doc_value(key, value) def update_db(self) -> None: - """Update database by current document.""" + """Persist in-memory document to storage backend.""" self._storage.update_db() def update_doc(self) -> None: - """Update current document by database.""" + """Reload document from storage backend to memory.""" self._storage.update_doc() def get_document(self) -> BaseModel: - """Get document by schema. + """Get full document with schema validation. Returns: - BaseModel: document by schema + BaseModel: Validated document model """ return self._storage.get_document() def clear(self) -> None: - """Clear database.""" + """Clear storage and reset to initial state.""" self._storage.clear() def compact(self) -> None: - """Compact database.""" + """Optimize storage (implementation-specific, may be no-op).""" self._storage.compact() + + +class StateStore(metaclass=SingletonMeta): + """HardPy state storage factory for test execution state. + + Creates appropriate storage backend based on configuration: + - JSON file storage when storage_type is "json" + - CouchDB storage when storage_type is "couchdb" + + This ensures state data is stored in the same backend as the main data. + """ + + def __init__(self) -> None: + config = ConfigManager() + storage_type = config.config.database.storage_type + + self._impl: StateStoreInterface + if storage_type == StorageType.JSON: + self._impl = JsonStateStore() + elif storage_type == StorageType.COUCHDB: + self._impl = CouchDBStateStore() + else: + msg = f"Unknown storage type: {storage_type}" + raise ValueError(msg) + + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the state store. + + Args: + key (str): Field key, supports nested access with dots + + Returns: + Any: Field value + """ + return self._impl.get_field(key) + + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value in memory (does not persist). + + Args: + key (str): Field key, supports nested access with dots + value (Any): Value to set + """ + self._impl.update_doc_value(key, value) + + def update_db(self) -> None: + """Persist in-memory document to storage backend.""" + self._impl.update_db() + + def update_doc(self) -> None: + """Reload document from storage backend to memory.""" + self._impl.update_doc() + + def get_document(self) -> BaseModel: + """Get full document with schema validation. + + Returns: + BaseModel: Validated document model + """ + return self._impl.get_document() + + def clear(self) -> None: + """Clear storage and reset to initial state.""" + self._impl.clear() + + def compact(self) -> None: + """Optimize storage (implementation-specific, may be no-op).""" + self._impl.compact() From 0f966aab60a1659d6f75e6fc4c4d1fc4329837f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 18:40:56 +0100 Subject: [PATCH 50/81] db: Refactor state store to use factory pattern and remove redundant methods --- hardpy/pytest_hardpy/db/statestore.py | 59 +++++---------------------- 1 file changed, 11 insertions(+), 48 deletions(-) diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 6ae53553..78f8deaf 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -188,61 +188,24 @@ class StateStore(metaclass=SingletonMeta): - CouchDB storage when storage_type is "couchdb" This ensures state data is stored in the same backend as the main data. + + Note: This class acts as a factory. When instantiated, it returns + the appropriate concrete implementation (JsonStateStore or CouchDBStateStore). """ - def __init__(self) -> None: + def __new__(cls): # type: ignore[misc] + """Create and return the appropriate storage implementation. + + Returns: + StateStoreInterface: Concrete storage implementation based on config + """ config = ConfigManager() storage_type = config.config.database.storage_type - self._impl: StateStoreInterface if storage_type == StorageType.JSON: - self._impl = JsonStateStore() + return JsonStateStore() elif storage_type == StorageType.COUCHDB: - self._impl = CouchDBStateStore() + return CouchDBStateStore() else: msg = f"Unknown storage type: {storage_type}" raise ValueError(msg) - - def get_field(self, key: str) -> Any: # noqa: ANN401 - """Get field from the state store. - - Args: - key (str): Field key, supports nested access with dots - - Returns: - Any: Field value - """ - return self._impl.get_field(key) - - def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value in memory (does not persist). - - Args: - key (str): Field key, supports nested access with dots - value (Any): Value to set - """ - self._impl.update_doc_value(key, value) - - def update_db(self) -> None: - """Persist in-memory document to storage backend.""" - self._impl.update_db() - - def update_doc(self) -> None: - """Reload document from storage backend to memory.""" - self._impl.update_doc() - - def get_document(self) -> BaseModel: - """Get full document with schema validation. - - Returns: - BaseModel: Validated document model - """ - return self._impl.get_document() - - def clear(self) -> None: - """Clear storage and reset to initial state.""" - self._impl.clear() - - def compact(self) -> None: - """Optimize storage (implementation-specific, may be no-op).""" - self._impl.compact() From be77fd788561d787ef2f8e4cb4069f512fbfa7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 18:41:00 +0100 Subject: [PATCH 51/81] db: Refactor temp store to use factory pattern and remove unused methods --- hardpy/pytest_hardpy/db/tempstore.py | 56 ++++++---------------------- 1 file changed, 11 insertions(+), 45 deletions(-) diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index bf45df3d..d22667e8 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -222,58 +222,24 @@ class TempStore(metaclass=SingletonMeta): - CouchDB storage when storage_type is "couchdb" This ensures temporary reports are stored in the same backend as the main data. + + Note: This class acts as a factory. When instantiated, it returns + the appropriate concrete implementation (JsonTempStore or CouchDBTempStore). """ - def __init__(self) -> None: + def __new__(cls): # type: ignore[misc] + """Create and return the appropriate storage implementation. + + Returns: + TempStoreInterface: Concrete storage implementation based on config + """ config = ConfigManager() storage_type = config.config.database.storage_type - self._impl: TempStoreInterface if storage_type == StorageType.JSON: - self._impl = JsonTempStore() + return JsonTempStore() elif storage_type == StorageType.COUCHDB: - self._impl = CouchDBTempStore() + return CouchDBTempStore() else: msg = f"Unknown storage type: {storage_type}" raise ValueError(msg) - - def push_report(self, report: ResultRunStore) -> bool: - """Push report to the temporary storage. - - Args: - report (ResultRunStore): report to store - - Returns: - bool: True if successful, False otherwise - """ - return self._impl.push_report(report) - - def reports(self) -> Generator[dict]: - """Get all reports from the temporary storage. - - Yields: - dict: report from temporary storage - """ - yield from self._impl.reports() - - def delete(self, report_id: str) -> bool: - """Delete report from the temporary storage. - - Args: - report_id (str): report ID to delete - - Returns: - bool: True if successful, False otherwise - """ - return self._impl.delete(report_id) - - def dict_to_schema(self, report: dict) -> ResultRunStore: - """Convert report dict to report schema. - - Args: - report (dict): report dictionary - - Returns: - ResultRunStore: validated report schema - """ - return self._impl.dict_to_schema(report) From 4e05e59eb198dd394e8299ec6f3155415ca36564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 19:07:06 +0100 Subject: [PATCH 52/81] refactor(db): consolidate storage implementations and remove redundant abstraction Remove generic Storage layer (JsonFileStore/CouchDBStore) by moving logic directly into concrete implementations. Delete 4 files (~800 LOC). Refactor stores to use __new__ factory pattern instead of delegation. This simplifies the architecture from 2 abstraction layers to 1, eliminates delegation overhead, and makes the codebase easier to maintain. --- hardpy/pytest_hardpy/db/__init__.py | 2 - hardpy/pytest_hardpy/db/couchdb_store.py | 139 ------- hardpy/pytest_hardpy/db/json_file_store.py | 179 --------- hardpy/pytest_hardpy/db/runstore.py | 387 +++++++++++++++++-- hardpy/pytest_hardpy/db/statestore.py | 258 +++++++++++-- hardpy/pytest_hardpy/db/storage_factory.py | 68 ---- hardpy/pytest_hardpy/db/storage_interface.py | 104 ----- 7 files changed, 580 insertions(+), 557 deletions(-) delete mode 100644 hardpy/pytest_hardpy/db/couchdb_store.py delete mode 100644 hardpy/pytest_hardpy/db/json_file_store.py delete mode 100644 hardpy/pytest_hardpy/db/storage_factory.py delete mode 100644 hardpy/pytest_hardpy/db/storage_interface.py diff --git a/hardpy/pytest_hardpy/db/__init__.py b/hardpy/pytest_hardpy/db/__init__.py index 12f1f877..5857f6c6 100644 --- a/hardpy/pytest_hardpy/db/__init__.py +++ b/hardpy/pytest_hardpy/db/__init__.py @@ -2,7 +2,6 @@ # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) from hardpy.pytest_hardpy.db.const import DatabaseField -from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore from hardpy.pytest_hardpy.db.runstore import RunStore from hardpy.pytest_hardpy.db.schema import ResultRunStore, ResultStateStore from hardpy.pytest_hardpy.db.stand_type import ( @@ -17,7 +16,6 @@ __all__ = [ "Chart", - "CouchDBStore", "DatabaseField", "Instrument", "NumericMeasurement", diff --git a/hardpy/pytest_hardpy/db/couchdb_store.py b/hardpy/pytest_hardpy/db/couchdb_store.py deleted file mode 100644 index f1058a7b..00000000 --- a/hardpy/pytest_hardpy/db/couchdb_store.py +++ /dev/null @@ -1,139 +0,0 @@ -# Copyright (c) 2024 Everypin -# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -from json import dumps -from logging import getLogger -from typing import Any - -from glom import PathAccessError, assign, glom -from pycouchdb import Server as DbServer -from pycouchdb.client import Database -from pycouchdb.exceptions import Conflict, GenericError, NotFound -from pydantic import BaseModel -from requests.exceptions import ConnectionError # noqa: A004 - -from hardpy.common.config import ConfigManager -from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 -from hardpy.pytest_hardpy.db.storage_interface import Storage - - -class CouchDBStore(Storage): - """CouchDB-based storage implementation. - - This class provides storage using CouchDB as the backend. - Handles database connections, document revisions, and conflict resolution. - """ - - def __init__(self, db_name: str, schema: type[BaseModel]) -> None: - config_manager = ConfigManager() - config = config_manager.config - self._db_srv = DbServer(config.database.url) - self._db_name = db_name - self._db = self._init_db() - self._doc_id = config.database.doc_id - self._log = getLogger(__name__) - self._doc: dict = self._init_doc() - self._schema: type[BaseModel] = schema - - def compact(self) -> None: - """Compact database.""" - self._db.compact() - - def get_field(self, key: str) -> Any: # noqa: ANN401 - """Get field from the state store. - - Args: - key (str): field name - - Returns: - Any: field value, or None if path does not exist - """ - try: - return glom(self._doc, key) - except PathAccessError: - # Return None for missing paths - return None - - def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value. - - HardPy collecting uses a simple key without dots. - Assign is used to update a document. - Assign is a longer function. - - Args: - key (str): document key - value: document value - """ - try: - dumps(value) - except Exception: # noqa: BLE001 - # serialize non-serializable objects as string - value = dumps(value, default=str) - if "." in key: - # Use glom's Assign with missing=dict to create intermediate paths - assign(self._doc, key, value, missing=dict) - else: - self._doc[key] = value - - def update_db(self) -> None: - """Update database by current document.""" - try: - self._doc = self._db.save(self._doc) - except Conflict: - self._doc["_rev"] = self._db.get(self._doc_id)["_rev"] - self._doc = self._db.save(self._doc) - - def update_doc(self) -> None: - """Update current document by database.""" - self._doc["_rev"] = self._db.get(self._doc_id)["_rev"] - self._doc = self._db.get(self._doc_id) - - def get_document(self) -> BaseModel: - """Get document by schema. - - Returns: - BaseModel: document by schema - """ - self._doc = self._db.get(self._doc_id) - return self._schema(**self._doc) - - def clear(self) -> None: - """Clear database.""" - try: - # Clear statestore and runstore databases before each launch - self._db.delete(self._doc_id) - except (Conflict, NotFound): - self._log.debug("Database will be created for the first time") - self._doc: dict = self._init_doc() - - def _init_db(self) -> Database: - try: - return self._db_srv.create(self._db_name) # type: ignore - except Conflict: - # database is already created - return self._db_srv.database(self._db_name) - except GenericError as exc: - msg = f"Error initializing database {exc}" - raise RuntimeError(msg) from exc - except ConnectionError as exc: - msg = f"Error initializing database: {exc}" - raise RuntimeError(msg) from exc - - def _init_doc(self) -> dict: - try: - doc = self._db.get(self._doc_id) - except NotFound: - return self._create_default_doc_structure(self._doc_id) - - # init document - if DF.MODULES not in doc: - doc[DF.MODULES] = {} - - # Reset volatile fields - default_doc = self._create_default_doc_structure(doc["_id"]) - doc[DF.DUT] = default_doc[DF.DUT] - doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] - doc[DF.PROCESS] = default_doc[DF.PROCESS] - - return doc diff --git a/hardpy/pytest_hardpy/db/json_file_store.py b/hardpy/pytest_hardpy/db/json_file_store.py deleted file mode 100644 index 7a76cd54..00000000 --- a/hardpy/pytest_hardpy/db/json_file_store.py +++ /dev/null @@ -1,179 +0,0 @@ -# Copyright (c) 2025 Everypin -# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -from __future__ import annotations - -import json -from json import dumps -from logging import getLogger -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from glom import assign, glom - -from hardpy.common.config import ConfigManager -from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 -from hardpy.pytest_hardpy.db.storage_interface import Storage - -if TYPE_CHECKING: - from pydantic import BaseModel - - -class JsonFileStore(Storage): - """JSON file-based storage implementation. - - Stores data in JSON files within the 'storage_path' directory of the test project. - Provides atomic writes for safer file operations. - """ - - def __init__(self, store_name: str, schema: type[BaseModel]) -> None: - """Initialize JSON file storage. - - Args: - store_name (str): Name of the storage (e.g., "runstore", "statestore") - schema (type[BaseModel]): Pydantic model class for document validation - """ - config_manager = ConfigManager() - self._store_name = store_name - storage_path = config_manager.config.database.storage_path - self._storage_dir = Path(storage_path) / "storage" - self._storage_dir.mkdir(parents=True, exist_ok=True) - self._file_path = self._storage_dir / f"{store_name}.json" - self._doc_id = config_manager.config.database.doc_id - self._log = getLogger(__name__) - self._doc: dict = self._init_doc() - self._schema: type[BaseModel] = schema - - def get_field(self, key: str) -> Any: # noqa: ANN401 - """Get field value from document using dot notation. - - Args: - key (str): Field key, supports nested access with dots - - Returns: - Any: Field value, or None if path does not exist - """ - from glom import PathAccessError - - try: - return glom(self._doc, key) - except PathAccessError: - # Return None for missing paths (matches CouchDB behavior) - return None - - def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value in memory (does not persist). - - Args: - key (str): Field key, supports nested access with dots - value (Any): Value to set - """ - try: - dumps(value) - except Exception: # noqa: BLE001 - # serialize non-serializable objects as string - value = dumps(value, default=str) - - if "." in key: - # Use glom's Assign with missing=dict to create intermediate paths - assign(self._doc, key, value, missing=dict) - else: - self._doc[key] = value - - def update_db(self) -> None: - """Persist in-memory document to JSON file with atomic write.""" - # Ensure storage directory exists - self._storage_dir.mkdir(parents=True, exist_ok=True) - - temp_file = self._file_path.with_suffix(".tmp") - - try: - # Write to temporary file first - with temp_file.open("w") as f: - json.dump(self._doc, f, indent=2, default=str) - - # Atomic rename (on most systems) - temp_file.replace(self._file_path) - - except Exception as exc: - self._log.error(f"Error writing to storage file: {exc}") - # Clean up temp file if it exists - if temp_file.exists(): - temp_file.unlink() - raise - - def update_doc(self) -> None: - """Reload document from JSON file to memory.""" - if self._file_path.exists(): - try: - with self._file_path.open("r") as f: - self._doc = json.load(f) - except json.JSONDecodeError as exc: - self._log.error(f"Error reading storage file: {exc}") - # Keep existing in-memory document if file is corrupted - except Exception as exc: - self._log.error(f"Error reading storage file: {exc}") - raise - - def get_document(self) -> BaseModel: - """Get full document with schema validation. - - Returns: - BaseModel: Validated document model - """ - self.update_doc() - return self._schema(**self._doc) - - def clear(self) -> None: - """Clear storage by resetting to initial state (in-memory only). - - Note: For JSON storage, clear() only resets the in-memory document. - Call update_db() explicitly to persist the cleared state. - This differs from CouchDB where clear() immediately affects the database. - """ - # Reset document to initial state (in-memory only) - self._doc = self._create_default_doc_structure(self._doc_id) - # NOTE: We do NOT call update_db() here to avoid persisting cleared state - # The caller should call update_db() when they want to persist changes - - def compact(self) -> None: - """Optimize storage (no-op for JSON file storage).""" - - def _init_doc(self) -> dict: - """Initialize or load document structure. - - Returns: - dict: Document structure - """ - if self._file_path.exists(): - try: - with self._file_path.open("r") as f: - doc = json.load(f) - - # Ensure required fields exist (for backward compatibility) - if DF.MODULES not in doc: - doc[DF.MODULES] = {} - - # Reset volatile fields for state-like stores - if self._store_name == "statestore": - default_doc = self._create_default_doc_structure(doc["_id"]) - doc[DF.DUT] = default_doc[DF.DUT] - doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] - doc[DF.PROCESS] = default_doc[DF.PROCESS] - - return doc - except json.JSONDecodeError: - self._log.warning( - f"Corrupted storage file {self._file_path}, creating new", - ) - except Exception as exc: # noqa: BLE001 - self._log.warning(f"Error loading storage file: {exc}, creating new") - - # Return default document structure - return self._create_default_doc_structure(self._doc_id) - - def _create_default_doc_structure(self, doc_id: str) -> dict: - # CouchDB compatibility - doc = super()._create_default_doc_structure(doc_id) - doc["_rev"] = self._doc_id - return doc diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 150a07ce..427cd97e 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -3,86 +3,403 @@ from __future__ import annotations +import json +from abc import ABC, abstractmethod +from json import dumps from logging import getLogger +from pathlib import Path from typing import TYPE_CHECKING, Any +from glom import PathAccessError, assign, glom + from hardpy.common.config import ConfigManager, StorageType from hardpy.common.singleton import SingletonMeta +from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 from hardpy.pytest_hardpy.db.schema import ResultRunStore -from hardpy.pytest_hardpy.db.storage_factory import StorageFactory if TYPE_CHECKING: from pydantic import BaseModel - from hardpy.pytest_hardpy.db.storage_interface import Storage +class RunStoreInterface(ABC): + """Interface for run storage implementations.""" -class RunStore(metaclass=SingletonMeta): - """HardPy run storage for test run data. + @abstractmethod + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field from the run store. - Creates appropriate storage backend based on configuration: - - JSON file storage when storage_type is "json" - - CouchDB storage when storage_type is "couchdb" + Args: + key (str): Field key, supports nested access with dots - Save state and case artifact. Supports multiple storage backends - through the factory pattern. + Returns: + Any: Field value + """ + + @abstractmethod + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value in memory (does not persist). + + Args: + key (str): Field key, supports nested access with dots + value (Any): Value to set + """ + + @abstractmethod + def update_db(self) -> None: + """Persist in-memory document to storage backend.""" + + @abstractmethod + def update_doc(self) -> None: + """Reload document from storage backend to memory.""" + + @abstractmethod + def get_document(self) -> BaseModel: + """Get full document with schema validation. + + Returns: + BaseModel: Validated document model + """ + + @abstractmethod + def clear(self) -> None: + """Clear storage and reset to initial state.""" + + @abstractmethod + def compact(self) -> None: + """Optimize storage (implementation-specific, may be no-op).""" + + +class JsonRunStore(RunStoreInterface): + """JSON file-based run storage implementation. + + Stores test run data using JSON files. """ def __init__(self) -> None: + config_manager = ConfigManager() + self._store_name = "runstore" + storage_path = config_manager.config.database.storage_path + self._storage_dir = Path(storage_path) / "storage" + self._storage_dir.mkdir(parents=True, exist_ok=True) + self._file_path = self._storage_dir / f"{self._store_name}.json" + self._doc_id = config_manager.config.database.doc_id self._log = getLogger(__name__) - config = ConfigManager() + self._schema: type[BaseModel] = ResultRunStore + self._doc: dict = self._init_doc() + + def get_field(self, key: str) -> Any: # noqa: ANN401 + """Get field value from document using dot notation. + + Args: + key (str): Field key, supports nested access with dots + + Returns: + Any: Field value, or None if path does not exist + """ + try: + return glom(self._doc, key) + except PathAccessError: + return None + + def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 + """Update document value in memory (does not persist). + + Args: + key (str): Field key, supports nested access with dots + value (Any): Value to set + """ + try: + dumps(value) + except Exception: # noqa: BLE001 + value = dumps(value, default=str) + + if "." in key: + assign(self._doc, key, value, missing=dict) + else: + self._doc[key] = value + + def update_db(self) -> None: + """Persist in-memory document to JSON file with atomic write.""" + self._storage_dir.mkdir(parents=True, exist_ok=True) + temp_file = self._file_path.with_suffix(".tmp") + + try: + with temp_file.open("w") as f: + json.dump(self._doc, f, indent=2, default=str) + temp_file.replace(self._file_path) + except Exception as exc: + self._log.error(f"Error writing to storage file: {exc}") + if temp_file.exists(): + temp_file.unlink() + raise + + def update_doc(self) -> None: + """Reload document from JSON file to memory.""" + if self._file_path.exists(): + try: + with self._file_path.open("r") as f: + self._doc = json.load(f) + except json.JSONDecodeError as exc: + self._log.error(f"Error reading storage file: {exc}") + except Exception as exc: + self._log.error(f"Error reading storage file: {exc}") + raise + + def get_document(self) -> BaseModel: + """Get full document with schema validation. + + Returns: + BaseModel: Validated document model + """ + self.update_doc() + return self._schema(**self._doc) + + def clear(self) -> None: + """Clear storage by resetting to initial state (in-memory only).""" + self._doc = self._create_default_doc_structure(self._doc_id) + + def compact(self) -> None: + """Optimize storage (no-op for JSON file storage).""" + + def _init_doc(self) -> dict: + """Initialize or load document structure.""" + if self._file_path.exists(): + try: + with self._file_path.open("r") as f: + doc = json.load(f) + + if DF.MODULES not in doc: + doc[DF.MODULES] = {} - self._storage: Storage = StorageFactory.create_storage( - "runstore", ResultRunStore, + return doc + except json.JSONDecodeError: + self._log.warning(f"Corrupted storage file {self._file_path}, creating new") + except Exception as exc: # noqa: BLE001 + self._log.warning(f"Error loading storage file: {exc}, creating new") + + return self._create_default_doc_structure(self._doc_id) + + def _create_default_doc_structure(self, doc_id: str) -> dict: + """Create default document structure with standard fields.""" + return { + "_id": doc_id, + "_rev": self._doc_id, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } + + +class CouchDBRunStore(RunStoreInterface): + """CouchDB-based run storage implementation. + + Stores test run data using CouchDB. + Clears the storage on initialization to start fresh. + """ + + def __init__(self) -> None: + from pycouchdb import Server as DbServer # type: ignore[import-untyped] + from pycouchdb.client import Database # type: ignore[import-untyped] + from pycouchdb.exceptions import ( # type: ignore[import-untyped] + Conflict, + GenericError, ) + from requests.exceptions import ConnectionError # noqa: A004 + + config_manager = ConfigManager() + config = config_manager.config + self._db_srv = DbServer(config.database.url) + self._db_name = "runstore" + self._doc_id = config.database.doc_id + self._log = getLogger(__name__) + self._schema: type[BaseModel] = ResultRunStore + + # Initialize database + try: + self._db: Database = self._db_srv.create(self._db_name) + except Conflict: + self._db = self._db_srv.database(self._db_name) + except GenericError as exc: + msg = f"Error initializing database {exc}" + raise RuntimeError(msg) from exc + except ConnectionError as exc: + msg = f"Error initializing database: {exc}" + raise RuntimeError(msg) from exc + + self._doc: dict = self._init_doc() # Clear the runstore on initialization for CouchDB - if config.config.database.storage_type == StorageType.COUCHDB: - try: - self._storage.clear() - except Exception: # noqa: BLE001 - self._log.debug("Runstore storage will be created for the first time") + try: + self.clear() + except Exception: # noqa: BLE001 + self._log.debug("Runstore storage will be created for the first time") def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the run store. Args: - key (str): field name + key (str): Field key, supports nested access with dots Returns: - Any: field value + Any: Field value, or None if path does not exist """ - return self._storage.get_field(key) + try: + return glom(self._doc, key) + except PathAccessError: + return None def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value. + """Update document value in memory (does not persist). Args: - key (str): document key - value: document value + key (str): Field key, supports nested access with dots + value (Any): Value to set """ - self._storage.update_doc_value(key, value) + try: + dumps(value) + except Exception: # noqa: BLE001 + value = dumps(value, default=str) + + if "." in key: + assign(self._doc, key, value, missing=dict) + else: + self._doc[key] = value def update_db(self) -> None: - """Update database by current document.""" - self._storage.update_db() + """Persist in-memory document to storage backend.""" + from pycouchdb.exceptions import Conflict # type: ignore[import-untyped] + + try: + self._doc = self._db.save(self._doc) + except Conflict: + self._doc["_rev"] = self._db.get(self._doc_id)["_rev"] + self._doc = self._db.save(self._doc) def update_doc(self) -> None: - """Update current document by database.""" - self._storage.update_doc() + """Reload document from storage backend to memory.""" + self._doc["_rev"] = self._db.get(self._doc_id)["_rev"] + self._doc = self._db.get(self._doc_id) def get_document(self) -> BaseModel: - """Get document by schema. + """Get full document with schema validation. Returns: - BaseModel: document by schema + BaseModel: Validated document model """ - return self._storage.get_document() + self._doc = self._db.get(self._doc_id) + return self._schema(**self._doc) def clear(self) -> None: - """Clear database.""" - self._storage.clear() + """Clear storage and reset to initial state.""" + from pycouchdb.exceptions import Conflict, NotFound # type: ignore[import-untyped] + + try: + self._db.delete(self._doc_id) + except (Conflict, NotFound): + self._log.debug("Database will be created for the first time") + self._doc = self._init_doc() def compact(self) -> None: - """Compact database.""" - self._storage.compact() + """Optimize storage (implementation-specific, may be no-op).""" + self._db.compact() + + def _init_doc(self) -> dict: + """Initialize or load document structure.""" + from pycouchdb.exceptions import NotFound # type: ignore[import-untyped] + + try: + doc = self._db.get(self._doc_id) + except NotFound: + return self._create_default_doc_structure(self._doc_id) + + if DF.MODULES not in doc: + doc[DF.MODULES] = {} + + return doc + + def _create_default_doc_structure(self, doc_id: str) -> dict: + """Create default document structure with standard fields.""" + return { + "_id": doc_id, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } + + +class RunStore(metaclass=SingletonMeta): + """HardPy run storage factory for test run data. + + Creates appropriate storage backend based on configuration: + - JSON file storage when storage_type is "json" + - CouchDB storage when storage_type is "couchdb" + + Save state and case artifact. Supports multiple storage backends + through the factory pattern. + + Note: This class acts as a factory. When instantiated, it returns + the appropriate concrete implementation (JsonRunStore or CouchDBRunStore). + """ + + def __new__(cls): # type: ignore[misc] + """Create and return the appropriate storage implementation. + + Returns: + RunStoreInterface: Concrete storage implementation based on config + """ + config = ConfigManager() + storage_type = config.config.database.storage_type + + if storage_type == StorageType.JSON: + return JsonRunStore() + elif storage_type == StorageType.COUCHDB: + return CouchDBRunStore() + else: + msg = f"Unknown storage type: {storage_type}" + raise ValueError(msg) diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 78f8deaf..0c0a3e06 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -3,20 +3,23 @@ from __future__ import annotations +import json from abc import ABC, abstractmethod +from json import dumps from logging import getLogger +from pathlib import Path from typing import TYPE_CHECKING, Any +from glom import PathAccessError, assign, glom + from hardpy.common.config import ConfigManager, StorageType from hardpy.common.singleton import SingletonMeta +from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 from hardpy.pytest_hardpy.db.schema import ResultStateStore -from hardpy.pytest_hardpy.db.storage_factory import StorageFactory if TYPE_CHECKING: from pydantic import BaseModel - from hardpy.pytest_hardpy.db.storage_interface import Storage - class StateStoreInterface(ABC): """Interface for state storage implementations.""" @@ -73,21 +76,30 @@ class JsonStateStore(StateStoreInterface): """ def __init__(self) -> None: + config_manager = ConfigManager() + self._store_name = "statestore" + storage_path = config_manager.config.database.storage_path + self._storage_dir = Path(storage_path) / "storage" + self._storage_dir.mkdir(parents=True, exist_ok=True) + self._file_path = self._storage_dir / f"{self._store_name}.json" + self._doc_id = config_manager.config.database.doc_id self._log = getLogger(__name__) - self._storage: Storage = StorageFactory.create_storage( - "statestore", ResultStateStore, - ) + self._schema: type[BaseModel] = ResultStateStore + self._doc: dict = self._init_doc() def get_field(self, key: str) -> Any: # noqa: ANN401 - """Get field from the state store. + """Get field value from document using dot notation. Args: key (str): Field key, supports nested access with dots Returns: - Any: Field value + Any: Field value, or None if path does not exist """ - return self._storage.get_field(key) + try: + return glom(self._doc, key) + except PathAccessError: + return None def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 """Update document value in memory (does not persist). @@ -96,15 +108,42 @@ def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 key (str): Field key, supports nested access with dots value (Any): Value to set """ - self._storage.update_doc_value(key, value) + try: + dumps(value) + except Exception: # noqa: BLE001 + value = dumps(value, default=str) + + if "." in key: + assign(self._doc, key, value, missing=dict) + else: + self._doc[key] = value def update_db(self) -> None: - """Persist in-memory document to storage backend.""" - self._storage.update_db() + """Persist in-memory document to JSON file with atomic write.""" + self._storage_dir.mkdir(parents=True, exist_ok=True) + temp_file = self._file_path.with_suffix(".tmp") + + try: + with temp_file.open("w") as f: + json.dump(self._doc, f, indent=2, default=str) + temp_file.replace(self._file_path) + except Exception as exc: + self._log.error(f"Error writing to storage file: {exc}") + if temp_file.exists(): + temp_file.unlink() + raise def update_doc(self) -> None: - """Reload document from storage backend to memory.""" - self._storage.update_doc() + """Reload document from JSON file to memory.""" + if self._file_path.exists(): + try: + with self._file_path.open("r") as f: + self._doc = json.load(f) + except json.JSONDecodeError as exc: + self._log.error(f"Error reading storage file: {exc}") + except Exception as exc: + self._log.error(f"Error reading storage file: {exc}") + raise def get_document(self) -> BaseModel: """Get full document with schema validation. @@ -112,15 +151,72 @@ def get_document(self) -> BaseModel: Returns: BaseModel: Validated document model """ - return self._storage.get_document() + self.update_doc() + return self._schema(**self._doc) def clear(self) -> None: - """Clear storage and reset to initial state.""" - self._storage.clear() + """Clear storage by resetting to initial state (in-memory only).""" + self._doc = self._create_default_doc_structure(self._doc_id) def compact(self) -> None: - """Optimize storage (implementation-specific, may be no-op).""" - self._storage.compact() + """Optimize storage (no-op for JSON file storage).""" + + def _init_doc(self) -> dict: + """Initialize or load document structure.""" + if self._file_path.exists(): + try: + with self._file_path.open("r") as f: + doc = json.load(f) + + if DF.MODULES not in doc: + doc[DF.MODULES] = {} + + # Reset volatile fields for statestore + default_doc = self._create_default_doc_structure(doc["_id"]) + doc[DF.DUT] = default_doc[DF.DUT] + doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] + doc[DF.PROCESS] = default_doc[DF.PROCESS] + + return doc + except json.JSONDecodeError: + self._log.warning(f"Corrupted storage file {self._file_path}, creating new") + except Exception as exc: # noqa: BLE001 + self._log.warning(f"Error loading storage file: {exc}, creating new") + + return self._create_default_doc_structure(self._doc_id) + + def _create_default_doc_structure(self, doc_id: str) -> dict: + """Create default document structure with standard fields.""" + return { + "_id": doc_id, + "_rev": self._doc_id, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } class CouchDBStateStore(StateStoreInterface): @@ -130,10 +226,35 @@ class CouchDBStateStore(StateStoreInterface): """ def __init__(self) -> None: - self._log = getLogger(__name__) - self._storage: Storage = StorageFactory.create_storage( - "statestore", ResultStateStore, + from pycouchdb import Server as DbServer # type: ignore[import-untyped] + from pycouchdb.client import Database # type: ignore[import-untyped] + from pycouchdb.exceptions import ( # type: ignore[import-untyped] + Conflict, + GenericError, ) + from requests.exceptions import ConnectionError # noqa: A004 + + config_manager = ConfigManager() + config = config_manager.config + self._db_srv = DbServer(config.database.url) + self._db_name = "statestore" + self._doc_id = config.database.doc_id + self._log = getLogger(__name__) + self._schema: type[BaseModel] = ResultStateStore + + # Initialize database + try: + self._db: Database = self._db_srv.create(self._db_name) + except Conflict: + self._db = self._db_srv.database(self._db_name) + except GenericError as exc: + msg = f"Error initializing database {exc}" + raise RuntimeError(msg) from exc + except ConnectionError as exc: + msg = f"Error initializing database: {exc}" + raise RuntimeError(msg) from exc + + self._doc: dict = self._init_doc() def get_field(self, key: str) -> Any: # noqa: ANN401 """Get field from the state store. @@ -142,9 +263,12 @@ def get_field(self, key: str) -> Any: # noqa: ANN401 key (str): Field key, supports nested access with dots Returns: - Any: Field value + Any: Field value, or None if path does not exist """ - return self._storage.get_field(key) + try: + return glom(self._doc, key) + except PathAccessError: + return None def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 """Update document value in memory (does not persist). @@ -153,15 +277,30 @@ def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 key (str): Field key, supports nested access with dots value (Any): Value to set """ - self._storage.update_doc_value(key, value) + try: + dumps(value) + except Exception: # noqa: BLE001 + value = dumps(value, default=str) + + if "." in key: + assign(self._doc, key, value, missing=dict) + else: + self._doc[key] = value def update_db(self) -> None: """Persist in-memory document to storage backend.""" - self._storage.update_db() + from pycouchdb.exceptions import Conflict # type: ignore[import-untyped] + + try: + self._doc = self._db.save(self._doc) + except Conflict: + self._doc["_rev"] = self._db.get(self._doc_id)["_rev"] + self._doc = self._db.save(self._doc) def update_doc(self) -> None: """Reload document from storage backend to memory.""" - self._storage.update_doc() + self._doc["_rev"] = self._db.get(self._doc_id)["_rev"] + self._doc = self._db.get(self._doc_id) def get_document(self) -> BaseModel: """Get full document with schema validation. @@ -169,15 +308,74 @@ def get_document(self) -> BaseModel: Returns: BaseModel: Validated document model """ - return self._storage.get_document() + self._doc = self._db.get(self._doc_id) + return self._schema(**self._doc) def clear(self) -> None: """Clear storage and reset to initial state.""" - self._storage.clear() + from pycouchdb.exceptions import Conflict, NotFound # type: ignore[import-untyped] + + try: + self._db.delete(self._doc_id) + except (Conflict, NotFound): + self._log.debug("Database will be created for the first time") + self._doc = self._init_doc() def compact(self) -> None: """Optimize storage (implementation-specific, may be no-op).""" - self._storage.compact() + self._db.compact() + + def _init_doc(self) -> dict: + """Initialize or load document structure.""" + from pycouchdb.exceptions import NotFound # type: ignore[import-untyped] + + try: + doc = self._db.get(self._doc_id) + except NotFound: + return self._create_default_doc_structure(self._doc_id) + + if DF.MODULES not in doc: + doc[DF.MODULES] = {} + + # Reset volatile fields + default_doc = self._create_default_doc_structure(doc["_id"]) + doc[DF.DUT] = default_doc[DF.DUT] + doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] + doc[DF.PROCESS] = default_doc[DF.PROCESS] + + return doc + + def _create_default_doc_structure(self, doc_id: str) -> dict: + """Create default document structure with standard fields.""" + return { + "_id": doc_id, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } class StateStore(metaclass=SingletonMeta): diff --git a/hardpy/pytest_hardpy/db/storage_factory.py b/hardpy/pytest_hardpy/db/storage_factory.py deleted file mode 100644 index 99acccba..00000000 --- a/hardpy/pytest_hardpy/db/storage_factory.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (c) 2025 Everypin -# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -from __future__ import annotations - -from logging import getLogger -from typing import TYPE_CHECKING - -from hardpy.common.config import ConfigManager, StorageType - -if TYPE_CHECKING: - from pydantic import BaseModel - - from hardpy.pytest_hardpy.db.storage_interface import Storage - -logger = getLogger(__name__) - - -class StorageFactory: - """Factory for creating storage instances based on configuration. - - This factory pattern allows HardPy to support multiple storage backends - (CouchDB, JSON files, etc.) and switch between them based on configuration. - """ - - @staticmethod - def create_storage(store_name: str, schema: type[BaseModel]) -> Storage: - """Create storage instance based on configuration. - - Args: - store_name (str): Name of the storage (e.g., "runstore", "statestore") - schema (type[BaseModel]): Pydantic model class for document validation - - Returns: - Storage: Storage instance - - Raises: - ValueError: If storage type is unknown or unsupported - ImportError: If required dependencies for storage type are not installed - """ - config = ConfigManager().config - storage_type = config.database.storage_type - - if storage_type == StorageType.JSON: - from hardpy.pytest_hardpy.db.json_file_store import JsonFileStore - - logger.debug(f"Creating JSON file storage for {store_name}") - return JsonFileStore(store_name, schema) - - if storage_type == StorageType.COUCHDB: - try: - from hardpy.pytest_hardpy.db.couchdb_store import CouchDBStore - except ImportError as exc: - msg = ( - "CouchDB storage requires pycouchdb. " - "Install with: pip install hardpy[couchdb] or " - 'pip install "pycouchdb>=1.14.2"' - ) - raise ImportError(msg) from exc - - logger.debug(f"Creating CouchDB storage for {store_name}") - return CouchDBStore(store_name, schema) - - msg = ( - f"Unknown storage type: {storage_type}. " - "Supported types: 'json', 'couchdb'" - ) - raise ValueError(msg) diff --git a/hardpy/pytest_hardpy/db/storage_interface.py b/hardpy/pytest_hardpy/db/storage_interface.py deleted file mode 100644 index 2baefadd..00000000 --- a/hardpy/pytest_hardpy/db/storage_interface.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright (c) 2025 Everypin -# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any - -from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 - -if TYPE_CHECKING: - from pydantic import BaseModel - - -class Storage(ABC): - """Abstract storage interface for HardPy data persistence. - - This interface defines the contract for storage implementations, - allowing HardPy to support multiple storage backends (CouchDB, JSON files, etc.). - """ - - @abstractmethod - def get_field(self, key: str) -> Any: # noqa: ANN401 - """Get field value from document using dot notation. - - Args: - key (str): Field key, supports nested access with dots - (e.g., "modules.test1.status") - - Returns: - Any: Field value - """ - - @abstractmethod - def update_doc_value(self, key: str, value: Any) -> None: # noqa: ANN401 - """Update document value in memory (does not persist). - - Args: - key (str): Field key, supports nested access with dots - value (Any): Value to set - """ - - @abstractmethod - def update_db(self) -> None: - """Persist in-memory document to storage backend.""" - - @abstractmethod - def update_doc(self) -> None: - """Reload document from storage backend to memory.""" - - @abstractmethod - def get_document(self) -> BaseModel: - """Get full document with schema validation. - - Returns: - BaseModel: Validated document model - """ - - @abstractmethod - def clear(self) -> None: - """Clear storage and reset to initial state.""" - - @abstractmethod - def compact(self) -> None: - """Optimize storage (implementation-specific, may be no-op).""" - - def _create_default_doc_structure(self, doc_id: str) -> dict: - """Create default document structure with standard fields. - - Args: - doc_id (str): Document ID to use - - Returns: - dict: Default document structure with DUT, TEST_STAND, and PROCESS fields - """ - return { - "_id": doc_id, - DF.MODULES: {}, - DF.DUT: { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - }, - DF.TEST_STAND: { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - }, - DF.PROCESS: { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - }, - } From b0d7fa8b125c95b83819a2bfae8bfd4605e67c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 19:12:26 +0100 Subject: [PATCH 53/81] refactor(db): extract `_create_default_doc_structure` into shared function and remove duplicates Centralize default document structure creation to reduce redundancy. Update state and run stores to use the shared function, ensuring consistency across implementations. Adjust logic to handle `_rev` field appropriately for CouchDB. --- hardpy/pytest_hardpy/db/runstore.py | 116 +++++++++++-------------- hardpy/pytest_hardpy/db/statestore.py | 120 +++++++++++--------------- 2 files changed, 98 insertions(+), 138 deletions(-) diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 427cd97e..d61a299b 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -21,6 +21,48 @@ from pydantic import BaseModel +def _create_default_doc_structure(doc_id: str, doc_id_for_rev: str) -> dict: + """Create default document structure with standard fields. + + Args: + doc_id (str): Document ID to use + doc_id_for_rev (str): Document ID for _rev field (for JSON compatibility) + + Returns: + dict: Default document structure + """ + return { + "_id": doc_id, + "_rev": doc_id_for_rev, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } + + class RunStoreInterface(ABC): """Interface for run storage implementations.""" @@ -156,7 +198,7 @@ def get_document(self) -> BaseModel: def clear(self) -> None: """Clear storage by resetting to initial state (in-memory only).""" - self._doc = self._create_default_doc_structure(self._doc_id) + self._doc = _create_default_doc_structure(self._doc_id, self._doc_id) def compact(self) -> None: """Optimize storage (no-op for JSON file storage).""" @@ -177,40 +219,7 @@ def _init_doc(self) -> dict: except Exception as exc: # noqa: BLE001 self._log.warning(f"Error loading storage file: {exc}, creating new") - return self._create_default_doc_structure(self._doc_id) - - def _create_default_doc_structure(self, doc_id: str) -> dict: - """Create default document structure with standard fields.""" - return { - "_id": doc_id, - "_rev": self._doc_id, - DF.MODULES: {}, - DF.DUT: { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - }, - DF.TEST_STAND: { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - }, - DF.PROCESS: { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - }, - } + return _create_default_doc_structure(self._doc_id, self._doc_id) class CouchDBRunStore(RunStoreInterface): @@ -333,45 +342,16 @@ def _init_doc(self) -> dict: try: doc = self._db.get(self._doc_id) except NotFound: - return self._create_default_doc_structure(self._doc_id) + # CouchDB doesn't need _rev field in the default structure + default = _create_default_doc_structure(self._doc_id, self._doc_id) + del default["_rev"] # CouchDB manages _rev automatically + return default if DF.MODULES not in doc: doc[DF.MODULES] = {} return doc - def _create_default_doc_structure(self, doc_id: str) -> dict: - """Create default document structure with standard fields.""" - return { - "_id": doc_id, - DF.MODULES: {}, - DF.DUT: { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - }, - DF.TEST_STAND: { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - }, - DF.PROCESS: { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - }, - } - class RunStore(metaclass=SingletonMeta): """HardPy run storage factory for test run data. diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 0c0a3e06..347832e5 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -21,6 +21,48 @@ from pydantic import BaseModel +def _create_default_doc_structure(doc_id: str, doc_id_for_rev: str) -> dict: + """Create default document structure with standard fields. + + Args: + doc_id (str): Document ID to use + doc_id_for_rev (str): Document ID for _rev field (for JSON compatibility) + + Returns: + dict: Default document structure + """ + return { + "_id": doc_id, + "_rev": doc_id_for_rev, + DF.MODULES: {}, + DF.DUT: { + DF.TYPE: None, + DF.NAME: None, + DF.REVISION: None, + DF.SERIAL_NUMBER: None, + DF.PART_NUMBER: None, + DF.SUB_UNITS: [], + DF.INFO: {}, + }, + DF.TEST_STAND: { + DF.HW_ID: None, + DF.NAME: None, + DF.REVISION: None, + DF.TIMEZONE: None, + DF.LOCATION: None, + DF.NUMBER: None, + DF.INSTRUMENTS: [], + DF.DRIVERS: {}, + DF.INFO: {}, + }, + DF.PROCESS: { + DF.NAME: None, + DF.NUMBER: None, + DF.INFO: {}, + }, + } + + class StateStoreInterface(ABC): """Interface for state storage implementations.""" @@ -156,7 +198,7 @@ def get_document(self) -> BaseModel: def clear(self) -> None: """Clear storage by resetting to initial state (in-memory only).""" - self._doc = self._create_default_doc_structure(self._doc_id) + self._doc = _create_default_doc_structure(self._doc_id, self._doc_id) def compact(self) -> None: """Optimize storage (no-op for JSON file storage).""" @@ -172,7 +214,7 @@ def _init_doc(self) -> dict: doc[DF.MODULES] = {} # Reset volatile fields for statestore - default_doc = self._create_default_doc_structure(doc["_id"]) + default_doc = _create_default_doc_structure(doc["_id"], self._doc_id) doc[DF.DUT] = default_doc[DF.DUT] doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] doc[DF.PROCESS] = default_doc[DF.PROCESS] @@ -183,40 +225,7 @@ def _init_doc(self) -> dict: except Exception as exc: # noqa: BLE001 self._log.warning(f"Error loading storage file: {exc}, creating new") - return self._create_default_doc_structure(self._doc_id) - - def _create_default_doc_structure(self, doc_id: str) -> dict: - """Create default document structure with standard fields.""" - return { - "_id": doc_id, - "_rev": self._doc_id, - DF.MODULES: {}, - DF.DUT: { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - }, - DF.TEST_STAND: { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - }, - DF.PROCESS: { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - }, - } + return _create_default_doc_structure(self._doc_id, self._doc_id) class CouchDBStateStore(StateStoreInterface): @@ -332,51 +341,22 @@ def _init_doc(self) -> dict: try: doc = self._db.get(self._doc_id) except NotFound: - return self._create_default_doc_structure(self._doc_id) + # CouchDB doesn't need _rev field in the default structure + default = _create_default_doc_structure(self._doc_id, self._doc_id) + del default["_rev"] # CouchDB manages _rev automatically + return default if DF.MODULES not in doc: doc[DF.MODULES] = {} # Reset volatile fields - default_doc = self._create_default_doc_structure(doc["_id"]) + default_doc = _create_default_doc_structure(doc["_id"], self._doc_id) doc[DF.DUT] = default_doc[DF.DUT] doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] doc[DF.PROCESS] = default_doc[DF.PROCESS] return doc - def _create_default_doc_structure(self, doc_id: str) -> dict: - """Create default document structure with standard fields.""" - return { - "_id": doc_id, - DF.MODULES: {}, - DF.DUT: { - DF.TYPE: None, - DF.NAME: None, - DF.REVISION: None, - DF.SERIAL_NUMBER: None, - DF.PART_NUMBER: None, - DF.SUB_UNITS: [], - DF.INFO: {}, - }, - DF.TEST_STAND: { - DF.HW_ID: None, - DF.NAME: None, - DF.REVISION: None, - DF.TIMEZONE: None, - DF.LOCATION: None, - DF.NUMBER: None, - DF.INSTRUMENTS: [], - DF.DRIVERS: {}, - DF.INFO: {}, - }, - DF.PROCESS: { - DF.NAME: None, - DF.NUMBER: None, - DF.INFO: {}, - }, - } - class StateStore(metaclass=SingletonMeta): """HardPy state storage factory for test execution state. From a085ab2749accbbb5524a15deb5bb12586651f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 19:19:06 +0100 Subject: [PATCH 54/81] Apply suggestions from code review --- hardpy/hardpy_panel/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hardpy/hardpy_panel/api.py b/hardpy/hardpy_panel/api.py index b00295d8..553d4b90 100644 --- a/hardpy/hardpy_panel/api.py +++ b/hardpy/hardpy_panel/api.py @@ -387,12 +387,12 @@ def get_json_data() -> dict: try: storage_dir = Path(config_manager.config.database.storage_path) / "storage" - runstore_file = storage_dir / "runstore.json" + statestore_file = storage_dir / "statestore.json" - if not runstore_file.exists(): + if not statestore_file.exists(): return {"rows": [], "total_rows": 0} - with runstore_file.open("r") as f: + with statestore_file.open("r") as f: data = json.load(f) # Format data to match CouchDB's _all_docs format From 66a5b0001418ff1eae33dffe935b71e1878ae263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 19:20:46 +0100 Subject: [PATCH 55/81] refactor(db): improve exception imports and simplify storage type handling --- hardpy/pytest_hardpy/db/runstore.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index d61a299b..c0d5c395 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -323,7 +323,10 @@ def get_document(self) -> BaseModel: def clear(self) -> None: """Clear storage and reset to initial state.""" - from pycouchdb.exceptions import Conflict, NotFound # type: ignore[import-untyped] + from pycouchdb.exceptions import ( # type: ignore[import-untyped] + Conflict, + NotFound, + ) try: self._db.delete(self._doc_id) @@ -378,8 +381,7 @@ def __new__(cls): # type: ignore[misc] if storage_type == StorageType.JSON: return JsonRunStore() - elif storage_type == StorageType.COUCHDB: + if storage_type == StorageType.COUCHDB: return CouchDBRunStore() - else: - msg = f"Unknown storage type: {storage_type}" - raise ValueError(msg) + msg = f"Unknown storage type: {storage_type}" + raise ValueError(msg) From d72efab887a2d798619fafd86545032299f4ac58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 19:20:57 +0100 Subject: [PATCH 56/81] refactor(db): clean up exception imports and streamline storage type checks --- hardpy/pytest_hardpy/db/statestore.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 347832e5..2a8dcbf0 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -322,7 +322,10 @@ def get_document(self) -> BaseModel: def clear(self) -> None: """Clear storage and reset to initial state.""" - from pycouchdb.exceptions import Conflict, NotFound # type: ignore[import-untyped] + from pycouchdb.exceptions import ( # type: ignore[import-untyped] + Conflict, + NotFound, + ) try: self._db.delete(self._doc_id) @@ -382,8 +385,7 @@ def __new__(cls): # type: ignore[misc] if storage_type == StorageType.JSON: return JsonStateStore() - elif storage_type == StorageType.COUCHDB: + if storage_type == StorageType.COUCHDB: return CouchDBStateStore() - else: - msg = f"Unknown storage type: {storage_type}" - raise ValueError(msg) + msg = f"Unknown storage type: {storage_type}" + raise ValueError(msg) From 8885b3a84eb370cab0599334272f753c9e49c002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 19:21:02 +0100 Subject: [PATCH 57/81] refactor(db): simplify storage type conditional checks in temp store --- hardpy/pytest_hardpy/db/tempstore.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index d22667e8..33b1a481 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -238,8 +238,7 @@ def __new__(cls): # type: ignore[misc] if storage_type == StorageType.JSON: return JsonTempStore() - elif storage_type == StorageType.COUCHDB: + if storage_type == StorageType.COUCHDB: return CouchDBTempStore() - else: - msg = f"Unknown storage type: {storage_type}" - raise ValueError(msg) + msg = f"Unknown storage type: {storage_type}" + raise ValueError(msg) From ce51ae31d8e7ad65227fc77bfd3e37411c2a7df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 19:24:05 +0100 Subject: [PATCH 58/81] refactor(db): reformat multiline statements for improved readability --- hardpy/pytest_hardpy/db/runstore.py | 3 ++- hardpy/pytest_hardpy/db/statestore.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index c0d5c395..15190cdc 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -215,7 +215,8 @@ def _init_doc(self) -> dict: return doc except json.JSONDecodeError: - self._log.warning(f"Corrupted storage file {self._file_path}, creating new") + self._log.warning(f"Corrupted storage file {self._file_path}," + f" creating new") except Exception as exc: # noqa: BLE001 self._log.warning(f"Error loading storage file: {exc}, creating new") diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 2a8dcbf0..0b18c8dd 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -214,7 +214,8 @@ def _init_doc(self) -> dict: doc[DF.MODULES] = {} # Reset volatile fields for statestore - default_doc = _create_default_doc_structure(doc["_id"], self._doc_id) + default_doc = _create_default_doc_structure(doc["_id"], + self._doc_id) doc[DF.DUT] = default_doc[DF.DUT] doc[DF.TEST_STAND] = default_doc[DF.TEST_STAND] doc[DF.PROCESS] = default_doc[DF.PROCESS] From 05b974c0ad0c12c6d8284537c77b9adfcddd1e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 15 Dec 2025 19:26:38 +0100 Subject: [PATCH 59/81] refactor(db): add type annotations, clean up unused imports, and improve logging formatting --- hardpy/pytest_hardpy/db/runstore.py | 6 +++--- hardpy/pytest_hardpy/db/statestore.py | 10 ++++++---- hardpy/pytest_hardpy/db/tempstore.py | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 15190cdc..f21bd243 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -18,6 +18,7 @@ from hardpy.pytest_hardpy.db.schema import ResultRunStore if TYPE_CHECKING: + from pycouchdb.client import Database # type: ignore[import-untyped] from pydantic import BaseModel @@ -232,7 +233,6 @@ class CouchDBRunStore(RunStoreInterface): def __init__(self) -> None: from pycouchdb import Server as DbServer # type: ignore[import-untyped] - from pycouchdb.client import Database # type: ignore[import-untyped] from pycouchdb.exceptions import ( # type: ignore[import-untyped] Conflict, GenericError, @@ -249,7 +249,7 @@ def __init__(self) -> None: # Initialize database try: - self._db: Database = self._db_srv.create(self._db_name) + self._db: Database = self._db_srv.create(self._db_name) # type: ignore[name-defined] except Conflict: self._db = self._db_srv.database(self._db_name) except GenericError as exc: @@ -371,7 +371,7 @@ class RunStore(metaclass=SingletonMeta): the appropriate concrete implementation (JsonRunStore or CouchDBRunStore). """ - def __new__(cls): # type: ignore[misc] + def __new__(cls) -> RunStoreInterface: # type: ignore[misc] """Create and return the appropriate storage implementation. Returns: diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 0b18c8dd..2eebf99c 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -18,6 +18,7 @@ from hardpy.pytest_hardpy.db.schema import ResultStateStore if TYPE_CHECKING: + from pycouchdb.client import Database # type: ignore[import-untyped] from pydantic import BaseModel @@ -222,7 +223,9 @@ def _init_doc(self) -> dict: return doc except json.JSONDecodeError: - self._log.warning(f"Corrupted storage file {self._file_path}, creating new") + self._log.warning( + f"Corrupted storage file {self._file_path}, creating new", + ) except Exception as exc: # noqa: BLE001 self._log.warning(f"Error loading storage file: {exc}, creating new") @@ -237,7 +240,6 @@ class CouchDBStateStore(StateStoreInterface): def __init__(self) -> None: from pycouchdb import Server as DbServer # type: ignore[import-untyped] - from pycouchdb.client import Database # type: ignore[import-untyped] from pycouchdb.exceptions import ( # type: ignore[import-untyped] Conflict, GenericError, @@ -254,7 +256,7 @@ def __init__(self) -> None: # Initialize database try: - self._db: Database = self._db_srv.create(self._db_name) + self._db: Database = self._db_srv.create(self._db_name) # type: ignore[name-defined] except Conflict: self._db = self._db_srv.database(self._db_name) except GenericError as exc: @@ -375,7 +377,7 @@ class StateStore(metaclass=SingletonMeta): the appropriate concrete implementation (JsonStateStore or CouchDBStateStore). """ - def __new__(cls): # type: ignore[misc] + def __new__(cls) -> StateStoreInterface: # type: ignore[misc] """Create and return the appropriate storage implementation. Returns: diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index 33b1a481..b22b9506 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -227,7 +227,7 @@ class TempStore(metaclass=SingletonMeta): the appropriate concrete implementation (JsonTempStore or CouchDBTempStore). """ - def __new__(cls): # type: ignore[misc] + def __new__(cls) -> TempStoreInterface: # type: ignore[misc] """Create and return the appropriate storage implementation. Returns: From f1b0d0fe99e420a1cb73d2b4d716cc87eb0cc531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Wed, 24 Dec 2025 14:19:33 +0100 Subject: [PATCH 60/81] refactor(db): handle relative storage paths for better flexibility Adjust logic to ensure `storage_path` supports both relative and absolute paths. Update handling across all stores to maintain consistency. --- hardpy/hardpy_panel/api.py | 10 +++++++++- hardpy/pytest_hardpy/db/runstore.py | 11 +++++++++-- hardpy/pytest_hardpy/db/statestore.py | 11 +++++++++-- hardpy/pytest_hardpy/db/tempstore.py | 10 +++++++++- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/hardpy/hardpy_panel/api.py b/hardpy/hardpy_panel/api.py index 553d4b90..53e8e0d6 100644 --- a/hardpy/hardpy_panel/api.py +++ b/hardpy/hardpy_panel/api.py @@ -386,7 +386,15 @@ def get_json_data() -> dict: return {"error": "JSON storage not configured"} try: - storage_dir = Path(config_manager.config.database.storage_path) / "storage" + config_storage_path = Path(config_manager.config.database.storage_path) + if config_storage_path.is_absolute(): + storage_dir = config_storage_path / "storage" + else: + storage_dir = Path( + config_manager.tests_path + / config_manager.config.database.storage_path + / "storage", + ) statestore_file = storage_dir / "statestore.json" if not statestore_file.exists(): diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index f21bd243..7a642468 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -121,8 +121,15 @@ class JsonRunStore(RunStoreInterface): def __init__(self) -> None: config_manager = ConfigManager() self._store_name = "runstore" - storage_path = config_manager.config.database.storage_path - self._storage_dir = Path(storage_path) / "storage" + config_storage_path = Path(config_manager.config.database.storage_path) + if config_storage_path.is_absolute(): + self._storage_dir = config_storage_path / "storage" + else: + self._storage_dir = Path( + config_manager.tests_path + / config_manager.config.database.storage_path + / "storage", + ) self._storage_dir.mkdir(parents=True, exist_ok=True) self._file_path = self._storage_dir / f"{self._store_name}.json" self._doc_id = config_manager.config.database.doc_id diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 2eebf99c..365a7dc2 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -121,8 +121,15 @@ class JsonStateStore(StateStoreInterface): def __init__(self) -> None: config_manager = ConfigManager() self._store_name = "statestore" - storage_path = config_manager.config.database.storage_path - self._storage_dir = Path(storage_path) / "storage" + config_storage_path = Path(config_manager.config.database.storage_path) + if config_storage_path.is_absolute(): + self._storage_dir = config_storage_path / "storage" + else: + self._storage_dir = Path( + config_manager.tests_path + / config_manager.config.database.storage_path + / "storage", + ) self._storage_dir.mkdir(parents=True, exist_ok=True) self._file_path = self._storage_dir / f"{self._store_name}.json" self._doc_id = config_manager.config.database.doc_id diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index b22b9506..d729a3b5 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -72,7 +72,15 @@ class JsonTempStore(TempStoreInterface): def __init__(self) -> None: self._log = getLogger(__name__) config = ConfigManager() - self._storage_dir = Path(config.config.database.storage_path) / "tempstore" + config_storage_path = Path(config.config.database.storage_path) + if config_storage_path.is_absolute(): + self._storage_dir = config_storage_path / "tempstore" + else: + self._storage_dir = Path( + config.tests_path + / config.config.database.storage_path + / "tempstore", + ) self._storage_dir.mkdir(parents=True, exist_ok=True) self._schema = ResultRunStore From 18cc74d7c6ee95d26dee43cf0e2fafac6f3b6e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Wed, 24 Dec 2025 14:22:38 +0100 Subject: [PATCH 61/81] refactor(db): include `store_name` in storage path and use `doc_id` for file naming --- hardpy/hardpy_panel/api.py | 8 +++++--- hardpy/pytest_hardpy/db/runstore.py | 7 ++++--- hardpy/pytest_hardpy/db/statestore.py | 7 ++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/hardpy/hardpy_panel/api.py b/hardpy/hardpy_panel/api.py index 53e8e0d6..e59df105 100644 --- a/hardpy/hardpy_panel/api.py +++ b/hardpy/hardpy_panel/api.py @@ -388,14 +388,16 @@ def get_json_data() -> dict: try: config_storage_path = Path(config_manager.config.database.storage_path) if config_storage_path.is_absolute(): - storage_dir = config_storage_path / "storage" + storage_dir = config_storage_path / "storage" / "statestore" else: storage_dir = Path( config_manager.tests_path / config_manager.config.database.storage_path - / "storage", + / "storage" + / "statestore", ) - statestore_file = storage_dir / "statestore.json" + _doc_id = config_manager.config.database.doc_id + statestore_file = storage_dir / f"{_doc_id}.json" if not statestore_file.exists(): return {"rows": [], "total_rows": 0} diff --git a/hardpy/pytest_hardpy/db/runstore.py b/hardpy/pytest_hardpy/db/runstore.py index 7a642468..e8dfb5ad 100644 --- a/hardpy/pytest_hardpy/db/runstore.py +++ b/hardpy/pytest_hardpy/db/runstore.py @@ -123,16 +123,17 @@ def __init__(self) -> None: self._store_name = "runstore" config_storage_path = Path(config_manager.config.database.storage_path) if config_storage_path.is_absolute(): - self._storage_dir = config_storage_path / "storage" + self._storage_dir = config_storage_path / "storage" / self._store_name else: self._storage_dir = Path( config_manager.tests_path / config_manager.config.database.storage_path - / "storage", + / "storage" + / self._store_name, ) self._storage_dir.mkdir(parents=True, exist_ok=True) - self._file_path = self._storage_dir / f"{self._store_name}.json" self._doc_id = config_manager.config.database.doc_id + self._file_path = self._storage_dir / f"{self._doc_id}.json" self._log = getLogger(__name__) self._schema: type[BaseModel] = ResultRunStore self._doc: dict = self._init_doc() diff --git a/hardpy/pytest_hardpy/db/statestore.py b/hardpy/pytest_hardpy/db/statestore.py index 365a7dc2..f07f8eff 100644 --- a/hardpy/pytest_hardpy/db/statestore.py +++ b/hardpy/pytest_hardpy/db/statestore.py @@ -123,16 +123,17 @@ def __init__(self) -> None: self._store_name = "statestore" config_storage_path = Path(config_manager.config.database.storage_path) if config_storage_path.is_absolute(): - self._storage_dir = config_storage_path / "storage" + self._storage_dir = config_storage_path / "storage" / self._store_name else: self._storage_dir = Path( config_manager.tests_path / config_manager.config.database.storage_path - / "storage", + / "storage" + / self._store_name, ) self._storage_dir.mkdir(parents=True, exist_ok=True) - self._file_path = self._storage_dir / f"{self._store_name}.json" self._doc_id = config_manager.config.database.doc_id + self._file_path = self._storage_dir / f"{self._doc_id}.json" self._log = getLogger(__name__) self._schema: type[BaseModel] = ResultStateStore self._doc: dict = self._init_doc() From b351deaeaeae4e3121a67007962c0b00c8e0e813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Thu, 25 Dec 2025 18:16:48 +0100 Subject: [PATCH 62/81] fix(plugin): correct argument for `set_module_stop_time` and `set_case_stop_time` methods --- hardpy/pytest_hardpy/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hardpy/pytest_hardpy/plugin.py b/hardpy/pytest_hardpy/plugin.py index 373f999f..d4c4ffeb 100644 --- a/hardpy/pytest_hardpy/plugin.py +++ b/hardpy/pytest_hardpy/plugin.py @@ -496,7 +496,7 @@ def _validate_stop_time(self) -> None: module_start_time = self._reporter.get_module_start_time(module_id) module_stop_time = self._reporter.get_module_stop_time(module_id) if module_start_time and not module_stop_time: - self._reporter.set_module_stop_time(module_start_time) + self._reporter.set_module_stop_time(module_id) for module_data_key in module_data: # skip module status if module_data_key == "module_status": @@ -505,7 +505,7 @@ def _validate_stop_time(self) -> None: case_start_time = self._reporter.get_case_start_time(module_id, case_id) case_stop_time = self._reporter.get_case_stop_time(module_id, case_id) if case_start_time and not case_stop_time: - self._reporter.set_case_stop_time(case_start_time) + self._reporter.set_case_stop_time(module_id, case_id) def _stop_tests(self) -> None: """Update module and case statuses to stopped and skipped.""" From 0bc8bb325ccccab1ef2a80815f68557ab24a2ebe Mon Sep 17 00:00:00 2001 From: Ilya <163293136+xorialexandrov@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:44:28 +0300 Subject: [PATCH 63/81] Update hardpy/pytest_hardpy/plugin.py --- hardpy/pytest_hardpy/plugin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hardpy/pytest_hardpy/plugin.py b/hardpy/pytest_hardpy/plugin.py index d1d516c9..d4c4ffeb 100644 --- a/hardpy/pytest_hardpy/plugin.py +++ b/hardpy/pytest_hardpy/plugin.py @@ -34,7 +34,6 @@ from hardpy.common.config import ConfigManager, HardpyConfig from hardpy.common.stand_cloud.connector import StandCloudConnector, StandCloudError -from hardpy.pytest_hardpy.db.const import DatabaseField as DF # noqa: N817 from hardpy.pytest_hardpy.reporter import HookReporter from hardpy.pytest_hardpy.result.report_synchronizer.synchronizer import ( StandCloudSynchronizer, From a3dbe7c2af6193d5b3f73af990cf8fea1d22d6c1 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 10:34:38 +0300 Subject: [PATCH 64/81] Update package to 0.20.0 --- docs/changelog.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 4af0ead4..158537b2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,11 @@ Versions follow [Semantic Versioning](https://semver.org/): `..`. +## 0.20.0 + +* Add JSON file storage support as alternative to CouchDB. + [[PR-239](https://github.com/everypinio/hardpy/pull/239)] + ## 0.19.1 * Add a `stop_time` validator function. If a case or module has a start time, diff --git a/pyproject.toml b/pyproject.toml index 1339636d..9875bef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ [project] name = "hardpy" - version = "0.19.1" + version = "0.20.0" description = "HardPy library for device testing" license = "GPL-3.0-or-later" authors = [{ name = "Everypin", email = "info@everypin.io" }] From 5c8791bfb92300f819e844684365730b639c2d07 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 10:34:56 +0300 Subject: [PATCH 65/81] Add JSON info --- README.md | 4 ++-- docs/index.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 08a1c929..85040570 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ HardPy is a python library for creating a test bench for devices. [![pytest versions](https://img.shields.io/badge/pytest-%3E%3D7.0-blue)](https://docs.pytest.org/en/latest/) [![Documentation](https://img.shields.io/badge/Documentation%20-Overview%20-%20%23007ec6)](https://everypinio.github.io/hardpy/) [![Reddit](https://img.shields.io/badge/-Reddit-FF4500?style=flat&logo=reddit&logoColor=white)](https://www.reddit.com/r/HardPy) -[![Discord](https://img.shields.io/discord/1304494076799877172?color=7389D8&label&logo=discord&logoColor=ffffff)](https://discord.com/invite/3kBG9CbS) +[![Discord](https://img.shields.io/discord/1304494076799877172?color=7389D8&label&logo=discord&logoColor=ffffff)](https://discord.gg/98bWadmG8J) [![Telegram](https://img.shields.io/badge/-Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/everypin) @@ -26,7 +26,7 @@ HardPy allows you to: * Create test benches for devices using [pytest](https://docs.pytest.org/); * Use a browser to view, start, stop, and interact with tests; -* Store test results in the [CouchDB](https://couchdb.apache.org/) database; +* Store test results in the [CouchDB](https://couchdb.apache.org/) database or to simple JSON files; * Store test results on the [StandCloud](https://standcloud.io/) analytics platform.

diff --git a/docs/index.md b/docs/index.md index 8e084310..53e1c755 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ HardPy allows you to: * Create test benches for devices using [pytest](https://docs.pytest.org/); * Use a browser to view, start, stop, and interact with tests; -* Store test results in the [CouchDB](https://couchdb.apache.org/) database; +* Store test results in the [CouchDB](https://couchdb.apache.org/) database or to simple JSON files; * Store test results on the [StandCloud](https://standcloud.io/) analytics platform.

From bdd9ae600462157d2fecb034b033bf49cd0bca3e Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 10:35:26 +0300 Subject: [PATCH 66/81] [documentation] Add storage type info --- docs/documentation/cli.md | 1 + docs/documentation/hardpy_config.md | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/documentation/cli.md b/docs/documentation/cli.md index 8a7f21a4..4982a99f 100644 --- a/docs/documentation/cli.md +++ b/docs/documentation/cli.md @@ -53,6 +53,7 @@ More info in [hardpy config](./hardpy_config.md). | [default: no-sc-autosync] │ | [default: check-stand-cloud] │ │ --sc-api-key TEXT Specify a StandCloud API key. │ +│ --storage-type TEXT Specify a storage type. [default: couchdb] | │ --help Show this message and exit. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` diff --git a/docs/documentation/hardpy_config.md b/docs/documentation/hardpy_config.md index a509afec..08728cd5 100644 --- a/docs/documentation/hardpy_config.md +++ b/docs/documentation/hardpy_config.md @@ -30,7 +30,7 @@ tests_name = "My tests" current_test_config = "" [database] -storage_type = "json" +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" @@ -88,13 +88,21 @@ Storage type. The default is `couchdb`. - `couchdb`: Stores test results and measurements in a CouchDB database. Requires a running CouchDB instance. - `json`: Stores test results and measurements in local JSON files. No external database required. + Files are stored in the `.hardpy` directory in the root of the project. The user can change this value with the `hardpy init --storage-type` option. #### storage_path Path to the storage directory. The default is `.hardpy` in the root of the project. -The user can change this value with the `hardpy init --storage-path` option. Relative and absolute paths are supported. +The user can change this value in the hardpy.toml file using the `storage_path` option. +Both relative and absolute paths are supported. + +```toml +[database] +storage_type = "json" +storage_path = "result" +``` #### user From 64b231fdccba1f7d69d87cec5b17a12df1d79bb0 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 10:36:05 +0300 Subject: [PATCH 67/81] [hardpy] Add storage_type --- hardpy/cli/cli.py | 6 ++++++ hardpy/common/config.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/hardpy/cli/cli.py b/hardpy/cli/cli.py index 1fc1f5f0..425ca090 100644 --- a/hardpy/cli/cli.py +++ b/hardpy/cli/cli.py @@ -82,6 +82,10 @@ def init( # noqa: PLR0913 default=default_config.stand_cloud.api_key, help="Specify a StandCloud API key.", ), + storage_type: str = typer.Option( + default=default_config.database.storage_type.value, + help="Specify a storage type.", + ), ) -> None: """Initialize HardPy tests directory. @@ -100,6 +104,7 @@ def init( # noqa: PLR0913 sc_connection_only (bool): Flag to check StandCloud service availability sc_autosync (bool): Flag to enable StandCloud auto syncronization sc_api_key (str | None): StandCloud API key + storage_type (str): Storage type, "json" or "couchdb", "couchdb" by default """ dir_path = Path(Path.cwd() / tests_dir if tests_dir else "tests") config_manager = ConfigManager() @@ -116,6 +121,7 @@ def init( # noqa: PLR0913 sc_connection_only=sc_connection_only, sc_autosync=sc_autosync, sc_api_key=sc_api_key, + storage_type=storage_type, ) # create tests directory Path.mkdir(dir_path, exist_ok=True, parents=True) diff --git a/hardpy/common/config.py b/hardpy/common/config.py index cf997190..14b7ba7b 100644 --- a/hardpy/common/config.py +++ b/hardpy/common/config.py @@ -173,6 +173,7 @@ def init_config( # noqa: PLR0913 sc_connection_only: bool, sc_autosync: bool, sc_api_key: str, + storage_type: str, ) -> None: """Initialize the HardPy configuration. @@ -192,11 +193,17 @@ def init_config( # noqa: PLR0913 sc_autosync (bool): StandCloud auto syncronization. sc_api_key (str): StandCloud API key. """ + # try: + # _storage_type = + # except ValueError: + # msg = f"Invalid storage type {storage_type}" + # raise ValueError(msg) self._config.tests_name = tests_name self._config.frontend.host = frontend_host self._config.frontend.port = frontend_port self._config.frontend.language = frontend_language self._config.database.user = database_user + self._config.database.storage_type = StorageType(storage_type) self._config.database.password = database_password self._config.database.host = database_host self._config.database.port = database_port From f51036b0fce674f7a2d64bda05b28576b82317d4 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 10:36:18 +0300 Subject: [PATCH 68/81] [test_cli] Add storage_type test --- tests/test_cli/test_hardpy_init.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_cli/test_hardpy_init.py b/tests/test_cli/test_hardpy_init.py index beee5073..0f23878d 100644 --- a/tests/test_cli/test_hardpy_init.py +++ b/tests/test_cli/test_hardpy_init.py @@ -11,11 +11,13 @@ frontend_no_default_host = "localhost1" frontend_no_default_port = "8001" stand_cloud_no_default_addr = "everypin1.standcloud.localhost" +storage_type_no_default = "json" db_default_port = "5984" frontend_default_host = "localhost" frontend_default_port = "8000" frontend_default_language = "en" +storage_type_default = "couchdb" def test_cli_init(tmp_path: Path): @@ -112,6 +114,31 @@ def test_cli_init_db_port(tmp_path: Path): ";port = 5985" in content ), "couchdb.ini does not contain the expected port." +def test_cli_init_storage_type_default(tmp_path: Path): + subprocess.run( + [*HARDPY_COMMAND, tmp_path], + check=True, + ) + hardpy_toml_path = tmp_path / "hardpy.toml" + with Path.open(hardpy_toml_path) as f: + content = f.read() + storage_type_info = f"""storage_type = "{storage_type_default}" +""" + assert_msg = "hardpy.toml does not contain the default storage type." + assert storage_type_info in content, assert_msg + +def test_cli_init_storage_type_no_default(tmp_path: Path): + subprocess.run( + [*HARDPY_COMMAND, tmp_path, "--storage-type", storage_type_no_default], + check=True, + ) + hardpy_toml_path = tmp_path / "hardpy.toml" + with Path.open(hardpy_toml_path) as f: + content = f.read() + storage_type_info = f"""storage_type = "{storage_type_no_default}" +""" + assert_msg = "hardpy.toml does not contain the valid storage type." + assert storage_type_info in content, assert_msg def test_cli_init_frontend_host(tmp_path: Path): subprocess.run( From 9b40b64d6daaf945c87108976069a5aa63ddcbc5 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 10:57:06 +0300 Subject: [PATCH 69/81] [examples] Add storage_type --- examples/attempts/hardpy.toml | 1 + examples/couchdb_load/hardpy.toml | 1 + examples/dialog_box/hardpy.toml | 1 + examples/hello_hardpy/hardpy.toml | 1 + examples/measurement/hardpy.toml | 1 + examples/minute_parity/hardpy.toml | 1 + examples/multiple_configs/hardpy.toml | 1 + examples/operator_msg/hardpy.toml | 1 + examples/stand_cloud/hardpy.toml | 1 + 9 files changed, 9 insertions(+) diff --git a/examples/attempts/hardpy.toml b/examples/attempts/hardpy.toml index b94f1a38..f6247fcf 100644 --- a/examples/attempts/hardpy.toml +++ b/examples/attempts/hardpy.toml @@ -3,6 +3,7 @@ tests_name = "Attempts" current_test_config = "" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" diff --git a/examples/couchdb_load/hardpy.toml b/examples/couchdb_load/hardpy.toml index a8c4bab9..a7001d31 100644 --- a/examples/couchdb_load/hardpy.toml +++ b/examples/couchdb_load/hardpy.toml @@ -3,6 +3,7 @@ tests_name = "CouchDB Load" current_test_config = "" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" diff --git a/examples/dialog_box/hardpy.toml b/examples/dialog_box/hardpy.toml index 6d502185..d305d1c0 100644 --- a/examples/dialog_box/hardpy.toml +++ b/examples/dialog_box/hardpy.toml @@ -3,6 +3,7 @@ tests_name = "Dialog Box" current_test_config = "" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" diff --git a/examples/hello_hardpy/hardpy.toml b/examples/hello_hardpy/hardpy.toml index ce541134..bb7559c6 100644 --- a/examples/hello_hardpy/hardpy.toml +++ b/examples/hello_hardpy/hardpy.toml @@ -3,6 +3,7 @@ tests_name = "Hello HardPy" current_test_config = "" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" diff --git a/examples/measurement/hardpy.toml b/examples/measurement/hardpy.toml index 022960ae..0c6b4902 100644 --- a/examples/measurement/hardpy.toml +++ b/examples/measurement/hardpy.toml @@ -3,6 +3,7 @@ tests_name = "Measurement" current_test_config = "" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" diff --git a/examples/minute_parity/hardpy.toml b/examples/minute_parity/hardpy.toml index 0342d5cd..f6c0fd9f 100644 --- a/examples/minute_parity/hardpy.toml +++ b/examples/minute_parity/hardpy.toml @@ -3,6 +3,7 @@ tests_name = "Minute Parity" current_test_config = "" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" diff --git a/examples/multiple_configs/hardpy.toml b/examples/multiple_configs/hardpy.toml index 89fd9a12..124a807e 100644 --- a/examples/multiple_configs/hardpy.toml +++ b/examples/multiple_configs/hardpy.toml @@ -9,6 +9,7 @@ host = "localhost" port = 5984 [frontend] +storage_type = "couchdb" host = "localhost" port = 8000 language = "en" diff --git a/examples/operator_msg/hardpy.toml b/examples/operator_msg/hardpy.toml index 89b4d452..d6dad4d0 100644 --- a/examples/operator_msg/hardpy.toml +++ b/examples/operator_msg/hardpy.toml @@ -3,6 +3,7 @@ tests_name = "Operator Message" current_test_config = "" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" diff --git a/examples/stand_cloud/hardpy.toml b/examples/stand_cloud/hardpy.toml index 3847a1aa..87f48d4a 100644 --- a/examples/stand_cloud/hardpy.toml +++ b/examples/stand_cloud/hardpy.toml @@ -3,6 +3,7 @@ tests_name = "StandCloud" current_test_config = "" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" From 95bf5b95f6b3339461cd05da905703452fbcd1fa Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 11:00:25 +0300 Subject: [PATCH 70/81] [common] Add storage_type docstring --- hardpy/common/config.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/hardpy/common/config.py b/hardpy/common/config.py index 14b7ba7b..86f218f2 100644 --- a/hardpy/common/config.py +++ b/hardpy/common/config.py @@ -192,12 +192,8 @@ def init_config( # noqa: PLR0913 sc_connection_only (bool): StandCloud check availability. sc_autosync (bool): StandCloud auto syncronization. sc_api_key (str): StandCloud API key. + storage_type (str): Database storage type. """ - # try: - # _storage_type = - # except ValueError: - # msg = f"Invalid storage type {storage_type}" - # raise ValueError(msg) self._config.tests_name = tests_name self._config.frontend.host = frontend_host self._config.frontend.port = frontend_port From a4e527f042285f95aadf0c1abc42d43cdf907f21 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 11:44:33 +0300 Subject: [PATCH 71/81] [test_config] Add storage_type --- tests/test_common/test_config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index 440d07c5..54a3e2bb 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -64,10 +64,12 @@ def test_config_manager_init(): sc_connection_only=stand_cloud_no_default_connection_only, sc_autosync=stand_cloud_no_default_autosync, sc_api_key=stand_cloud_no_default_api_key, + storage_type="json", ) config = config_manager.config assert isinstance(config, HardpyConfig) assert config.tests_name == tests_no_default_name + assert config.database.storage_type == "json" assert config.database.user == db_no_default_user assert config.database.password == db_no_default_password assert config.database.host == db_no_default_host @@ -157,6 +159,7 @@ def test_config_manager_create_config(tmp_path: Path): sc_connection_only=stand_cloud_dafault_connection_only, sc_autosync=stand_cloud_default_autosync, sc_api_key=stand_cloud_default_api_key, + storage_type="json", ) config_manager.create_config(tests_dir) From cfa42ed4966a868444a51be2ccbf2433050bdac8 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 14:16:24 +0300 Subject: [PATCH 72/81] Add uuid6 for uuid7 using --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 9875bef5..df36ea1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ "tomli>=2.0.1, <3", "py-machineid~=0.6.0", "tzlocal~=5.2", + "uuid6", # Frontend "fastapi>=0.100.1", From d8636848bbb70c0d0a7d3d518418035d18f10066 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 14:17:04 +0300 Subject: [PATCH 73/81] [pytest_hardpy] Fix JSON tempstore usage --- hardpy/pytest_hardpy/db/tempstore.py | 12 ++++++++---- .../result/report_synchronizer/synchronizer.py | 11 ++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/hardpy/pytest_hardpy/db/tempstore.py b/hardpy/pytest_hardpy/db/tempstore.py index d729a3b5..235a08b7 100644 --- a/hardpy/pytest_hardpy/db/tempstore.py +++ b/hardpy/pytest_hardpy/db/tempstore.py @@ -8,7 +8,8 @@ from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING -from uuid import uuid4 + +from uuid6 import uuid7 from hardpy.common.config import ConfigManager, StorageType from hardpy.common.singleton import SingletonMeta @@ -74,11 +75,12 @@ def __init__(self) -> None: config = ConfigManager() config_storage_path = Path(config.config.database.storage_path) if config_storage_path.is_absolute(): - self._storage_dir = config_storage_path / "tempstore" + self._storage_dir = config_storage_path / "storage" / "tempstore" else: self._storage_dir = Path( config.tests_path / config.config.database.storage_path + / "storage" / "tempstore", ) self._storage_dir.mkdir(parents=True, exist_ok=True) @@ -94,8 +96,10 @@ def push_report(self, report: ResultRunStore) -> bool: bool: True if successful, False otherwise """ report_dict = report.model_dump() - report_id = report_dict.get("id", str(uuid4())) - report_dict["id"] = report_id # Ensure ID is in the document + report_dict.pop("id", None) + report_id = str(uuid7()) + report_dict["_id"] = report_id + report_dict["_rev"] = report_id report_file = self._storage_dir / f"{report_id}.json" try: diff --git a/hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py b/hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py index 6d89b450..4d1c9b50 100644 --- a/hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py +++ b/hardpy/pytest_hardpy/result/report_synchronizer/synchronizer.py @@ -8,7 +8,7 @@ from pydantic import ValidationError from hardpy.common.stand_cloud.exception import StandCloudError -from hardpy.pytest_hardpy.db.tempstore import TempStore +from hardpy.pytest_hardpy.db.tempstore import CouchDBTempStore, TempStore from hardpy.pytest_hardpy.result.report_loader.stand_cloud_loader import ( StandCloudLoader, ) @@ -40,6 +40,7 @@ def sync(self) -> str: Returns: str: Synchronization message """ + _tempstore = self._get_tempstore if not self._get_tempstore.reports(): return "All reports are synchronized with StandCloud" loader = self._create_sc_loader() @@ -48,8 +49,12 @@ def sync(self) -> str: success_report_counter = 0 for _report in self._get_tempstore.reports(): try: - report_id: str = _report.get("id") # type: ignore[assignment] - document: dict = _report.get("doc") # type: ignore[assignment] + if isinstance(_tempstore, CouchDBTempStore): + document: dict = _report.get("doc") # type: ignore[assignment] + report_id: str = _report.get("id") # type: ignore[assignment] + else: + document: dict = _report + report_id: str = _report.get("_id") document.pop("rev") except KeyError: try: From d1293cb2a5f3c565f98356c28343489d2f645b4e Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 15:34:48 +0300 Subject: [PATCH 74/81] [.vscode] Add JSON storage example --- .vscode/launch.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index fc1522a4..e5b35449 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -138,6 +138,17 @@ "examples/minute_parity" ] }, + { + "name": "Python: Example JSON storage", + "type": "debugpy", + "request": "launch", + "module": "hardpy.cli.cli", + "console": "integratedTerminal", + "args": [ + "run", + "examples/json_storage" + ] + }, { "name": "Python: Example Multiple configs", "type": "debugpy", From 527cb28beb0f0d543f0cfb836662d91e5f9b0b63 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 15:34:59 +0300 Subject: [PATCH 75/81] Add json_storage.md --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 0d4d2c7b..cd720a89 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - Dialog box: examples/dialog_box.md - HardPy launch: examples/hardpy_launch.md - Hello HardPy: examples/hello_hardpy.md + - JSON storage: examples/json_storage.md - Launch arguments: examples/launch_arguments.md - Pytest logging: examples/logging.md - Measurement: examples/measurement.md From cb39cee170d82513456fda4d5267b181926ba24a Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 15:35:22 +0300 Subject: [PATCH 76/81] Add example without a database --- README.md | 14 ++++++++++++++ docs/index.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/README.md b/README.md index 85040570..860d969b 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ pip install hardpy ## Getting Started +### With CouchDB + 1. Create your first test bench. ```bash hardpy init @@ -59,6 +61,18 @@ hardpy run Login and password: **dev**, database - **runstore**. +### Without a database + +1. Create your first test bench. +```bash +hardpy init --no-create-database --storage-type json +``` +2. Launch HardPy operator panel. +```bash +hardpy run +``` +3. View operator panel in browser: http://localhost:8000/ + ## Examples For more examples of using **HardPy**, see the [examples](https://github.com/everypinio/hardpy/tree/main/examples) folder and the [documentation](https://everypinio.github.io/hardpy/examples/). diff --git a/docs/index.md b/docs/index.md index 53e1c755..ebf9e2c4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,8 @@ pip install hardpy ## Getting Started +### With CouchDB + 1. Create your first test bench. ```bash @@ -62,6 +64,18 @@ hardpy run alt="hardpy runstore" style="width:500px;">

+### Without a database + +1. Create your first test bench. +```bash +hardpy init --no-create-database --storage-type json +``` +2. Launch HardPy operator panel. +```bash +hardpy run +``` +3. View operator panel in browser: http://localhost:8000/ + ## Measurement instruments **HardPy** does not contain any drivers for interacting with measuring equipment. From 656316449a2fee1675a90fa220090a9406152efb Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 15:35:38 +0300 Subject: [PATCH 77/81] [docs] Fix link --- docs/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 158537b2..1d0c753d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -24,7 +24,7 @@ Versions follow [Semantic Versioning](https://semver.org/): `.. Date: Mon, 29 Dec 2025 15:36:05 +0300 Subject: [PATCH 78/81] [docs] Add JSON features --- docs/documentation/pytest_hardpy.md | 29 ++ docs/examples/json_storage.md | 408 ++++++++++++++++++++++++++++ docs/features/features.md | 11 +- 3 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 docs/examples/json_storage.md diff --git a/docs/documentation/pytest_hardpy.md b/docs/documentation/pytest_hardpy.md index 984d66f3..fe6e2bde 100644 --- a/docs/documentation/pytest_hardpy.md +++ b/docs/documentation/pytest_hardpy.md @@ -911,6 +911,35 @@ def fill_actions_after_test(post_run_functions: list): yield ``` +#### JsonLoader + +Used to write reports to the JSON. + +**Arguments:** + +- `storage_dir` *(Path | None)*: JSON file directory. + +**Functions:** + +- `load` *(ResultRunStore, new_report_id)*: Load report to the JSON file. + +**Example:** + +```python +# conftest +def save_report_to_dir(): + report = get_current_report() + if report: + loader = JsonLoader(Path.cwd() / "reports") + loader.load(report) + + +@pytest.fixture(scope="session", autouse=True) +def fill_actions_after_test(post_run_functions: list): + post_run_functions.append(save_report_to_dir) + yield +``` + #### StandCloudLoader Used to write reports to the **StandCloud**. diff --git a/docs/examples/json_storage.md b/docs/examples/json_storage.md new file mode 100644 index 00000000..8a783f92 --- /dev/null +++ b/docs/examples/json_storage.md @@ -0,0 +1,408 @@ +# JSON storage + +This is an example of using **pytest-hardpy** functions, storing +the result to JSON file. +The code for this example can be seen inside the hardpy package +[JSON storage](https://github.com/everypinio/hardpy/tree/main/examples/json_storage). + +### how to start + +1. Launch `hardpy init --no-create-database --storage-type json json_storage`. +2. Modify the files described below. +3. Launch `hardpy run json_storage`. + +### hardpy.toml + +Replace the settings in the `[frontend]` and `[frontend.modal_result]` sections +with those shown in the **hardpy.toml** example file below. + +```toml +title = "HardPy JSON Storage Demo" +tests_name = "Device Test Suite" + +[database] +storage_type = "json" +storage_path = "result" + +[frontend] +host = "localhost" +port = 8000 +language = "en" +sound_on = false +full_size_button = false +manual_collect = false +measurement_display = true + +[frontend.modal_result] +enable = true +auto_dismiss_pass = true +auto_dismiss_timeout = 5 +``` + +### conftest.py + +```python +from pathlib import Path +import pytest + +from hardpy import JsonLoader, get_current_report + + +@pytest.fixture(scope="session") +def setup_test_environment(): + """Set up test environment before all tests.""" + print("\n=== Setting up test environment ===") + # Add any global setup here + yield + print("\n=== Tearing down test environment ===") + # Add any global cleanup here + + +@pytest.fixture(scope="function") +def test_device(): + """Fixture providing simulated test device.""" + + class TestDevice: + def __init__(self): + self.connected = False + self.voltage = 5.0 + self.current = 0.0 + + def connect(self): + self.connected = True + return True + + def disconnect(self): + self.connected = False + + def measure_voltage(self): + return self.voltage + + def measure_current(self): + return self.current + + device = TestDevice() + device.connect() + + yield device + + device.disconnect() + + +def save_report_to_dir(): + report = get_current_report() + if report: + loader = JsonLoader(Path.cwd() / "reports") + loader.load(report) + + +@pytest.fixture(scope="session", autouse=True) +def fill_actions_after_test(post_run_functions: list): + post_run_functions.append(save_report_to_dir) + yield +``` + +### test_chart_demo.py + +```python +import hardpy +import pytest +import math + +@pytest.mark.case_name("Sine Wave Analysis") +@pytest.mark.module_name("Chart Demonstrations") +def test_sine_wave(): + """Test generating and analyzing a sine wave.""" + hardpy.set_message("Generating sine wave data...") + + # Generate sine wave data + x_data = [] + y_data = [] + + for i in range(100): + x = i / 10.0 # 0 to 10 + y = math.sin(x) + x_data.append(x) + y_data.append(y) + + # Create chart + chart = hardpy.Chart( + title="Sine Wave", + x_label="Time", + y_label="Amplitude", + type=hardpy.ChartType.LINE, + ) + chart.add_series(x_data, y_data, "Sine Wave") + + hardpy.set_case_chart(chart) + + # Verify amplitude + max_amplitude = max(y_data) + min_amplitude = min(y_data) + peak_to_peak = max_amplitude - min_amplitude + + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Peak-to-Peak Amplitude", + value=peak_to_peak, + unit="V", + lower_limit=1.9, + upper_limit=2.1, + ) + ) + + hardpy.set_message(f"Peak-to-peak amplitude: {peak_to_peak:.3f}V") + + assert 1.9 <= peak_to_peak <= 2.1, "Amplitude out of range" + +@pytest.mark.case_name("Temperature Curve") +@pytest.mark.module_name("Chart Demonstrations") +def test_temperature_curve(): + """Test temperature rise curve.""" + hardpy.set_message("Recording temperature curve...") + + # Simulate temperature rise + time_data = [] + temp_data = [] + + for i in range(50): + time = i * 2 # seconds + # Exponential rise to 80°C + temp = 25 + 55 * (1 - math.exp(-i / 20)) + time_data.append(time) + temp_data.append(temp) + + # Create chart + chart = hardpy.Chart( + title="Temperature Rise Curve", + x_label="Time (seconds)", + y_label="Temperature (°C)", + type=hardpy.ChartType.LINE, + ) + chart.add_series(time_data, temp_data, "Temperature") + + hardpy.set_case_chart(chart) + + # Check final temperature + final_temp = temp_data[-1] + + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Final Temperature", + value=final_temp, + unit="°C", + upper_limit=85, + ) + ) + + hardpy.set_message(f"Final temperature: {final_temp:.1f}°C") + + assert final_temp < 85, f"Temperature too high: {final_temp}°C" +``` + +### test_communication.py + +```python +import hardpy +import pytest +from time import sleep + +@pytest.mark.case_name("Serial Port Connection") +@pytest.mark.module_name("Communication Tests") +def test_serial_connection(): + """Test serial port connection.""" + hardpy.set_message("Testing serial port connection...") + + # Simulate connection + port = "/dev/ttyUSB0" + baudrate = 115200 + + hardpy.set_instrument( + hardpy.Instrument( + name="Serial Port", + comment=f"{port} @ {baudrate} baud" + ) + ) + + # Simulate successful connection + connection_ok = True + + hardpy.set_message(f"Connected to {port} at {baudrate} baud") + + assert connection_ok, "Failed to establish serial connection" + +@pytest.mark.case_name("Data Transfer Test") +@pytest.mark.module_name("Communication Tests") +@pytest.mark.attempt(3) # Allow 2 retries +def test_data_transfer(): + """Test data transfer over serial.""" + hardpy.set_message("Testing data transfer...") + + # Simulate sending and receiving data + sent_bytes = 1024 + received_bytes = 1024 + transfer_time = 0.5 # seconds + + # Calculate transfer rate + transfer_rate = (sent_bytes + received_bytes) / transfer_time + + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Transfer Rate", + value=transfer_rate, + unit="bytes/s", + lower_limit=1000, + ) + ) + + hardpy.set_message(f"Transfer rate: {transfer_rate:.0f} bytes/s") + + assert received_bytes == sent_bytes, "Data integrity error" + assert transfer_rate > 1000, "Transfer rate too slow" + +@pytest.mark.case_name("Protocol Validation") +@pytest.mark.module_name("Communication Tests") +@pytest.mark.critical # Critical test - stops all if fails +def test_protocol_validation(): + """Test communication protocol validation.""" + hardpy.set_message("Validating communication protocol...") + + # Simulate protocol check + protocol_version = "v2.1" + expected_version = "v2.1" + + hardpy.set_case_measurement( + hardpy.StringMeasurement( + name="Protocol Version", + value=protocol_version, + comparison_value=expected_version, + ) + ) + + hardpy.set_message(f"Protocol version: {protocol_version}") + + assert protocol_version == expected_version, \ + f"Protocol mismatch: got {protocol_version}, expected {expected_version}" + +@pytest.mark.case_name("Error Handling Test") +@pytest.mark.module_name("Communication Tests") +@pytest.mark.dependency("test_communication::test_protocol_validation") +def test_error_handling(): + """Test error handling (depends on protocol validation).""" + hardpy.set_message("Testing error handling...") + + # Simulate error injection and recovery + errors_injected = 5 + errors_handled = 5 + + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Errors Handled", + value=errors_handled, + unit="count", + comparison_value=errors_injected, + ) + ) + + hardpy.set_message(f"Handled {errors_handled}/{errors_injected} errors") + + assert errors_handled == errors_injected, "Some errors not handled correctly" +``` + +### test_voltage.py + +```python +import hardpy +import pytest +from time import sleep + +@pytest.mark.case_name("Check Power Supply Voltage") +@pytest.mark.module_name("Power Supply Tests") +def test_power_supply_voltage(): + """Test that power supply outputs correct voltage.""" + # Set test stand information + hardpy.set_stand_name("Test Bench #1") + hardpy.set_stand_location("Lab A") + + # Set device under test information + hardpy.set_dut_serial_number("PSU-12345") + hardpy.set_dut_name("Power Supply Unit") + hardpy.set_dut_type("DC Power Supply") + + # Simulate voltage measurement + expected_voltage = 5.0 + measured_voltage = 5.02 # Simulated measurement + tolerance = 0.1 + + # Record measurement + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Output Voltage", + value=measured_voltage, + unit="V", + lower_limit=expected_voltage - tolerance, + upper_limit=expected_voltage + tolerance, + ) + ) + + # Add message + hardpy.set_message(f"Measured voltage: {measured_voltage}V (expected: {expected_voltage}V)") + + # Verify voltage is within tolerance + assert abs(measured_voltage - expected_voltage) <= tolerance, \ + f"Voltage out of tolerance: {measured_voltage}V" + +@pytest.mark.case_name("Check Current Limit") +@pytest.mark.module_name("Power Supply Tests") +def test_current_limit(): + """Test that power supply has correct current limit.""" + # Simulate current limit test + expected_limit = 3.0 + measured_limit = 3.05 # Simulated measurement + tolerance = 0.2 + + # Record measurement + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Current Limit", + value=measured_limit, + unit="A", + lower_limit=expected_limit - tolerance, + upper_limit=expected_limit + tolerance, + ) + ) + + hardpy.set_message(f"Current limit: {measured_limit}A") + + assert abs(measured_limit - expected_limit) <= tolerance + +@pytest.mark.case_name("Voltage Stability Test") +@pytest.mark.module_name("Power Supply Tests") +@pytest.mark.attempt(2) # Retry once if fails +def test_voltage_stability(): + """Test voltage stability over time.""" + hardpy.set_message("Testing voltage stability over 5 seconds...") + + voltage_readings = [] + for i in range(5): + # Simulate reading voltage + voltage = 5.0 + (i * 0.01) # Slight increase + voltage_readings.append(voltage) + sleep(0.1) # Simulate measurement delay + + max_variation = max(voltage_readings) - min(voltage_readings) + + # Record measurement + hardpy.set_case_measurement( + hardpy.NumericMeasurement( + name="Voltage Variation", + value=max_variation, + unit="V", + upper_limit=0.1, + ) + ) + + hardpy.set_message(f"Max voltage variation: {max_variation:.3f}V") + + assert max_variation < 0.1, f"Voltage not stable: {max_variation}V variation" +``` diff --git a/docs/features/features.md b/docs/features/features.md index d3f9f936..9f17e76f 100644 --- a/docs/features/features.md +++ b/docs/features/features.md @@ -190,7 +190,7 @@ The user can check the status of tests using the [hardpy status](./../documentat ### Storing test result in database -**HardPy** does not allow you to run tests without a running [CouchDB](https://couchdb.apache.org/) database. +**HardPy** allows you to run tests with a running [CouchDB](https://couchdb.apache.org/) database. This is a NoSQL database that ensures that the results of the current test run are committed, even if the tests are aborted early. @@ -206,6 +206,15 @@ An example of configuring **conftest.py** to store test run history can be found including the [couchdb_load](./../examples/couchdb_load.md) and [minute_parity](./../examples/minute_parity.md). +### JSON + +With **HardPy**, you can run tests without a database and save the test data to local JSON documents. +These documents have a structure similar to that of databases and documents in **CouchDB**. +A .hardpy/storage folder is created by default in the project folder, +where test reports can be found in the **runstore** folder. + +An example of its use can be found on page [JSON storage](./../examples/json_storage.md). + ### Other databases In order to save data to other databases, users will need to write their own adapter class to convert their From 49582e9e0d4836f5d0ec7aafe46c0e5065e1d3ab Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 15:37:09 +0300 Subject: [PATCH 79/81] [json_storage] Mv json storage example --- examples/json_storage/.gitignore | 1 + examples/json_storage/README.md | 3 +++ .../conftest.py | 17 +++++++++++++++++ .../hardpy.toml | 0 .../pytest.ini | 0 .../test_chart_demo.py | 0 .../test_communication.py | 0 .../test_voltage.py | 0 8 files changed, 21 insertions(+) create mode 100644 examples/json_storage/.gitignore create mode 100644 examples/json_storage/README.md rename examples/{demo_json_storage => json_storage}/conftest.py (70%) rename examples/{demo_json_storage => json_storage}/hardpy.toml (100%) rename examples/{demo_json_storage => json_storage}/pytest.ini (100%) rename examples/{demo_json_storage => json_storage}/test_chart_demo.py (100%) rename examples/{demo_json_storage => json_storage}/test_communication.py (100%) rename examples/{demo_json_storage => json_storage}/test_voltage.py (100%) diff --git a/examples/json_storage/.gitignore b/examples/json_storage/.gitignore new file mode 100644 index 00000000..c4a847d9 --- /dev/null +++ b/examples/json_storage/.gitignore @@ -0,0 +1 @@ +/result diff --git a/examples/json_storage/README.md b/examples/json_storage/README.md new file mode 100644 index 00000000..dcc71eaf --- /dev/null +++ b/examples/json_storage/README.md @@ -0,0 +1,3 @@ +# JSON storage + +Example documentation: https://everypinio.github.io/hardpy/examples/json_storage/ diff --git a/examples/demo_json_storage/conftest.py b/examples/json_storage/conftest.py similarity index 70% rename from examples/demo_json_storage/conftest.py rename to examples/json_storage/conftest.py index 4c60935a..dd169b15 100644 --- a/examples/demo_json_storage/conftest.py +++ b/examples/json_storage/conftest.py @@ -1,5 +1,8 @@ +from pathlib import Path import pytest +from hardpy import JsonLoader, get_current_report + @pytest.fixture(scope="session") def setup_test_environment(): @@ -14,6 +17,7 @@ def setup_test_environment(): @pytest.fixture(scope="function") def test_device(): """Fixture providing simulated test device.""" + class TestDevice: def __init__(self): self.connected = False @@ -39,3 +43,16 @@ def measure_current(self): yield device device.disconnect() + + +def save_report_to_dir(): + report = get_current_report() + if report: + loader = JsonLoader(Path.cwd() / "reports") + loader.load(report) + + +@pytest.fixture(scope="session", autouse=True) +def fill_actions_after_test(post_run_functions: list): + post_run_functions.append(save_report_to_dir) + yield diff --git a/examples/demo_json_storage/hardpy.toml b/examples/json_storage/hardpy.toml similarity index 100% rename from examples/demo_json_storage/hardpy.toml rename to examples/json_storage/hardpy.toml diff --git a/examples/demo_json_storage/pytest.ini b/examples/json_storage/pytest.ini similarity index 100% rename from examples/demo_json_storage/pytest.ini rename to examples/json_storage/pytest.ini diff --git a/examples/demo_json_storage/test_chart_demo.py b/examples/json_storage/test_chart_demo.py similarity index 100% rename from examples/demo_json_storage/test_chart_demo.py rename to examples/json_storage/test_chart_demo.py diff --git a/examples/demo_json_storage/test_communication.py b/examples/json_storage/test_communication.py similarity index 100% rename from examples/demo_json_storage/test_communication.py rename to examples/json_storage/test_communication.py diff --git a/examples/demo_json_storage/test_voltage.py b/examples/json_storage/test_voltage.py similarity index 100% rename from examples/demo_json_storage/test_voltage.py rename to examples/json_storage/test_voltage.py From 79a6de6d520ea9a6fb01a88d9793e67534189121 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 15:37:40 +0300 Subject: [PATCH 80/81] [hardpy] Add JsonLoader --- hardpy/__init__.py | 2 + hardpy/pytest_hardpy/result/__init__.py | 2 + .../result/report_loader/json_loader.py | 49 +++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 hardpy/pytest_hardpy/result/report_loader/json_loader.py diff --git a/hardpy/__init__.py b/hardpy/__init__.py index ca4560b6..f11f2dda 100644 --- a/hardpy/__init__.py +++ b/hardpy/__init__.py @@ -45,6 +45,7 @@ ) from hardpy.pytest_hardpy.result import ( CouchdbLoader, + JsonLoader, StandCloudLoader, StandCloudReader, ) @@ -82,6 +83,7 @@ "HTMLComponent", "ImageComponent", "Instrument", + "JsonLoader", "MultistepWidget", "NumericInputWidget", "NumericMeasurement", diff --git a/hardpy/pytest_hardpy/result/__init__.py b/hardpy/pytest_hardpy/result/__init__.py index a82d101b..204629db 100644 --- a/hardpy/pytest_hardpy/result/__init__.py +++ b/hardpy/pytest_hardpy/result/__init__.py @@ -2,6 +2,7 @@ # GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) from hardpy.pytest_hardpy.result.report_loader.couchdb_loader import CouchdbLoader +from hardpy.pytest_hardpy.result.report_loader.json_loader import JsonLoader from hardpy.pytest_hardpy.result.report_loader.stand_cloud_loader import ( StandCloudLoader, ) @@ -13,6 +14,7 @@ __all__ = [ "CouchdbLoader", "CouchdbReader", + "JsonLoader", "StandCloudLoader", "StandCloudReader", ] diff --git a/hardpy/pytest_hardpy/result/report_loader/json_loader.py b/hardpy/pytest_hardpy/result/report_loader/json_loader.py new file mode 100644 index 00000000..c6e3d2eb --- /dev/null +++ b/hardpy/pytest_hardpy/result/report_loader/json_loader.py @@ -0,0 +1,49 @@ +# Copyright (c) 2024 Everypin +# GNU General Public License v3.0 (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import annotations + +import json +from logging import getLogger +from pathlib import Path +from typing import TYPE_CHECKING + +from uuid6 import uuid7 + +if TYPE_CHECKING: + from hardpy.pytest_hardpy.db.schema import ResultRunStore + + +class JsonLoader: + """JSON report generator.""" + + def __init__(self, storage_dir: Path | None = None) -> None: + if not storage_dir: + storage_dir = Path.cwd() / "reports" + self._storage_dir = storage_dir + self._storage_dir.mkdir(parents=True, exist_ok=True) + self._log = getLogger(__name__) + + def load(self, report: ResultRunStore, new_report_id: str | None = None) -> bool: + """Load report to the report database. + + Args: + report (ResultRunStore): report + new_report_id (str | None, optional): user's report ID. Defaults to uuid7. + + Returns: + bool: True if success, else False + """ + report_dict = report.model_dump() + report_id = new_report_id if new_report_id else str(uuid7()) + report_dict["id"] = report_id + report_file = self._storage_dir / f"{report_id}.json" + + try: + with report_file.open("w") as f: + json.dump(report_dict, f, indent=2, default=str) + except Exception as exc: # noqa: BLE001 + self._log.error(f"Error while saving report {report_id}: {exc}") + return False + else: + self._log.debug(f"Report saved with id: {report_id}") + return True From 9f6a2a5f0697521d4c48bf0bee628896972c7548 Mon Sep 17 00:00:00 2001 From: "i.alexandrov" Date: Mon, 29 Dec 2025 15:59:37 +0300 Subject: [PATCH 81/81] [test_plugin] Add JSON tests --- tests/test_plugin/conftest.py | 36 +++++++++++++++---------- tests/test_plugin/hardpy.toml | 1 + tests/test_plugin/json_toml/hardpy.toml | 18 +++++++++++++ 3 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 tests/test_plugin/json_toml/hardpy.toml diff --git a/tests/test_plugin/conftest.py b/tests/test_plugin/conftest.py index b386beb8..1d9b5f7f 100644 --- a/tests/test_plugin/conftest.py +++ b/tests/test_plugin/conftest.py @@ -7,18 +7,26 @@ pytest_plugins = "pytester" -@pytest.fixture -def hardpy_opts(): +@pytest.fixture(params=["couchdb", "json"], autouse=True) +def hardpy_opts(request): # noqa: ANN001 config_manager = ConfigManager() - config_data = config_manager.read_config( - Path(__file__).parent.resolve(), - ) - if not config_data: - msg = "Config not found" - raise RuntimeError(msg) - return [ - "--hardpy-clear-database", - "--hardpy-db-url", - config_data.database.url, - "--hardpy-pt", - ] + if request.param == "couchdb": + config_data = config_manager.read_config( + Path(__file__).parent.resolve(), + ) + if not config_data: + msg = "Config not found" + raise RuntimeError(msg) + + return [ + "--hardpy-clear-database", + "--hardpy-db-url", + config_data.database.url, + "--hardpy-pt", + ] + if request.param == "json": + config_data = config_manager.read_config( + Path(__file__).parent / "json_toml", + ) + return [ "--hardpy-clear-database", "--hardpy-pt"] + return None diff --git a/tests/test_plugin/hardpy.toml b/tests/test_plugin/hardpy.toml index c91e7d77..8f0c2ad0 100644 --- a/tests/test_plugin/hardpy.toml +++ b/tests/test_plugin/hardpy.toml @@ -1,6 +1,7 @@ title = "HardPy TOML config" [database] +storage_type = "couchdb" user = "dev" password = "dev" host = "localhost" diff --git a/tests/test_plugin/json_toml/hardpy.toml b/tests/test_plugin/json_toml/hardpy.toml new file mode 100644 index 00000000..86d5114b --- /dev/null +++ b/tests/test_plugin/json_toml/hardpy.toml @@ -0,0 +1,18 @@ +title = "HardPy TOML config" + +[database] +storage_type = "json" + +[frontend] +host = "localhost" +port = 8000 +language = "en" +full_size_button = false +sound_on = false +measurement_display = true +manual_collect = false + +[frontend.modal_result] +enable = false +auto_dismiss_pass = true +auto_dismiss_timeout = 5