Repository: hololinked-dev/hololinked
Pull request #76: resolve issue/69
Author: login: Marcusfam-RB
Created: 2025-04-22T23:02:15Z | Merged: 2025-04-25T14:13:38Z

Added ThingJsonStorage class in server/json_storage.py, which implements a file-based property storage using plain JSON. Extended the Thing class to support **use_json_file** and **json_filename** parameters. Added tests, similiar to regular db ones.


Diff:
diff --git a/hololinked/server/json_storage.py b/hololinked/server/json_storage.py
new file mode 100644
index 00000000..67bb6ef4
--- /dev/null
+++ b/hololinked/server/json_storage.py
@@ -0,0 +1,165 @@
+import os
+import threading
+from .serializers import JSONSerializer
+from typing import Any, Dict, List, Optional, Union
+from .property import Property
+from ..param import Parameterized
+
+
+class ThingJsonStorage:
+    """
+    JSON-based storage engine composed within ``Thing``. Carries out property operations such as storing and
+    retrieving values from a plain JSON file.
+
+    Parameters
+    ----------
+    filename : str
+        Path to the JSON file to use for storage.
+    instance : Parameterized
+        The ``Thing`` instance which uses this storage. Required to read default property values when
+        creating missing properties.
+    serializer : JSONSerializer, optional
+        Serializer used for encoding and decoding JSON data. Defaults to an instance of ``JSONSerializer``.
+    """
+    def __init__(self, filename: str, instance: Parameterized, serializer: Optional[Any]=None):
+        self.filename = filename
+        self.thing_instance = instance
+        self.instance_name = instance.instance_name
+        self._serializer = serializer or JSONSerializer()
+        self._lock = threading.RLock()
+        self._data = self._load()
+
+    def _load(self) -> Dict[str, Any]:
+        """
+        Load and decode data from the JSON file.
+
+        Returns
+        -------
+        value: dict
+        A dictionary of all stored properties. Empty if the file does not exist or cannot be decoded.
+        """
+        if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0:
+            return {}
+        try:
+            with open(self.filename, 'rb') as f:
+                raw_bytes = f.read()
+                if not raw_bytes:
+                    return {}
+                return self._serializer.loads(raw_bytes)
+        except Exception:
+            return {}
+
+    def _save(self):
+        """
+        Encode and write data to the JSON file.
+        """
+        raw_bytes = self._serializer.dumps(self._data)
+        with open(self.filename, 'wb') as f:
+            f.write(raw_bytes)
+
+    def get_property(self, property: Union[str, Property]) -> Any:
+        """
+        Fetch a single property.
+
+        Parameters
+        ----------
+        property: str | Property
+            string name or descriptor object
+
+        Returns
+        -------
+        value: Any
+            property value
+        """
+        name = property if isinstance(property, str) else property.name
+        if name not in self._data:
+            raise KeyError(f"property {name} not found in JSON storage")
+        with self._lock:
+            return self._data[name]
+
+    def set_property(self, property: Union[str, Property], value: Any) -> None:
+        """
+        change the value of an already existing property.
+
+        Parameters
+        ----------
+        property: str | Property
+            string name or descriptor object
+        value: Any
+            value of the property
+        """
+        name = property if isinstance(property, str) else property.name
+        with self._lock:
+            self._data[name] = value
+            self._save()
+
+    def get_properties(self, properties: Dict[Union[str, Property], Any]) -> Dict[str, Any]:
+        """
+        get multiple properties at once.
+
+        Parameters
+        ----------
+        properties: List[str | Property]
+            string names or the descriptor of the properties as a list
+
+        Returns
+        -------
+        value: Dict[str, Any]
+            property names and values as items
+        """
+        names = [key if isinstance(key, str) else key.name for key in properties.keys()]
+        with self._lock:
+            return {name: self._data.get(name) for name in names}
+
+    def set_properties(self, properties: Dict[Union[str, Property], Any]) -> None:
+        """
+        change the values of already existing few properties at once
+
+        Parameters
+        ----------
+        properties: Dict[str | Property, Any]
+            string names or the descriptor of the property and any value as dictionary pairs
+        """
+        with self._lock:
+            for obj, value in properties.items():
+                name = obj if isinstance(obj, str) else obj.name
+                self._data[name] = value
+            self._save()
+
+    def get_all_properties(self) -> Dict[str, Any]:
+        """
+        read all properties of the ``Thing`` instance.
+        """
+        with self._lock:
+            return dict(self._data)
+
+    def create_missing_properties(self, properties: Dict[str, Property],
+                                  get_missing_property_names: bool = False) -> Optional[List[str]]:
+        """
+        create any and all missing properties of ``Thing`` instance
+
+        Parameters
+        ----------
+        properties: Dict[str, Property]
+            descriptors of the properties
+
+        Returns
+        -------
+        missing_props: List[str]
+            list of missing properties if get_missing_property_names is True
+        """
+        missing_props = []
+        with self._lock:
+            existing_props = self.get_all_properties()
+            for name, new_prop in properties.items():
+                if name not in existing_props:
+                    self._data[name] = getattr(self.thing_instance, new_prop.name)
+                    missing_props.append(name)
+            self._save()
+        if get_missing_property_names:
+            return missing_props
+
+
+__all__ = [
+    ThingJsonStorage.__name__,
+]
diff --git a/hololinked/server/thing.py b/hololinked/server/thing.py
index 7edbdc33..2c6e83a8 100644
--- a/hololinked/server/thing.py
+++ b/hololinked/server/thing.py
@@ -1,6 +1,7 @@
-import logging 
+import logging
 import inspect
 import os
+import re
 import ssl
 import typing
 import warnings
@@ -21,6 +22,7 @@
 from .zmq_message_brokers import RPCServer, ServerTypes, EventPublisher
 from .state_machine import StateMachine
 from .events import Event
+from .json_storage import ThingJsonStorage
 
 
 
@@ -34,13 +36,13 @@ class ThingMeta(ParameterizedMetaclass):
     are also loaded from database at this time. One can overload ``__post_init__()`` for any operations that rely on properties
     values loaded from database.
     """
-    
+
     @classmethod
     def __prepare__(cls, name, bases):
         return TypedKeyMappingsConstrainedDict({},
             type_mapping = dict(
                 state_machine = (StateMachine, type(None)),
-                instance_name = String, 
+                instance_name = String,
                 log_level = Selector,
                 logger = ClassSelector,
                 logfile = String,
@@ -52,13 +54,13 @@ def __prepare__(cls, name, bases):
 
     def __new__(cls, __name, __bases, __dict : TypedKeyMappingsConstrainedDict):
         return super().__new__(cls, __name, __bases, __dict._inner)
-    
+
     def __call__(mcls, *args, **kwargs):
         instance = super().__call__(*args, **kwargs)
 
         instance.__post_init__()
         return instance
-    
+
     def _create_param_container(mcs, mcs_members : dict) -> None:
         """
         creates ``ClassProperties`` instead of ``param``'s own ``Parameters`` 
@@ -83,18 +85,18 @@ class Thing(Parameterized, metaclass=ThingMeta):
     """
 
     __server_type__ = ServerTypes.THING # not a server, this needs to be removed.
-   
+
     # local properties
     instance_name = String(default=None, regex=r'[A-Za-z]+[A-Za-z_0-9\-\/]*', constant=True, remote=False,
                         doc="""Unique string identifier of the instance. This value is used for many operations,
                         for example - creating zmq socket address, tables in databases, and to identify the instance 
                         in the HTTP Server - (http(s)://{domain and sub domain}/{instance name}). 
                         If creating a big system, instance names are recommended to be unique.""") # type: str
-    logger = ClassSelector(class_=logging.Logger, default=None, allow_None=True, remote=False, 
+    logger = ClassSelector(class_=logging.Logger, default=None, allow_None=True, remote=False,
                         doc="""logging.Logger instance to print log messages. Default 
                             logger with a IO-stream handler and network accessible handler is created 
                             if none supplied.""") # type: logging.Logger
-    zmq_serializer = ClassSelector(class_=(BaseSerializer, str), 
+    zmq_serializer = ClassSelector(class_=(BaseSerializer, str),
                         allow_None=True, default='json', remote=False,
                         doc="""Serializer used for exchanging messages with python RPC clients. Subclass the base serializer 
                         or one of the available serializers to implement your own serialization requirements; or, register 
@@ -104,31 +106,31 @@ class Thing(Parameterized, metaclass=ThingMeta):
                         doc="""Serializer used for exchanging messages with a HTTP clients,
                             subclass JSONSerializer to implement your own JSON serialization requirements; or, 
                             register type replacements. Other types of serializers are currently not allowed for HTTP clients.""") # type: JSONSerializer
-    schema_validator = ClassSelector(class_=BaseSchemaValidator, default=JsonSchemaValidator, allow_None=True, 
+    schema_validator = ClassSelector(class_=BaseSchemaValidator, default=JsonSchemaValidator, allow_None=True,
                         remote=False, isinstance=False,
                         doc="""Validator for JSON schema. If not supplied, a default JSON schema validator is created.""") # type: BaseSchemaValidator
-    
+
     # remote properties
-    state = String(default=None, allow_None=True, URL_path='/state', readonly=True, observable=True, 
-                fget=lambda self : self.state_machine.current_state if hasattr(self, 'state_machine') else None,  
+    state = String(default=None, allow_None=True, URL_path='/state', readonly=True, observable=True,
+                fget=lambda self : self.state_machine.current_state if hasattr(self, 'state_machine') else None,
                 doc="current state machine's state if state machine present, None indicates absence of state machine.") #type: typing.Optional[str]
-    httpserver_resources = Property(readonly=True, URL_path='/resources/http-server', 
-                        doc="object's resources exposed to HTTP client (through ``hololinked.server.HTTPServer.HTTPServer``)", 
+    httpserver_resources = Property(readonly=True, URL_path='/resources/http-server',
+                        doc="object's resources exposed to HTTP client (through ``hololinked.server.HTTPServer.HTTPServer``)",
                         fget=lambda self: self._httpserver_resources ) # type: typing.Dict[str, HTTPResource]
-    zmq_resources = Property(readonly=True, URL_path='/resources/zmq-object-proxy', 
-                        doc="object's resources exposed to RPC client, similar to HTTP resources but differs in details.", 
+    zmq_resources = Property(readonly=True, URL_path='/resources/zmq-object-proxy',
+                        doc="object's resources exposed to RPC client, similar to HTTP resources but differs in details.",
                         fget=lambda self: self._zmq_resources) # type: typing.Dict[str, ZMQResource]
-    gui_resources = Property(readonly=True, URL_path='/resources/portal-app', 
+    gui_resources = Property(readonly=True, URL_path='/resources/portal-app',
                         doc="""object's data read by hololinked-portal GUI client, similar to http_resources but differs 
                         in details.""",
                         fget=lambda self: build_our_temp_TD(self)) # type: typing.Dict[str, typing.Any]
     GUI = Property(default=None, allow_None=True, URL_path='/resources/web-gui', fget = lambda self : self._gui,
-                        doc="GUI specified here will become visible at GUI tab of hololinked-portal dashboard tool")     
+                        doc="GUI specified here will become visible at GUI tab of hololinked-portal dashboard tool")
     object_info = Property(doc="contains information about this object like the class name, script location etc.",
                         URL_path='/object-info') # type: ThingInformation
-    
 
-    def __init__(self, *, instance_name : str, logger : typing.Optional[logging.Logger] = None, 
+
+    def __init__(self, *, instance_name : str, logger : typing.Optional[logging.Logger] = None,
                 serializer : typing.Optional[JSONSerializer] = None, **kwargs) -> None:
         """
         Parameters
@@ -165,13 +167,19 @@ class attribute, see docs.
                 schema validator class for JSON schema validation, not supported by ZMQ clients. 
             db_config_file: str, optional
                 if not using a default database, supply a JSON configuration file to create a connection. Check documentaion
-                of ``hololinked.server.database``.  
+                of ``hololinked.server.database``.
+            use_json_file: bool, Default False
+                if True, a JSON file will be used as the property storage instead of a database. This value can also be
+                set as a class attribute.
+            json_filename: str, optional
+                If using JSON storage, this filename is used to persist property values. If not provided, a default filename
+                is generated based on the instance name.
 
         """
         if instance_name.startswith('/'):
             instance_name = instance_name[1:]
         # Type definitions
-        self._owner : typing.Optional[Thing] = None 
+        self._owner : typing.Optional[Thing] = None
         self._internal_fixed_attributes : typing.List[str]
         self._full_URL_path_prefix : str
         self._gui = None # filler for a future feature
@@ -188,17 +196,23 @@ class attribute, see docs.
                                                                     zmq_serializer=zmq_serializer,
                                                                     http_serializer=http_serializer
                                                                 )
-        super().__init__(instance_name=instance_name, logger=logger, 
+        super().__init__(instance_name=instance_name, logger=logger,
                         zmq_serializer=zmq_serializer, http_serializer=http_serializer, **kwargs)
 
         self._prepare_logger(
-                    log_level=kwargs.get('log_level', None), 
+                    log_level=kwargs.get('log_level', None),
                     log_file=kwargs.get('log_file', None),
                     remote_access=kwargs.get('logger_remote_access', self.__class__.logger_remote_access if hasattr(
                                                                 self.__class__, 'logger_remote_access') else False)
                 )
-        self._prepare_state_machine()  
-        self._prepare_DB(kwargs.get('use_default_db', False), kwargs.get('db_config_file', None))   
+        self._prepare_state_machine()
+
+        # choose storage type, if use_json_file is True - use JSON storage, else - use database
+        if kwargs.get('use_json_file',
+                      self.__class__.use_json_file if hasattr(self.__class__, 'use_json_file') else False):
+            self._prepare_json_storage(filename=kwargs.get('json_filename', f"{self._prepare_json_filename()}"))
+        else:
+            self._prepare_DB(kwargs.get('use_default_db', False), kwargs.get('db_config_file', None))
 
 
     def __post_init__(self):
@@ -219,22 +233,22 @@ def _prepare_resources(self):
     def _prepare_logger(self, log_level : int, log_file : str, remote_access : bool = False):
         from .logger import RemoteAccessHandler
         if self.logger is None:
-            self.logger = get_default_logger(self.instance_name, 
-                                    logging.INFO if not log_level else log_level, 
+            self.logger = get_default_logger(self.instance_name,
+                                    logging.INFO if not log_level else log_level,
                                     None if not log_file else log_file)
         if remote_access:
             if not any(isinstance(handler, RemoteAccessHandler) for handler in self.logger.handlers):
-                self._remote_access_loghandler = RemoteAccessHandler(instance_name='logger', 
-                                                    maxlen=500, emit_interval=1, logger=self.logger) 
+                self._remote_access_loghandler = RemoteAccessHandler(instance_name='logger',
+                                                    maxlen=500, emit_interval=1, logger=self.logger)
                                                     # thing has its own logger so we dont recreate one for
                                                     # remote access handler
                 self.logger.addHandler(self._remote_access_loghandler)
-        
+
         if not isinstance(self, logging.Logger):
             for handler in self.logger.handlers:
                 # if remote access is True or not, if a default handler is found make a variable for it anyway
                 if isinstance(handler, RemoteAccessHandler):
-                    self._remote_access_loghandler = handler        
+                    self._remote_access_loghandler = handler
 
 
     def _prepare_state_machine(self):
@@ -242,48 +256,66 @@ def _prepare_state_machine(self):
             self.state_machine._prepare(self)
             self.logger.debug("setup state machine")
 
-    
+
     def _prepare_DB(self, default_db : bool = False, config_file : str = None):
-        if not default_db and not config_file: 
+        if not default_db and not config_file:
             self.object_info
-            return 
-        # 1. create engine 
-        self.db_engine = ThingDB(instance=self, config_file=None if default_db else config_file, 
-                                    serializer=self.zmq_serializer) # type: ThingDB 
+            return
+        # 1. create engine
+        self.db_engine = ThingDB(instance=self, config_file=None if default_db else config_file,
+                                    serializer=self.zmq_serializer) # type: ThingDB
         # 2. create an object metadata to be used by different types of clients
         object_info = self.db_engine.fetch_own_info()
         if object_info is not None:
             self._object_info = object_info
         # 3. enter properties to DB if not already present 
         if self.object_info.class_name != self.__class__.__name__:
-            raise ValueError("Fetched instance name and class name from database not matching with the ", 
-                "current Thing class/subclass. You might be reusing an instance name of another subclass ", 
-                "and did not remove the old data from database. Please clean the database using database tools to ", 
+            raise ValueError("Fetched instance name and class name from database not matching with the ",
+                "current Thing class/subclass. You might be reusing an instance name of another subclass ",
+                "and did not remove the old data from database. Please clean the database using database tools to ",
                 "start fresh.")
 
+    def _prepare_json_storage(self, filename: str = None):
+        if not filename:
+            filename = f"{self._prepare_json_filename()}"
+        self.db_engine = ThingJsonStorage(filename=filename, instance=self)
+
+    def _prepare_json_filename(self):
+        class_name = self.__class__.__name__
+
+        # Remove invalid characters from the instance name
+        safe_instance_name = re.sub(r'[<>:"/\\|?*\x00-\x1F]+', '_', self.instance_name)
+        # Collapse consecutive underscores into one
+        safe_instance_name = re.sub(r'_+', '_', safe_instance_name)
+        # Remove leading and trailing underscores
+        safe_instance_name = safe_instance_name.strip('_')
+
+        filename = f"{class_name}-{safe_instance_name or '_'}.json"
+        return filename
+
 
     @object_info.getter
     def _get_object_info(self):
         if not hasattr(self, '_object_info'):
             self._object_info = ThingInformation(
-                    instance_name  = self.instance_name, 
+                    instance_name  = self.instance_name,
                     class_name     = self.__class__.__name__,
                     script         = os.path.dirname(os.path.abspath(inspect.getfile(self.__class__))),
-                    http_server    = "USER_MANAGED", 
-                    kwargs         = "USER_MANAGED",  
-                    eventloop_instance_name = "USER_MANAGED", 
-                    level          = "USER_MANAGED", 
+                    http_server    = "USER_MANAGED",
+                    kwargs         = "USER_MANAGED",
+                    eventloop_instance_name = "USER_MANAGED",
+                    level          = "USER_MANAGED",
                     level_type     = "USER_MANAGED"
-                )  
+                )
         return self._object_info
-    
+
     @object_info.setter
     def _set_object_info(self, value):
-        self._object_info = ThingInformation(**value)  
+        self._object_info = ThingInformation(**value)
         for name, thing in inspect._getmembers(self, lambda o: isinstance(o, Thing), getattr_without_descriptor_read):
             thing._object_info.http_server = self._object_info.http_server
-           
-    
+
+
     @property
     def properties(self) -> ClassProperties:
         """container for the property descriptors of the object."""
@@ -296,7 +328,7 @@ def _get_properties(self, **kwargs) -> typing.Dict[str, typing.Any]:
         skip_props = ["httpserver_resources", "zmq_resources", "gui_resources", "GUI", "object_info"]
         for prop_name in skip_props:
             if prop_name in kwargs:
-                raise RuntimeError("GUI, httpserver resources, RPC resources , object info etc. cannot be queried" + 
+                raise RuntimeError("GUI, httpserver resources, RPC resources , object info etc. cannot be queried" +
                                   " using multiple property fetch.")
         data = {}
         if len(kwargs) == 0:
@@ -322,9 +354,9 @@ def _get_properties(self, **kwargs) -> typing.Dict[str, typing.Any]:
             for rename, requested_prop in kwargs.items():
                 if not isinstance(self.properties[requested_prop], Property) or self.properties[requested_prop]._remote_info is None:
                     raise AttributeError("this property is not remote accessible")
-                data[rename] = self.properties[requested_prop].__get__(self, type(self))                   
-        return data 
-    
+                data[rename] = self.properties[requested_prop].__get__(self, type(self))
+        return data
+
     @action(URL_path='/properties', http_method=[HTTP_METHODS.PUT, HTTP_METHODS.PATCH])
     def _set_properties(self, **values : typing.Dict[str, typing.Any]) -> None:
         """ 
@@ -345,12 +377,12 @@ def _set_properties(self, **values : typing.Dict[str, typing.Any]) -> None:
                 errors += f'{name} : {str(ex)}\n'
                 produced_error = True
         if produced_error:
-            ex = RuntimeError("Some properties could not be set due to errors. " + 
+            ex = RuntimeError("Some properties could not be set due to errors. " +
                             "Check exception notes or server logs for more information.")
             ex.__notes__ = errors
             raise ex from None
 
-    @action(URL_path='/properties/db', http_method=HTTP_METHODS.GET)     
+    @action(URL_path='/properties/db', http_method=HTTP_METHODS.GET)
     def _get_properties_in_db(self) -> typing.Dict[str, JSONSerializable]:
         """
         get all properties in the database
@@ -396,27 +428,27 @@ def event_publisher(self) -> EventPublisher:
         event publishing PUB socket owning object, valid only after 
         ``run()`` is called, otherwise raises AttributeError.
         """
-        return self._event_publisher 
-                   
+        return self._event_publisher
+
     @event_publisher.setter
     def event_publisher(self, value : EventPublisher) -> None:
         if self._event_publisher is not None:
             if value is not self._event_publisher:
                 raise AttributeError("Can set event publisher only once")
-            
+
         def recusively_set_event_publisher(obj : Thing, publisher : EventPublisher) -> None:
             for name, evt in inspect._getmembers(obj, lambda o: isinstance(o, Event), getattr_without_descriptor_read):
                 assert isinstance(evt, Event), "object is not an event"
                 # above is type definition
-                e = evt.__get__(obj, type(obj)) 
-                e.publisher = publisher 
+                e = evt.__get__(obj, type(obj))
+                e.publisher = publisher
                 e._remote_info.socket_address = publisher.socket_address
                 self.logger.info(f"registered event '{evt.friendly_name}' serving at PUB socket with address : {publisher.socket_address}")
             for name, subobj in inspect._getmembers(obj, lambda o: isinstance(o, Thing), getattr_without_descriptor_read):
                 if name == '_owner':
-                    continue 
+                    continue
                 recusively_set_event_publisher(subobj, publisher)
-            obj._event_publisher = publisher            
+            obj._event_publisher = publisher
 
         recusively_set_event_publisher(self, value)
 
@@ -441,20 +473,20 @@ def load_properties_from_DB(self):
                 except Exception as ex:
                     self.logger.error(f"could not set attribute {db_prop} due to error {str(ex)}")
 
-        
+
     @action(URL_path='/resources/postman-collection', http_method=HTTP_METHODS.GET)
     def get_postman_collection(self, domain_prefix : str = None):
         """
         organised postman collection for this object
         """
         from .api_platforms import postman_collection
-        return postman_collection.build(instance=self, 
+        return postman_collection.build(instance=self,
                     domain_prefix=domain_prefix if domain_prefix is not None else self._object_info.http_server)
-    
+
 
     @action(URL_path='/resources/wot-td', http_method=HTTP_METHODS.GET)
-    def get_thing_description(self, authority : typing.Optional[str] = None, ignore_errors : bool = False): 
-                            # allow_loose_schema : typing.Optional[bool] = False): 
+    def get_thing_description(self, authority : typing.Optional[str] = None, ignore_errors : bool = False):
+                            # allow_loose_schema : typing.Optional[bool] = False):
         """
         generate thing description schema of Web of Things https://www.w3.org/TR/wot-thing-description11/.
         one can use the node-wot as a client for the object with the generated schema 
@@ -482,10 +514,10 @@ def get_thing_description(self, authority : typing.Optional[str] = None, ignore_
         #     In other words, schema validation will always pass.  
         from .td import ThingDescription
         return ThingDescription(instance=self, authority=authority or self._object_info.http_server,
-                                    allow_loose_schema=False, ignore_errors=ignore_errors).produce() #allow_loose_schema)   
+                                    allow_loose_schema=False, ignore_errors=ignore_errors).produce() #allow_loose_schema)
 
 
-    @action(URL_path='/exit', http_method=HTTP_METHODS.POST)                                                                                                                                          
+    @action(URL_path='/exit', http_method=HTTP_METHODS.POST)
     def exit(self) -> None:
         """
         Exit the object without killing the eventloop that runs this object. If Thing was 
@@ -493,23 +525,23 @@ def exit(self) -> None:
         only be called remotely.
         """
         if self.rpc_server is None:
-            return 
+            return
         if self._owner is None:
             self.rpc_server.stop_polling()
             raise BreakInnerLoop # stops the inner loop of the object
         else:
             warnings.warn("call exit on the top object, composed objects cannot exit the loop.", RuntimeWarning)
-    
+
     @action()
     def ping(self) -> None:
         """ping the Thing to see if it is alive"""
-        pass 
+        pass
 
-    def run(self, 
-            zmq_protocols : typing.Union[typing.Sequence[ZMQ_PROTOCOLS], 
-                                         ZMQ_PROTOCOLS] = ZMQ_PROTOCOLS.IPC, 
+    def run(self,
+            zmq_protocols : typing.Union[typing.Sequence[ZMQ_PROTOCOLS],
+                                         ZMQ_PROTOCOLS] = ZMQ_PROTOCOLS.IPC,
             # expose_eventloop : bool = False,
-            **kwargs 
+            **kwargs
         ) -> None:
         """
         Quick-start ``Thing`` server by creating a default eventloop & ZMQ servers. This 
@@ -545,28 +577,28 @@ def run(self,
         context = context or zmq.asyncio.Context()
 
         self.rpc_server = RPCServer(
-                                instance_name=self.instance_name, 
-                                server_type=self.__server_type__.value, 
-                                context=context, 
-                                protocols=zmq_protocols, 
-                                zmq_serializer=self.zmq_serializer, 
-                                http_serializer=self.http_serializer, 
+                                instance_name=self.instance_name,
+                                server_type=self.__server_type__.value,
+                                context=context,
+                                protocols=zmq_protocols,
+                                zmq_serializer=self.zmq_serializer,
+                                http_serializer=self.http_serializer,
                                 tcp_socket_address=kwargs.get('tcp_socket_address', None),
                                 logger=self.logger
-                            ) 
+                            )
         self.message_broker = self.rpc_server.inner_inproc_server
-        self.event_publisher = self.rpc_server.event_publisher 
+        self.event_publisher = self.rpc_server.event_publisher
 
         from .eventloop import EventLoop
         self.event_loop = EventLoop(
-                    instance_name=f'{self.instance_name}/eventloop', 
-                    things=[self], 
+                    instance_name=f'{self.instance_name}/eventloop',
+                    things=[self],
                     logger=self.logger,
-                    zmq_serializer=self.zmq_serializer, 
-                    http_serializer=self.http_serializer, 
+                    zmq_serializer=self.zmq_serializer,
+                    http_serializer=self.http_serializer,
                     expose=False, # expose_eventloop
                 )
-        
+
         if kwargs.get('http_server', None):
             from .HTTPServer import HTTPServer
             httpserver = kwargs.pop('http_server')
@@ -579,11 +611,11 @@ def run(self,
         self.event_loop.run()
 
 
-    def run_with_http_server(self, port : int = 8080, address : str = '0.0.0.0', 
-                # host : str = None, 
-                allowed_clients : typing.Union[str, typing.Iterable[str]] = None,   
-                ssl_context : ssl.SSLContext = None, # protocol_version : int = 1, 
-                # network_interface : str = 'Ethernet', 
+    def run_with_http_server(self, port : int = 8080, address : str = '0.0.0.0',
+                # host : str = None,
+                allowed_clients : typing.Union[str, typing.Iterable[str]] = None,
+                ssl_context : ssl.SSLContext = None, # protocol_version : int = 1,
+                # network_interface : str = 'Ethernet',
                 **kwargs):
         """
         Quick-start ``Thing`` server by creating a default eventloop & servers. This 
@@ -615,17 +647,17 @@ def run_with_http_server(self, port : int = 8080, address : str = '0.0.0.0',
         #     send the network interface name to retrieve the IP. If a DNS server is present, you may leave this field
         # host: str
         #     Host Server to subscribe to coordinate starting sequence of things & web GUI
-        
+
         from .HTTPServer import HTTPServer
-        
+
         http_server = HTTPServer(
-            [self.instance_name], logger=self.logger, serializer=self.http_serializer, 
+            [self.instance_name], logger=self.logger, serializer=self.http_serializer,
             port=port, address=address, ssl_context=ssl_context,
             allowed_clients=allowed_clients, schema_validator=self.schema_validator,
             # network_interface=network_interface, 
             **kwargs,
         )
-        
+
         self.run(
             zmq_protocols=ZMQ_PROTOCOLS.INPROC,
             http_server=http_server,
@@ -634,6 +666,6 @@ def run_with_http_server(self, port : int = 8080, address : str = '0.0.0.0',
 
         http_server.tornado_instance.stop()
 
-       
+
 
 
diff --git a/tests/test_property.py b/tests/test_property.py
index d17fa8a4..45e03a96 100644
--- a/tests/test_property.py
+++ b/tests/test_property.py
@@ -1,4 +1,5 @@
 import logging, unittest, time, os
+import tempfile
 import pydantic_core
 from pydantic import BaseModel
 from hololinked.client import ObjectProxy
@@ -276,6 +277,50 @@ def test_6_pydantic_model_property(self):
         self.assertTrue("validation error for 'int'" in str(ex.exception))
 
 
+    def test_7_json_db_operations(self):
+        with tempfile.NamedTemporaryFile(delete=False) as tf:
+            filename = tf.name
+
+        # test db commit property
+        thing = TestThing(instance_name="test-db-operations", use_json_file=True,
+                          json_filename=filename, log_level=logging.WARN)
+        self.assertEqual(thing.db_commit_number_prop, 0)
+        thing.db_commit_number_prop = 100
+        self.assertEqual(thing.db_commit_number_prop, 100)
+        self.assertEqual(thing.db_engine.get_property('db_commit_number_prop'), 100)
+
+        # test db persist property
+        self.assertEqual(thing.db_persist_selector_prop, 'a')
+        thing.db_persist_selector_prop = 'c'
+        self.assertEqual(thing.db_persist_selector_prop, 'c')
+        self.assertEqual(thing.db_engine.get_property('db_persist_selector_prop'), 'c')
+
+        # test db init property
+        self.assertEqual(thing.db_init_int_prop, 1)
+        thing.db_init_int_prop = 50
+        self.assertEqual(thing.db_init_int_prop, 50)
+        self.assertNotEqual(thing.db_engine.get_property('db_init_int_prop'), 50)
+        self.assertEqual(thing.db_engine.get_property('db_init_int_prop'), TestThing.db_init_int_prop.default)
+        del thing
+
+        # delete thing and reload from database
+        thing = TestThing(instance_name="test-db-operations", use_json_file=True,
+                          json_filename=filename, log_level=logging.WARN)
+        self.assertEqual(thing.db_init_int_prop, TestThing.db_init_int_prop.default)
+        self.assertEqual(thing.db_persist_selector_prop, 'c')
+        self.assertNotEqual(thing.db_commit_number_prop, 100)
+        self.assertEqual(thing.db_commit_number_prop, TestThing.db_commit_number_prop.default)
+
+        # check db init prop with a different value in database apart from default
+        thing.db_engine.set_property('db_init_int_prop', 101)
+        del thing
+        thing = TestThing(instance_name="test-db-operations", use_json_file=True,
+                          json_filename=filename, log_level=logging.WARN)
+        self.assertEqual(thing.db_init_int_prop, 101)
+
+        os.remove(filename)
+
+
 
 class TestClassPropertyThing(Thing):
     # Simple class property with default value


Reviews:
user: login: VigneshVSV
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T07:27:51Z
user: login: VigneshVSV
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T07:33:41Z
user: login: VigneshVSV
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T07:47:47Z
user: login: Marcusfam-RB
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:49:31Z
user: login: Marcusfam-RB
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:52:12Z
user: login: Marcusfam-RB
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:58:18Z

Review comments:
diff_hunk: @@ -0,0 +1,75 @@
+import os
+import json
path: hololinked/server/json_storage.py
user: login: VigneshVSV
body: There is a seriliazer implementation from [`msgspec`](https://jcristharif.com/msgspec/) which you will find in [serializers file](https://github.com/hololinked-dev/hololinked/blob/main/hololinked/server/serializers.py). If this JSON implementation can be replaced with that, That is best!

Optional. But I will do it later anyway. 
created_at: 2025-04-24T07:27:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,75 @@
+import os
+import json
+import threading
+from typing import Any, Dict, List, Optional, Union
+from .property import Property
+from ..param import Parameterized
+
+
+class ThingJsonStorage:
+    def __init__(self, filename: str, instance: Parameterized):
path: hololinked/server/json_storage.py
user: login: VigneshVSV
body: Some class doc would be nice. We follow numpy doc style. 

It will later go into API reference, [something like this docs for the new version](https://docs.hololinked.dev/api-reference/thing/) 

Also docs for the individual methods would be nice. You can also copy as much as possible from the database API. 

Optional. I can also add these both later. 

created_at: 2025-04-24T07:33:41Z
author_association: COLLABORATOR
diff_hunk: @@ -261,6 +274,11 @@ def _prepare_DB(self, default_db : bool = False, config_file : str = None):
                 "and did not remove the old data from database. Please clean the database using database tools to ", 
                 "start fresh.")
 
+    def _prepare_json_storage(self, filename: str = None):
+        if not filename:
+            filename = f"{self.instance_name}.json"
path: hololinked/server/thing.py
user: login: VigneshVSV
body: The `instance_name` follows URL format. So people can technically add slashes. For example `my_name/my_sub_name`. This will become `my_name/my_sub_name.json`. I am not sure if this accepted by the OS, as slashes usually mean directories.

It would be nice to have a quick conversion from URL format to filename format. 

Further, using the `Thing` class' name would be good. This is because if there are multiple `Thing`s defined in the same file or folder, the JSON files would be sorted. 

Something like `f"{self.__class__.name__}-{self.instance_name}.json"`



created_at: 2025-04-24T07:47:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,75 @@
+import os
+import json
path: hololinked/server/json_storage.py
user: login: Marcusfam-RB
body: I replaced it with `msgspec` one. Looks fine, at least it passed the tests i made.
created_at: 2025-04-24T22:49:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+import os
+import json
+import threading
+from typing import Any, Dict, List, Optional, Union
+from .property import Property
+from ..param import Parameterized
+
+
+class ThingJsonStorage:
+    def __init__(self, filename: str, instance: Parameterized):
path: hololinked/server/json_storage.py
user: login: Marcusfam-RB
body: I added documentation for `ThingJsonStorage` class and it's methods, copied almost everything from the database API docs
created_at: 2025-04-24T22:52:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -261,6 +274,11 @@ def _prepare_DB(self, default_db : bool = False, config_file : str = None):
                 "and did not remove the old data from database. Please clean the database using database tools to ", 
                 "start fresh.")
 
+    def _prepare_json_storage(self, filename: str = None):
+        if not filename:
+            filename = f"{self.instance_name}.json"
path: hololinked/server/thing.py
user: login: Marcusfam-RB
body: Added a new method in Thing class - `_prepare_json_filename`. It replaces any filesystem-unsafe characters, like < > : " / \ | ? * with underscores ( _ ).  So the final name looks like this `f"{self.__class__.name__}-{self.safe_instance_name}.json"`


created_at: 2025-04-24T22:58:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: Marcusfam-RB
created_at: 2025-04-22T23:04:41Z
body: If something's wrong, or need to be changed, let me know! 
author_association: CONTRIBUTOR
user: login: VigneshVSV
created_at: 2025-04-23T06:03:28Z
body: Hi @Marcusfam-RB ,

Cool!! I will get to it today or latest by the weekend. 
author_association: COLLABORATOR
user: login: VigneshVSV
created_at: 2025-04-24T07:53:53Z
body: Hi @Marcusfam-RB ,

I made some minor comments. 

But its looking really good & the tests also passed!

Vignesh
author_association: COLLABORATOR
user: login: VigneshVSV
created_at: 2025-04-24T08:36:16Z
body: If you are interested, there are also other good first issues at varying levels of difficulty repository wise: https://github.com/hololinked-dev/#good-first-issues

There is a discord group where I am available for informal chit-chat! Only 3 people are there now. 


author_association: COLLABORATOR
user: login: VigneshVSV
created_at: 2025-04-24T09:18:35Z
body: Also, could you add this to CHANGELOG.md so that I can publish it to pypi? 

You can set the version in setup.py to 0.2.11 and in `hololinked.__init__.py` to the same. 


author_association: COLLABORATOR
user: login: VigneshVSV
created_at: 2025-04-25T14:12:44Z
body: Fantastic, I am merging it. thanks a lot! Feel free to pitch in to other issues, links that I have posted before. 
author_association: COLLABORATOR
user: login: Marcusfam-RB
created_at: 2025-04-25T14:49:50Z
body: It was a pleasure working with you! I'll look through another issues in my free time, maybe something will catch my eye
author_association: CONTRIBUTOR