Search code examples
pythonshinymodulespy-shiny

Is it possible to define class methods as Shiny Python modules?


I'm trying to build a Shiny for Python app where portions of the form code can be broken out using Shiny Modules where I can define the specific ui and server logic as a class method, but ultimately inherit some base capability. I have the following shiny app:

import pandas as pd
from shiny import App, reactive, ui, module, render
from abc import ABC, abstractmethod


class ShinyFormTemplate(ABC):
    @abstractmethod
    @module.ui
    def ui_func(self):
        pass

    @abstractmethod
    @module.server
    def server_func(self, input, output, session, *args, **kwargs):
        pass


class FruitForm(ShinyFormTemplate):
    @module.ui
    def ui_func(self):
        return ui.row(
            ui.input_text("fruits",'Select a Fruit',""),
            ui.output_text("output_fruit"),
            ui.input_text("qtys",'Quantity:',""),
            ui.output_text("output_qty"),
        )

    @module.server
    def server_func(self, input, output, session, *args, **kwargs):
        @render.text
        def output_fruits():
            return input.fruits()
        
        @render.text
        def output_qty():
            return input.qtys()

class VeggieForm(ShinyFormTemplate):
    @module.ui
    def ui_func(self):
        return ui.row(
            ui.input_radio_buttons("veggie","Select a Veggie:",{'Asparagus':'Asparagus','Spinach':'Spinach','Squash':'Squash','Lettuce':'Lettuce'}),
            ui.output_text("output_veggie"),
            ui.input_text("qtys",'Quantity:',""),
            ui.output_text("output_qty"),
        )

    @module.server
    def server_func(self, input, output, session, *args, **kwargs):
        @render.text
        def output_veggie():
            return input.veggie()
        
        @render.text
        def output_qty():
            return input.qtys()

fruits = FruitForm()
veggies = VeggieForm()



app_ui = ui.page_fluid(
    ui.page_navbar(
        ui.nav_panel("New Fruit", "New Fruit - Input Form",
            fruits.ui_func('fruit')
        ),

        ui.nav_panel("New Veggie", "New Veggie - Input Form",
            veggies.ui_func('veggie')
        ),
        title="Sample App",
        id="page",
    ),
    title="Basic App"
)

def server(input, output, session):
    fruits.server_func('fruit')
    veggies.server_func('veggie')


app = App(app_ui, server)

But when trying to run this, I get an error:

Exception has occurred: ValueError
`id` must be a single string
  File "C:\Users\this_user\OneDrive\Documents\Programming Projects\Python\example_class_module\app.py", line 66, in <module>
    fruits.ui_func('fruit')
ValueError: `id` must be a single string

I do not understand this error because I thought I provided a string based id for the module namespace ('fruit' in this case).

What I've Tried: I tried to define the server and ui objects outside the class and passing them in as arguments to the init() function instead of defining them as class methods. However, working this way, I don't think I can access attributes of the class instance from within the module function using self.attribute.

Is it possible to define class methods as shiny module (server and ui) components?


Solution

  • I found a way to do all the things desired, but I'm not sure it's the best way. Welcome suggestions if there is a better way..

    Instead of explicitly defining a ui or server module AS a class method, its possible to achieve similar functionality if the ui and server modules are defined INSIDE a class method and then called at the end of the method to activate the module code. Here is the basic idea:

    class MyForm:
       def __init__(self, namespace_id): # pass in other objects specific to this form, such as a dataframe to populate the form with initially
          self.__namespace_id = namespace_id
    
       def call_ui(self):
          @module.ui
          def ui_func():
             # ui module components go here for the module
    
          return ui_func(self.__namespace_id)
    
       def call_server(self):
          @module.server
          def server_func(self, input, output, session):
             # server module logic goes here
    
          server_func(self.__namespace_id)
    
    form = MyForm('fruit') # the module in this class uses the 'fruit' namespace
    
    app_ui = ui.page_fluid(
       form.call_ui()
    )
    
    def server(input, output, session):
       form.call_server(input, output, session)
       # input, output, and session must be explicitly passed into call_server method
    
    app = App(app_ui, server)
    

    This ultimately makes it possible to build a more complex app modularly that could for example have a bunch of UI forms that capture their own specific input fields and write to their own specific database table.

    Below is a working example built from the question that demonstrates encapsulation of a shiny module within a class, inheritance with common module functionality defined at the parent class (ShinyFormTemplate), and polymorphic behavior where the call_server() and call_ui() methods handle the form content specific to their child class (FruitForm, and VeggieForm). Also, note that the VeggieForm class was defined with additional complexity in it's init() method to set it apart from FruitForm (see the veggie_only_data parameter that only FruitForm requires).

    
    import pandas as pd
    from shiny import App, reactive, ui, module, render
    from abc import ABC, abstractmethod
    
    
    class ShinyFormTemplate(ABC):
        """This is the abstract base class that has some commonly defined and implemented ui and server logic methods, as well as abstract methods for ui and server logic methods that will be implemented by the children (FruitForm and VeggieForm)"""
    
        _namespace_id=None
    
        def __init__(self, namespace_id, *args, **kwargs): # will be inhereted by child classes
            self._namespace_id=namespace_id
    
        @abstractmethod
        def call_ui(self):
            pass
    
        @abstractmethod
        def call_server(self,input,output,session):
            pass
    
        def _common_button_ui(self):
            """This method gets inherited by both fruit and veggie classes, providing ui for counter button"""
            return ui.row(
    
                ui.column(6,ui.input_action_button('count_button',"Increment Counter")),
                ui.column(3,ui.output_text('show_counter')),
                ui.column(3),
            )
        
        def _common_button_server(self,input,output,session):
            """This method gets inherited by both fruit and veggie classes, providing server functionality for counter button"""
            counter = reactive.value(0)
    
            @reactive.effect
            @reactive.event(input.count_button)
            def namespace_text():
                counter.set(counter.get()+1)
    
            @render.text
            def show_counter():
                return str(counter())
    
    class FruitForm(ShinyFormTemplate):
        """This is the Fruit child class providing specific UI and Server functionality for Fruits."""
        def call_ui(self):
            """This method defines the FruitForm specific ui module AND calls it at the end returning the result."""
            @module.ui
            def ui_func():
                return ui.row(
                ui.input_text("fruits",'Select a Fruit',""),
                ui.output_text("output_fruits"),
                ui.input_text("qtys",'Quantity:',""),
                ui.output_text("output_qty"),
                self._common_button_ui(), # protected method inherited from ShinyFormTemplate.  
    
                # Insert additional fruit specific ui here that will operate in the 'fruit' namespace
            )
            return ui.nav_panel("New Fruit", "New Fruit - Input Form",
                ui_func(self._namespace_id) # the call to establish the fruit ui has to be returned at the end of this class, so that it gets inserted into the app_ui object that is defined globally
            )
    
        def call_server(self,input,output,session):
            """This method defines the ui module AND calls it at the end."""
            @module.server
            def server_func(input, output, session):
                @render.text
                def output_fruits():
                    return input.fruits()
                
                self.__server_fruit_addl_stuff(input, output, session) # private method for FruitForm class only
                # Insert additional Fruit specific server logic here that will operate in the 'fruit' namespace
                self._common_button_server(input,output,session) # protected method inherited from ShinyFormTemplate
                
            server_func(self._namespace_id)
    
        def __server_fruit_addl_stuff(self, input, output, session):
                """Here is some additional server functionality that exists only in the FruitForm class and can be called by the call_server() method"""
                @render.text
                def output_qty():
                    return input.qtys()
    
    class VeggieForm(ShinyFormTemplate):
        """This is the Veggie child class providing specific UI and Server functionality for Veggies."""
    
        def __init__(self, namespace_id, veggie_only_data, *args, **kwargs): # will be inhereted by child classes
            self._namespace_id=namespace_id    
            self.__veggie_only_data=veggie_only_data
        
        def call_ui(self):
            """This method defines the VeggieForm specific ui module AND calls it at the end returning the result."""
            @module.ui
            def ui_func():
                return ui.row(
                    ui.row(self.__veggie_only_data).add_style("font-weight: bold;"),
                    ui.input_radio_buttons("veggie","Select a Veggie:",{'Asparagus':'Asparagus','Spinach':'Spinach','Squash':'Squash','Lettuce':'Lettuce'}),
                    ui.output_text("output_veggie"),
                    ui.input_text("qtys",'Quantity:',""),
                    ui.output_text("output_qty"),
                    # Insert additional Veggie specific ui here that will operate in the 'veggie' namespace
                    self._common_button_ui(),
            )
            return ui.nav_panel("New Veggie", "New Veggie - Input Form",
                ui_func(self._namespace_id)
            )
    
    
        def call_server(self,input,output,session):
            @module.server
            def server_func(input, output, session):
                @render.text
                def output_veggie():
                    return input.veggie()
            
                @render.text
                def output_qty():
                    return input.qtys()
                
                # Insert additional Veggie specific server logic here that will operate in the 'veggie' namespace
                self._common_button_server(input, output, session)
            
            server_func(self._namespace_id)
    
    #Define fruit and veggie class object instances.  This allows us to also pass in other objects like specific dataframes and database models that we can use to write populated form data to PostgreSQL database
    fruits = FruitForm(namespace_id='fruit') # all ui/server components will operate in the 'fruit' namespace
    veggies = VeggieForm(namespace_id='veggie',veggie_only_data='This class has a veggie specific data model') # all ui/server components will operate in the 'fruit' namespace
    food_forms = [fruits, veggies]
    
    # main ui object for the app
    app_ui = ui.page_fluid(
        ui.page_navbar(
            [form.call_ui() for form in food_forms],  # iterate through any number of forms - creates and runs a ui module for each
            title="Sample App",
            id="page",
        ),
        title="Basic App"
    )
    
    # main server function for the app
    def server(input, output, session):
        
        for form in food_forms:
           form.call_server(input, output, session) # iterate through any number of forms - creates and runs a server module for each
    
    app = App(app_ui, server)
    

    Example Modular Form with Classes