Updated 2013-12-12 12:40:22 by RFox

Most of my applications use Tcl scripts to save/restore settings. I'm now building an application with a large number of very loosely coupled components and wanted a way for those components to each save and restore settings from a common file. Below is my modest solution to this. A snit::type (class) called StateManager and an associated type that uses the singleton pattern to allow parts of an application to interact with the same StateManager object.

Key features:

  • Application chunks register names of variables to put in the file along with a getter script, which is expected to return the value to write to file and a setter which is called when the variable has a setting in a configuration script.
  • A safe interpreter is encapsulated in the StateManager to ensure that settings files don't go wild on you.
  • Implementation of the singleton pattern to allow parts of a loosely coupled application to all use a common StateManager object. Snit's delegation mechanism makes it very easy to make the StateManagerSingleton objects a complete stand-in for the underlying StateManager singleton instance.

Work to do:

  • Ensure the safe interpreter name chosen is really unique -- any takers there?
##
# @file state.tcl
# @brief State manager for save/restore arbitrary states.
# @author Ron Fox <[email protected]>

package provide StateManager 1.0
package require snit

##
# @class StateManager
#
#  Provides a mechanism to allow packages in a program to save and restore
#  internal state.  This is done by associating state variables with
#  getters and setters and providing a changed method to indicate
#  when to write a new copy of the saved file.
#
#  Save files are simply Tcl scripts that contain a bunch of set commands.
#  Those commands will be executed in a safe interpreter.
#
# OPTIONS
#   -file  - Path to the file that will hold the save script.
#
# METHOD
#   addStateVariable   - Adds a new state variable to state manager
#   listStateVariables - lists the state variables in the state manager.
#   removeStateVariable - Removes a state variable from the registered list.
#   save               - Save the state (-file must be defined by now).
#   restore            - restore the state (-file must be defined by now).
#
snit::type StateManager {
    option -file
    
    
    # State variables are an ordered list so that
    # it is possible to define the order in which data are written to file
    # and restored from the file to internal state.
    #
    #  Each element of the list is a three element list containing:
    #  *  The variable name.
    #  *  A command that will provide the correct value for that variable.  This
    #     is parameterized by the variable name when called.
    #  * A command that will react to a restored value for this variable.
    #    This command is parameterized by the variable name and new value.
    #
    variable registeredVariables [list]
    
    ##
    # constructor
    #   Contstruct the object.  Simply process the configuration options.
    # @param args  option/value pairs.
    #
    constructor args {
        $self configurelist $args
    }
    ##
    # addStateVariable
    #   adds a stateVariable to the list
    #
    # @param name   - Name of the state variable.
    # @param getter - Command to get the variable value.
    # @param setter - Command to set the variable value.
    #
    method addStateVariable {name getter setter} {
        if {[lsearch -exact -index 0 $registeredVariables $name] != -1} {
            error "There is already a registration for the variable '$name'"
        }
        lappend registeredVariables [list $name $getter $setter]
    }
    ##
    # listStateVariables
    #
    # @return the current list of state variables.  This is a list whose
    #         elements are triples of name, getter, setter.
    #
    method listStateVariables {} {
        return $registeredVariables
    }
    ##
    # save
    #   Saves the configuration.
    #   * -file must  have been configured.
    #   * The registered variables are itereated over and the getter
    #     for each is called.
    #   * A set command for the registered variable is written to the
    #    specified -file
    #
    method save {} {
        if {$options(-file) eq ""} {
            error {Cannot save prior to configuring -file}
        }
        set fd [open $options(-file) w]
        
        # Now iterate over the variables, getting values and writing
        # set commands.
        
        foreach variable $registeredVariables {
            
            set varname [lindex $variable 0]
            set getter  [lindex $variable 1]
            set value [{*}$getter $varname]
            puts $fd [list set $varname $value]
        }
        
        #  Close the file
        
        close $fd
    }
    ##
    # restore
    #   Restores the configuratino
    #   * -file must have been configured.
    #   * A safe interpreter is created and [source] exposed
    #   * The -file is sourced into the interpreter.
    #   * For each variable in the registered list, if that variable
    #     exists in the slave interpreter, that variable's setter is called.
    #
    method restore {} {
        if {$options(-file) eq "" } {
            error {Cannot restore prior to configuring -file}
        }
        if {![file readable $options(-file)]} {
            error "The restore file '$options(-file)' does not exist or is not readable"
        }
        interp create -safe StateManagerInterp
        StateManagerInterp expose source
        StateManagerInterp eval source $options(-file)
        
        foreach variable $registeredVariables {
            set varname [lindex $variable 0]
            set setter  [lindex $variable 2]
            
            if {[StateManagerInterp eval info vars $varname] eq $varname} {
                set value [StateManagerInterp eval set $varname]
                {*}$setter $varname $value
            }
        }
        interp delete StateManagerInterp
    }
}

##
# @class StateManagerSingleton
#
#   This is provided for applications that need a single state saver.
#
snit::type StateManagerSingleton {
    component instance
    
    delegate option * to instance
    delegate method * to instance
    
    typevariable theInstance ""
    
    ##
    # constructor
    #    If this is the first construction, create the instance.
    #    Regardless, install the instance as the instance component.
    #    all options and methods are  delegated to the instance component
    #    so this will appear exactly like a state manager oject.
    #
    # @param args - configuration options.
    #
    constructor args {
        if {$theInstance eq ""} {
            set theInstance [StateManager %AUTO%]
        }
        install instance using set theInstance
        $self configurelist $args
    }
    
}

Here's the test suite for the component:
##
# @file state.test
# @brief Tests for program state save/restore
# @author Ron Fox <[email protected]>

package require tcltest
package require StateManager

#-----------------------------------------------------------------------------
# Construction tests

tcltest::test construct-0 {Construction provides an identifier} \
-cleanup {
    a destroy
} \
-body {
    StateManager a
} -result ::a

tcltest::test construct-1 {Can construct with a -file parameter} \
-cleanup {
    a destroy
} \
-body {
    StateManager a -file config.tcl
    a cget -file
} -result config.tcl

#---------------------------------------------------------------------------
#  Registration/listing tests.
#

tcltest::test register-list-1 {Registration is reflected in listings} \
-setup {
    StateManager a
} \
-cleanup {
    a destroy
} \
-body {
    a addStateVariable test setTest getTest
    a listStateVariables
    
} -result [list [list test setTest getTest]]


tcltest::test register-list-2 {Duplicate registration is an error} \
-setup {
    StateManager a
} \
-cleanup {
    a destroy
} \
-body {
    a addStateVariable test setTest getTest
    set status [catch {a addStateVariable test setTest getTest} message]
    list $status $message
} -result [list 1 {There is already a registration for the variable 'test'}]

tcltest::test register-list-3 {Registration order is preserved} \
-setup {
    StateManager a
} \
-cleanup {
    a destroy
} \
-body {
    a addStateVariable test setTest getTest
    a addStateVariable btest setbTest getbTest
} -result [list [list test setTest getTest] [list btest setbTest getbTest]]

#------------------------------------------------------------------------------
# Save tests

# Save/restore tests use the infrastructure below:

set aa {this is one variable}
set bb {this is another variable}

#
#  Setter puts stuff in the ::restored:: namespace which must be
#  created/cleaned up by the test.  This prevents the original
#  aa/bb from being overwritten.

proc setter {varname value} {
    set ::restored::$varname $value
}
proc getter {varname} {
    set ::$varname
}
#
#  These getter/setters are used in tests to ensure that
#  multiword getter/setters can be supplied
#
proc complexgetter {bravo varname} {
    set ::$varname
}
proc complexsetter {bravo varname value} {
    set ::restored::$varname $value
}

tcltest::test save-1 {Save without -file set is an error} \
-setup {
    StateManager a
} \
-cleanup {
    a destroy
    catch {file delete settings.test}
} \
-body {
   
    set status [catch {a save} message]
    list $status $message
} -result [list 1 {Cannot save prior to configuring -file}]


tcltest::test save-2 {Save with nothing monitored is an empty file} \
-setup {
    StateManager a
    set fullpath [file join [tcltest::temporaryDirectory] settings.test]
} \
-cleanup {
    a destroy
    tcltest::removeFile settings.test
} \
-body {
    
    a configure -file $fullpath
    a save
    set fd [open $fullpath r]
    set contents [read $fd]
    close $fd
    set contents
} -result {}

tcltest::test save-3 {Save with a registered makes a file with set a...} \
-setup {
    StateManager a
    set fullpath [file join [tcltest::temporaryDirectory] settings.test]
    a addStateVariable aa getter setter
    interp create -safe slave
    interp expose slave source
} \
-cleanup {
    a destroy
    tcltest::removeFile settings.test
    interp delete slave
} \
-body {
    
    a configure -file $fullpath
    a save
    interp eval slave source $fullpath
    interp eval slave set aa
    
} -result $aa


tcltest::test save-4 {Save with a,b, registered makes a file with set a, set b} \
-setup {
    StateManager a
    set fullpath [file join [tcltest::temporaryDirectory] settings.test]
    a addStateVariable aa getter setter
    a addStateVariable bb getter setter
    interp create -safe slave
    interp expose slave source
} \
-cleanup {
    a destroy
    tcltest::removeFile settings.test
    interp delete slave
} \
-body {
    
    a configure -file $fullpath
    a save
    interp eval slave source $fullpath
    set varaa [interp eval slave set aa]
    set varbb [interp eval slave set bb]
    
    list $varaa $varbb
} -result [list $aa $bb]

tcltest::test save-5 {Save a with complex getter should give correct save file} \
-setup {
    StateManager a
    set fullpath [file join [tcltest::temporaryDirectory] settings.test]
    a addStateVariable aa [list complexgetter junk] setter
    interp create -safe slave
    interp expose slave source
} \
-cleanup {
    a destroy
    tcltest::removeFile settings.test
    interp delete slave
} \
-body {
    
    a configure -file $fullpath
    a save
    interp eval slave source $fullpath
    interp eval slave set aa
    
} -result $aa

#------------------------------------------------------------------------------
# Restore tests.
#

tcltest::test restore-0 {Restore without -file configured fails} \
-setup {
    StateManager a
} \
-cleanup {
    a destroy 
} \
-body {
    set status [catch {a restore} text]
    list $status $text
} -result [list 1 {Cannot restore prior to configuring -file}]


tcltest::test restore-1 {Restore with no vars saved does nothing} \
-setup {
    set fullpath [file join [tcltest::temporaryDirectory] settings.test]
    StateManager a -file $fullpath
    a save
    
    namespace eval ::restored:: {}
} \
-cleanup {
    a destroy
    namespace delete ::restored
} \
-body {
    a restore
    info var ::restored::*
} -result [list]



tcltest::test restore-2 {Restore with aa saved makes aa in ::restored::} \
-setup {
    set fullpath [file join [tcltest::temporaryDirectory] settings.test]
    StateManager a -file $fullpath
    a addStateVariable aa getter setter
    a save
    
    namespace eval ::restored:: {}
} \
-cleanup {
    a destroy
    namespace delete ::restored
} \
-body {
    a restore
    list [info var ::restored::*] [set ::restored::aa]
    
} -result [list ::restored::aa $aa]


tcltest::test restore-3 {restore with aa,bb saved makes both in ::restored::} \
-setup {
    set fullpath [file join [tcltest::temporaryDirectory] settings.test]
    StateManager a -file $fullpath
    a addStateVariable aa getter setter
    a addStateVariable bb getter setter
    a save
    
    namespace eval ::restored:: {}
} \
-cleanup {
    a destroy
    namespace delete ::restored
} \
-body {
    a restore
    list [lsort [info var ::restored::*]] [set ::restored::aa] [set ::restored::bb]
    
} -result [list [list ::restored::aa ::restored::bb] $aa $bb]

tcltest::test restore-4 {Restore from nonexistent file is an error} \
-setup {
    StateManager a -file /no/such/file/exists.txt
} \
-cleanup {
    a destroy
} \
-body {
    set status [catch {a restore} message]
    list $status $message
} -result [list 1 {The restore file '/no/such/file/exists.txt' does not exist or is not readable}]

tcltest::test restore-5 {Restore with complex setter should work fine} \
-setup {
    set fullpath [file join [tcltest::temporaryDirectory] settings.test]
    StateManager a -file $fullpath
    a addStateVariable aa getter [list complexsetter 1234]
    a save
    
    namespace eval ::restored:: {}
} \
-cleanup {
    a destroy
    namespace delete ::restored
} \
-body {
    a restore
    list [info var ::restored::*] [set ::restored::aa]
    
} -result [list ::restored::aa $aa]



# Report the test results.

tcltest::cleanupTests