Package: pydase
pydase (Python Data Service)pydaseis a Python library for creating data service servers with integrated web and RPC servers. It's designed to handle the management of data structures, automated tasks, and callbacks, and provides built-in functionality for serving data over different protocols.FeaturesInstallationUsageDefining a DataServiceRunning the ServerAccessing the Web InterfaceConnecting to the Service using rpycUnderstanding the Component SystemBuilt-in Type and Enum ComponentsMethod ComponentsDataService Instances (Nested Classes)Custom Components (pydase.components)DeviceConnectionImageNumberSliderColouredEnumExtending with New ComponentsUnderstanding Service PersistenceControlling Property State Loading with@load_stateUnderstanding Tasks in pydaseUnderstanding Units in pydaseConfiguring pydase via Environment VariablesCustomizing the Web InterfaceEnhancing the Web Interface Style with Custom CSSTailoring Frontend Component LayoutLogging in pydaseChanging the Log LevelDocumentationContributingLicenseFeaturesSimple data service definition through class-based interfaceIntegrated web interface for interactive access and control of your data serviceSupport forrpycconnections, allowing for programmatic control and interaction with your serviceComponent system bridging Python backend with frontend visual representationCustomizable styling for the web interface through user-defined CSSSaving and restoring the service state for service persistenceAutomated task management with built-in start/stop controls and optional autostartSupport for unitsInstallationInstallpydaseusingpoetry:poetryaddpydaseorpip:pipinstallpydaseUsageUsingpydaseinvolves three main steps: defining aDataServicesubclass, running the server, and then connecting to the service either programmatically usingrpycor through the web interface.Defining a DataServiceTo use pydase, you'll first need to create a class that inherits fromDataService. This class represents your custom data service, which will be exposed via RPC (using rpyc) and a web server. Your class can implement class / instance attributes and synchronous and asynchronous tasks.Here's an example:frompydaseimportDataService,Serverfrompydase.utils.decoratorsimportfrontendclassDevice(DataService):_current=0.0_voltage=0.0_power=False@propertydefcurrent(self)->float:# run code to get currentreturnself._current@current.setterdefcurrent(self,value:float)->None:# run code to set currentself._current=value@propertydefvoltage(self)->float:# run code to get voltagereturnself._voltage@voltage.setterdefvoltage(self,value:float)->None:# run code to set voltageself._voltage=value@propertydefpower(self)->bool:# run code to get power statereturnself._power@power.setterdefpower(self,value:bool)->None:# run code to set power stateself._power=value@frontenddefreset(self)->None:self.current=0.0self.voltage=0.0if__name__=="__main__":service=Device()Server(service).run()In the above example, we define a Device class that extends DataService. We define a few properties (current, voltage, power) and their getter and setter methods.Running the ServerOnce your DataService is defined, you can create an instance of it and run the server:frompydaseimportServer# ... defining the Device class ...if__name__=="__main__":service=Device()Server(service).run()This will start the server, making your Device service accessible via RPC and a web server athttp://localhost:8001.Accessing the Web InterfaceOnce the server is running, you can access the web interface in a browser:In this interface, you can interact with the properties of yourDeviceservice.Connecting to the Service using rpycYou can also connect to the service usingrpyc. Here's an example on how to establish a connection and interact with the service:importrpyc# Connect to the serviceconn=rpyc.connect("<ip_addr>",18871)client=conn.root# Interact with the serviceclient.voltage=5.0print(client.voltage)# prints 5.0In this example, replace<ip_addr>with the IP address of the machine where the service is running. After establishing a connection, you can interact with the service attributes as if they were local attributes.Understanding the Component SystemInpydase, components are fundamental building blocks that bridge the Python backend logic with frontend visual representation and interactions. This system can be understood based on the following categories:Built-in Type and Enum Componentspydaseautomatically maps standard Python data types to their corresponding frontend components:str: Translated into aStringComponenton the frontend.intandfloat: Manifested as theNumberComponent.bool: Rendered as aButtonComponent.list: Each item displayed individually, named after the list attribute and its index.enum.Enum: Presented as anEnumComponent, facilitating dropdown selection.Method ComponentsWithin theDataServiceclass ofpydase, only methods devoid of arguments can be represented in the frontend, classified into two distinct categoriesTasks: Argument-free asynchronous functions, identified withinpydaseas tasks, are inherently designed for frontend interaction. These tasks are automatically rendered with a start/stop button, allowing users to initiate or halt the task execution directly from the web interface.Synchronous Methods with@frontendDecorator: Synchronous methods without arguments can also be presented in the frontend. For this, they have to be decorated with the@frontenddecorator.importpydaseimportpydase.componentsimportpydase.unitsasufrompydase.utils.decoratorsimportfrontendclassMyService(pydase.DataService):@frontenddefexposed_method(self)->None:...asyncdefmy_task(self)->None:whileTrue:# ...You can still define synchronous tasks with arguments and call them using a python client. However, decorating them with the@frontenddecorator will raise aFunctionDefinitionError. Defining a task with arguments will raise aTaskDefinitionError.
I decided against supporting function arguments for functions rendered in the frontend due to the following reasons:Feature Request Pitfall: supporting function arguments create a bottomless pit of feature requests. As users encounter the limitations of supported types, demands for extending support to more complex types would grow.Complexity in Supported Argument Types: while simple types likeint,float,boolandstrcould be easily supported, more complicated types are not (representation, (de-)serialization).DataService Instances (Nested Classes)NestedDataServiceinstances offer an organized hierarchy for components, enabling richer applications. Each nested class might have its own attributes and methods, each mapped to a frontend component.Here is an example:frompydaseimportDataService,ServerclassChannel(DataService):def__init__(self,channel_id:int)->None:super().__init__()self._channel_id=channel_idself._current=0.0@propertydefcurrent(self)->float:# run code to get currentresult=self._currentreturnresult@current.setterdefcurrent(self,value:float)->None:# run code to set currentself._current=valueclassDevice(DataService):def__init__(self)->None:super().__init__()self.channels=[Channel(i)foriinrange(2)]if__name__=="__main__":service=Device()Server(service).run()Notethat defining classes withinDataServiceclasses is not supported (seethis issue).Custom Components (pydase.components)The custom components inpydasehave two main parts:APython Component Classin the backend, implementing the logic needed to set, update, and manage the component's state and data.AFrontend React Componentthat renders and manages user interaction in the browser.Below are the components available in thepydase.componentsmodule, accompanied by their Python usage:DeviceConnectionTheDeviceConnectioncomponent acts as a base class within thepydaseframework for managing device connections. It provides a structured approach to handle connections by offering a customizableconnectmethod and aconnectedproperty. This setup facilitates the implementation of automatic reconnection logic, which periodically attempts reconnection whenever the connection is lost.In the frontend, this class abstracts away the direct interaction with theconnectmethod and theconnectedproperty. Instead, it showcases user-defined attributes, methods, and properties. When theconnectedstatus isFalse, the frontend displays an overlay that prompts manual reconnection through theconnect()method. Successful reconnection removes the overlay.importpydase.componentsimportpydase.unitsasuclassDevice(pydase.components.DeviceConnection):def__init__(self)->None:super().__init__()self._voltage=10*u.units.Vdefconnect(self)->None:ifnotself._connected:self._connected=True@propertydefvoltage(self)->float:returnself._voltageclassMyService(pydase.DataService):def__init__(self)->None:super().__init__()self.device=Device()if__name__=="__main__":service_instance=MyService()pydase.Server(service_instance).run()Customizing Connection LogicUsers are encouraged to primarily override theconnectmethod to tailor the connection process to their specific device. This method should adjust theself._connectedattribute based on the outcome of the connection attempt:importpydase.componentsclassMyDeviceConnection(pydase.components.DeviceConnection):def__init__(self)->None:super().__init__()# Add any necessary initialization code heredefconnect(self)->None:# Implement device-specific connection logic here# Update self._connected to `True` if the connection is successful,# or `False` if unsuccessful...Moreover, if the connection status requires additional logic, users can override theconnectedproperty:importpydase.componentsclassMyDeviceConnection(pydase.components.DeviceConnection):def__init__(self)->None:super().__init__()# Add any necessary initialization code heredefconnect(self)->None:# Implement device-specific connection logic here# Ensure self._connected reflects the connection status accurately...@propertydefconnected(self)->bool:# Implement custom logic to accurately report connection statusreturnself._connectedReconnection IntervalTheDeviceConnectioncomponent automatically executes a task that checks for device availability at a default interval of 10 seconds. This interval is adjustable by modifying the_reconnection_wait_timeattribute on the class instance.ImageThis component provides a versatile interface for displaying images within the application. Users can update and manage images from various sources, including local paths, URLs, and even matplotlib figures.The component offers methods to load images seamlessly, ensuring that visual content is easily integrated and displayed within the data service.importmatplotlib.pyplotaspltimportnumpyasnpimportpydasefrompydase.components.imageimportImageclassMyDataService(pydase.DataService):my_image=Image()if__name__=="__main__":service=MyDataService()# loading from local pathservice.my_image.load_from_path("/your/image/path/")# loading from a URLservice.my_image.load_from_url("https://cataas.com/cat")# loading a matplotlib figurefig=plt.figure()x=np.linspace(0,2*np.pi)plt.plot(x,np.sin(x))plt.grid()service.my_image.load_from_matplotlib_figure(fig)pydase.Server(service).run()NumberSliderTheNumberSlidercomponent in thepydasepackage provides an interactive slider interface for adjusting numerical values on the frontend. It is designed to support both numbers and quantities and ensures that values adjusted on the frontend are synchronized with the backend.To utilize theNumberSlider, users should implement a class that derives fromNumberSlider. This class can then define the initial values, minimum and maximum limits, step sizes, and additional logic as needed.Here's an example of how to implement and use a custom slider:importpydaseimportpydase.componentsclassMySlider(pydase.components.NumberSlider):def__init__(self,value:float=0.0,min_:float=0.0,max_:float=100.0,step_size:float=1.0,)->None:super().__init__(value,min_,max_,step_size)@propertydefmin(self)->float:returnself._min@min.setterdefmin(self,value:float)->None:self._min=value@propertydefmax(self)->float:returnself._max@max.setterdefmax(self,value:float)->None:self._max=value@propertydefstep_size(self)->float:returnself._step_size@step_size.setterdefstep_size(self,value:float)->None:self._step_size=value@propertydefvalue(self)->float:"""Slider value."""returnself._value@value.setterdefvalue(self,value:float)->None:ifvalue<self._minorvalue>self._max:raiseValueError("Value is either below allowed min or above max value.")self._value=valueclassMyService(pydase.DataService):def__init__(self)->None:super().__init__()self.voltage=MySlider()if__name__=="__main__":service_instance=MyService()service_instance.voltage.value=5print(service_instance.voltage.value)# Output: 5pydase.Server(service_instance).run()In this example,MySlideroverrides themin,max,step_size, andvalueproperties. Users can make any of these properties read-only by omitting the corresponding setter method.Accessing parent class resources inNumberSliderIn scenarios where you need the slider component to interact with or access resources from its parent class, you can achieve this by passing a callback function to it. This method avoids directly passing the entire parent class instance (self) and offers a more encapsulated approach. The callback function can be designed to utilize specific attributes or methods of the parent class, allowing the slider to perform actions or retrieve data in response to slider events.Here's an illustrative example:fromcollections.abcimportCallableimportpydaseimportpydase.componentsclassMySlider(pydase.components.NumberSlider):def__init__(self,value:float,on_change:Callable[[float],None],)->None:super().__init__(value=value)self._on_change=on_change# ... other properties ...@propertydefvalue(self)->float:returnself._value@value.setterdefvalue(self,new_value:float)->None:ifnew_value<self._minornew_value>self._max:raiseValueError("Value is either below allowed min or above max value.")self._value=new_valueself._on_change(new_value)classMyService(pydase.DataService):def__init__(self)->None:self.voltage=MySlider(5,on_change=self.handle_voltage_change,)defhandle_voltage_change(self,new_voltage:float)->None:print(f"Voltage changed to:{new_voltage}")# Additional logic hereif__name__=="__main__":service_instance=MyService()my_service.voltage.value=7# Output: "Voltage changed to: 7"pydase.Server(service_instance).run()Incorporating units inNumberSliderTheNumberSlideris capable ofdisplaying unitsalongside values, enhancing its usability in contexts where unit representation is crucial. When utilizingpydase.units, you can specify units for the slider's value, allowing the component to reflect these units in the frontend.Here's how to implement aNumberSliderwith unit display:importpydaseimportpydase.componentsimportpydase.unitsasuclassMySlider(pydase.components.NumberSlider):def__init__(self,value:u.Quantity=0.0*u.units.V,)->None:super().__init__(value)@propertydefvalue(self)->u.Quantity:returnself._value@value.setterdefvalue(self,value:u.Quantity)->None:ifvalue.m<self._minorvalue.m>self._max:raiseValueError("Value is either below allowed min or above max value.")self._value=valueclassMyService(pydase.DataService):def__init__(self)->None:super().__init__()self.voltage=MySlider()if__name__=="__main__":service_instance=MyService()service_instance.voltage.value=5*u.units.Vprint(service_instance.voltage.value)# Output: 5 Vpydase.Server(service_instance).run()ColouredEnumThis component provides a way to visually represent different states or categories in a data service using colour-coded options. It behaves similarly to a standardEnum, but the values encode colours in a format understood by CSS. The colours can be defined using various methods like Hexadecimal, RGB, HSL, and more.If the property associated with theColouredEnumhas a setter function, the keys of the enum will be rendered as a dropdown menu, allowing users to interact and select different options. Without a setter function, the selected key will simply be displayed as a coloured box with text inside, serving as a visual indicator.importpydaseimportpydase.componentsaspycclassMyStatus(pyc.ColouredEnum):PENDING="#FFA500"# Hexadecimal colour (Orange)RUNNING="#0000FF80"# Hexadecimal colour with transparency (Blue)PAUSED="rgb(169, 169, 169)"# RGB colour (Dark Gray)RETRYING="rgba(255, 255, 0, 0.3)"# RGB colour with transparency (Yellow)COMPLETED="hsl(120, 100%, 50%)"# HSL colour (Green)FAILED="hsla(0, 100%, 50%, 0.7)"# HSL colour with transparency (Red)CANCELLED="SlateGray"# Cross-browser colour name (Slate Gray)classStatusTest(pydase.DataService):_status=MyStatus.RUNNING@propertydefstatus(self)->MyStatus:returnself._status@status.setterdefstatus(self,value:MyStatus)->None:# do something ...self._status=value# Modifying or accessing the status value:my_service=StatusExample()my_service.status=MyStatus.FAILEDExtending with New ComponentsUsers can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, theguide on adding componentsprovides detailed steps on achieving this.Understanding Service Persistencepydaseallows you to easily persist the state of your service by saving it to a file. This is especially useful when you want to maintain the service's state across different runs.To save the state of your service, pass afilenamekeyword argument to the constructor of thepydase.Serverclass. If the file specified byfilenamedoes not exist, the state manager will create this file and store its state in it when the service is shut down. If the file already exists, the state manager will load the state from this file, setting the values of its attributes to the values stored in the file.Here's an example:frompydaseimportDataService,ServerclassDevice(DataService):# ... defining the Device class ...if__name__=="__main__":service=Device()Server(service,filename="device_state.json").run()In this example, the state of theDeviceservice will be saved todevice_state.jsonwhen the service is shut down. Ifdevice_state.jsonexists when the server is started, the state manager will restore the state of the service from this file.Controlling Property State Loading with@load_stateBy default, the state manager only restores values for public attributes of your service. If you have properties that you want to control the loading for, you can use the@load_statedecorator on your property setters. This indicates to the state manager that the value of the property should be loaded from the state file.Here is how you can apply the@load_statedecorator:frompydaseimportDataServicefrompydase.data_service.state_managerimportload_stateclassDevice(DataService):_name="Default Device Name"@propertydefname(self)->str:returnself._name@name.setter@load_statedefname(self,value:str)->None:self._name=valueWith the@load_statedecorator applied to thenameproperty setter, the state manager will load and apply thenameproperty's value from the file storing the state upon server startup, assuming it exists.Note: If the service class structure has changed since the last time its state was saved, only the attributes and properties decorated with@load_statethat have remained the same will be restored from the settings file.Understanding Tasks in pydaseInpydase, a task is defined as an asynchronous function without arguments contained in a class that inherits fromDataService. These tasks usually contain a while loop and are designed to carry out periodic functions.For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. One core feature ofpydaseis its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and python clients, giving you flexible and powerful control over your service's operation.Another powerful feature ofpydaseis its ability to automatically start tasks upon initialization of the service. By specifying the tasks and their arguments in the_autostart_tasksdictionary in your service class's__init__method,pydasewill automatically start these tasks when the server is started. Here's an example:frompydaseimportDataService,ServerclassSensorService(DataService):def__init__(self):super().__init__()self.readout_frequency=1.0self._autostart_tasks["read_sensor_data"]=()def_process_data(self,data:...)->None:...def_read_from_sensor(self)->Any:...asyncdefread_sensor_data(self):whileTrue:data=self._read_from_sensor()self._process_data(data)# Process the data as neededawaitasyncio.sleep(self.readout_frequency)if__name__=="__main__":service=SensorService()Server(service).run()In this example,read_sensor_datais a task that continuously reads data from a sensor. By adding it to the_autostart_tasksdictionary, it will automatically start running whenServer(service).run()is executed.
As with all tasks,pydasewill generatestart_read_sensor_dataandstop_read_sensor_datamethods, which can be called to manually start and stop the data reading task. The readout frequency can be updated using thereadout_frequencyattribute.Understanding Units in pydasepydaseintegrates with thepintpackage to allow you to work with physical quantities within your service. This enables you to define attributes with units, making your service more expressive and ensuring consistency in the handling of physical quantities.You can define quantities in yourDataServicesubclass usingpydase'sunitsfunctionality.Here's an example:fromtypingimportAnyimportpydase.unitsasufrompydaseimportDataService,ServerclassServiceClass(DataService):voltage=1.0*u.units.V_current:u.Quantity=1.0*u.units.mA@propertydefcurrent(self)->u.Quantity:returnself._current@current.setterdefcurrent(self,value:u.Quantity)->None:self._current=valueif__name__=="__main__":service=ServiceClass()service.voltage=10.0*u.units.Vservice.current=1.5*u.units.mAServer(service).run()In the frontend, quantities are rendered as floats, with the unit displayed as additional text. This allows you to maintain a clear and consistent representation of physical quantities across both the backend and frontend of your service.Should you need to access the magnitude or the unit of a quantity, you can use the.mattribute or the.uattribute of the variable, respectively. For example, this could be necessary to set the periodicity of a task:importasynciofrompydaseimportDataService,Serverimportpydase.unitsasuclassServiceClass(DataService):readout_wait_time=1.0*u.units.msasyncdefread_sensor_data(self):whileTrue:print("Reading out sensor ...")awaitasyncio.sleep(self.readout_wait_time.to("s").m)if__name__=="__main__":service=ServiceClass()Server(service).run()For more information about what you can do with the units, please consult the documentation ofpint.Configuring pydase via Environment VariablesConfiguringpydasethrough environment variables enhances flexibility, security, and reusability. This approach allows for easy adaptation of services across different environments without code changes, promoting scalability and maintainability. With that, it simplifies deployment processes and facilitates centralized configuration management. Moreover, environment variables enable separation of configuration from code, aiding in secure and collaborative development.pydaseoffers various configurable options:ENVIRONMENT: Sets the operation mode to either "development" or "production". Affects logging behaviour (seelogging section).SERVICE_CONFIG_DIR: Specifies the directory for service configuration files, likeweb_settings.json. This directory can also be used to hold user-defined configuration files. Default is theconfigfolder in the service root folder. The variable can be accessed through:importpydase.configpydase.config.ServiceConfig().config_dirSERVICE_WEB_PORT: Defines the port number for the web server. This has to be different for each services running on the same host. Default is 8001.SERVICE_RPC_PORT: Defines the port number for the rpc server. This has to be different for each services running on the same host. Default is 18871.GENERATE_WEB_SETTINGS: When set to true, generates / updates theweb_settings.jsonfile. If the file already exists, only new entries are appended.Some of those settings can also be altered directly in code when initializing the server:importpathlibfrompydaseimportServerfromyour_service_moduleimportYourServiceserver=Server(YourService(),web_port=8080,rpc_port=18880,config_dir=pathlib.Path("other_config_dir"),# note that you need to provide an argument of type pathlib.Pathgenerate_web_settings=True).run()Customizing the Web InterfaceEnhancing the Web Interface Style with Custom CSSpydaseallows you to enhance the user experience by customizing the web interface's appearance. You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.Here's how you can use this feature:Prepare your custom CSS file with the desired styles.When initializing your server, use thecssparameter of theServerclass to specify the path to your custom CSS file.frompydaseimportServer,DataServiceclassMyService(DataService):# ... your service definition ...if__name__=="__main__":service=MyService()server=Server(service,css="path/to/your/custom.css").run()This will apply the styles defined incustom.cssto the web interface, allowing you to maintain branding consistency or improve visual accessibility.Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.Tailoring Frontend Component Layoutpydaseenables users to customize the frontend layout via theweb_settings.jsonfile. Each key in the file corresponds to the full access path of public attributes, properties, and methods of the exposed service, using dot-notation.Custom Display Names: Modify the"displayName"value in the file to change how each component appears in the frontend.Theweb_settings.jsonfile will be stored in the directory specified bySERVICE_CONFIG_DIR. You can generate aweb_settings.jsonfile by setting theGENERATE_WEB_SETTINGStoTrue. For more information, see theconfiguration section.Logging in pydaseThepydaselibrary organizes its loggers on a per-module basis, mirroring the Python package hierarchy. This structured approach allows for granular control over logging levels and behaviour across different parts of the library.Changing the Log LevelYou have two primary ways to adjust the log levels inpydase:directly targetingpydaseloggersYou can set the log level for anypydaselogger directly in your code. This method is useful for fine-tuning logging levels for specific modules withinpydase. For instance, if you want to change the log level of the mainpydaselogger or target a submodule likepydase.data_service, you can do so as follows:# <your_script.py>importlogging# Set the log level for the main pydase loggerlogging.getLogger("pydase").setLevel(logging.INFO)# Optionally, target a specific submodule logger# logging.getLogger("pydase.data_service").setLevel(logging.DEBUG)# Your logger for the current scriptlogger=logging.getLogger(__name__)logger.info("My info message.")This approach allows for specific control over different parts of thepydaselibrary, depending on your logging needs.using theENVIRONMENTenvironment variableFor a more global setting that affects the entirepydaselibrary, you can utilize theENVIRONMENTenvironment variable. Setting this variable to "production" will configure allpydaseloggers to only log messages of level "INFO" and above, filtering out more verbose logging. This is particularly useful for production environments where excessive logging can be overwhelming or unnecessary.ENVIRONMENT="production"python-m<module_using_pydase>In the absence of this setting, the default behavior is to log everything of level "DEBUG" and above, suitable for development environments where more detailed logs are beneficial.Note: It is recommended to avoid calling thepydase.utils.logging.setup_loggingfunction directly, as this may result in duplicated logging messages.DocumentationThe full documentation provides more detailed information aboutpydase, including advanced usage examples, API references, and tips for troubleshooting common issues. See thefull documentationfor more information.ContributingWe welcome contributions! Please seecontributing.mdfor details on how to contribute.Licensepydaseis licensed under theMIT License.