Changeset 1130 for pykota/trunk

Show
Ignore:
Timestamp:
10/02/03 22:23:18 (21 years ago)
Author:
jalet
Message:

Storage caching mechanism added.

Location:
pykota/trunk
Files:
8 modified

Legend:

Unmodified
Added
Removed
  • pykota/trunk/conf/pykota.conf.sample

    r1114 r1130  
    5151storageuser: pykotauser 
    5252# storageuserpw: Comment out if unused, or set to Quota Storage user password 
     53 
     54# Should the database caching mechanism be enabled or not ? 
     55# If unset, caching is disabled. Possible values Y/N/YES/NO 
     56# caching mechanism works with both PostgreSQL and OpenLDAP backends 
     57# but may be interesting only with OpenLDAP. 
     58storagecaching: No 
    5359 
    5460# LDAP example, uncomment and adapt it to your own configuration : 
  • pykota/trunk/NEWS

    r1123 r1130  
    2121 
    2222PyKota NEWS : 
     23 
     24    - 1.15alpha5 : 
     25     
     26        - Caching mechanism for all database queries. 
    2327 
    2428    - 1.15alpha4 : 
  • pykota/trunk/pykota/config.py

    r1111 r1130  
    2121# 
    2222# $Log$ 
     23# Revision 1.36  2003/10/02 20:23:18  jalet 
     24# Storage caching mechanism added. 
     25# 
    2326# Revision 1.35  2003/07/29 09:54:03  jalet 
    2427# Added configurable LDAP mail attribute support 
     
    401404        else :     
    402405            return 0 
     406             
     407    def getCaching(self) :           
     408        """Returns 1 if database caching is enabled, else 0.""" 
     409        caching = self.getGlobalOption("storagecaching", ignore=1) 
     410        if (caching is not None) and (caching.upper().strip() in ['Y', 'YES', '1', 'ON', 'O']) : 
     411            return 1 
     412        else :     
     413            return 0 
  • pykota/trunk/pykota/storage.py

    r1087 r1130  
    2121# 
    2222# $Log$ 
     23# Revision 1.20  2003/10/02 20:23:18  jalet 
     24# Storage caching mechanism added. 
     25# 
    2326# Revision 1.19  2003/07/16 21:53:07  jalet 
    2427# Really big modifications wrt new configuration file's location and content. 
     
    298301        self.JobSize = jobsize 
    299302     
     303class BaseStorage : 
     304    def __init__(self, pykotatool) : 
     305        """Opens the LDAP connection.""" 
     306        # raise PyKotaStorageError, "Sorry, the LDAP backend for PyKota is not yet implemented !" 
     307        self.closed = 1 
     308        self.tool = pykotatool 
     309        self.usecache = pykotatool.config.getCaching() 
     310        if self.usecache : 
     311            self.tool.logdebug("Caching enabled.") 
     312            self.caches = { "USERS" : {}, "GROUPS" : {}, "PRINTERS" : {}, "USERPQUOTAS" : {}, "GROUPPQUOTAS" : {}, "JOBS" : {}, "LASTJOBS" : {} } 
     313         
     314    def __del__(self) :         
     315        """Ensures that the database connection is closed.""" 
     316        self.close() 
     317         
     318    def getFromCache(self, cachetype, key) : 
     319        """Tries to extract something from the cache.""" 
     320        if self.usecache : 
     321            entry = self.caches[cachetype].get(key) 
     322            if entry is not None : 
     323                self.tool.logdebug("Cache hit (%s->%s)" % (cachetype, key)) 
     324            else :     
     325                self.tool.logdebug("Cache miss (%s->%s)" % (cachetype, key)) 
     326            return entry     
     327             
     328    def cacheEntry(self, cachetype, key, value) :         
     329        """Puts an entry in the cache.""" 
     330        if self.usecache : 
     331            self.caches[cachetype][key] = value 
     332             
     333    def getUser(self, username) :         
     334        """Returns the user from cache.""" 
     335        user = self.getFromCache("USERS", username) 
     336        if user is None : 
     337            user = self.getUserFromBackend(username) 
     338            self.cacheEntry("USERS", username, user) 
     339        return user     
     340         
     341    def getGroup(self, groupname) :         
     342        """Returns the group from cache.""" 
     343        group = self.getFromCache("GROUPS", groupname) 
     344        if group is None : 
     345            group = self.getGroupFromBackend(groupname) 
     346            self.cacheEntry("GROUPS", groupname, group) 
     347        return group     
     348         
     349    def getPrinter(self, printername) :         
     350        """Returns the printer from cache.""" 
     351        printer = self.getFromCache("PRINTERS", printername) 
     352        if printer is None : 
     353            printer = self.getPrinterFromBackend(printername) 
     354            self.cacheEntry("PRINTERS", printername, printer) 
     355        return printer     
     356         
     357    def getUserPQuota(self, user, printer) :         
     358        """Returns the user quota information from cache.""" 
     359        useratprinter = "%s@%s" % (user.Name, printer.Name) 
     360        upquota = self.getFromCache("USERPQUOTAS", useratprinter) 
     361        if upquota is None : 
     362            upquota = self.getUserPQuotaFromBackend(user, printer) 
     363            self.cacheEntry("USERPQUOTAS", useratprinter, upquota) 
     364        return upquota     
     365         
     366    def getGroupPQuota(self, group, printer) :         
     367        """Returns the group quota information from cache.""" 
     368        groupatprinter = "%s@%s" % (group.Name, printer.Name) 
     369        gpquota = self.getFromCache("GROUPPQUOTAS", groupatprinter) 
     370        if gpquota is None : 
     371            gpquota = self.getGroupPQuotaFromBackend(group, printer) 
     372            self.cacheEntry("GROUPPQUOTAS", groupatprinter, gpquota) 
     373        return gpquota     
     374         
     375    def getPrinterLastJob(self, printer) :         
     376        """Extracts last job information for a given printer from cache.""" 
     377        lastjob = self.getFromCache("LASTJOBS", printer.Name) 
     378        if lastjob is None : 
     379            lastjob = self.getPrinterLastJobFromBackend(printer) 
     380            self.cacheEntry("LASTJOBS", printer.Name, lastjob) 
     381        return lastjob     
     382         
    300383def openConnection(pykotatool) : 
    301384    """Returns a connection handle to the appropriate Quota Storage Database.""" 
  • pykota/trunk/pykota/storages/ldapstorage.py

    r1119 r1130  
    2121# 
    2222# $Log$ 
     23# Revision 1.26  2003/10/02 20:23:18  jalet 
     24# Storage caching mechanism added. 
     25# 
    2326# Revision 1.25  2003/08/20 15:56:24  jalet 
    2427# Better user and group deletion 
     
    116119import md5 
    117120 
    118 from pykota.storage import PyKotaStorageError 
    119 from pykota.storage import StorageObject,StorageUser,StorageGroup,StoragePrinter,StorageLastJob,StorageUserPQuota,StorageGroupPQuota 
     121from pykota.storage import PyKotaStorageError,BaseStorage,StorageObject,StorageUser,StorageGroup,StoragePrinter,StorageLastJob,StorageUserPQuota,StorageGroupPQuota 
    120122 
    121123try : 
     
    127129    raise PyKotaStorageError, "This python version (%s) doesn't seem to have the python-ldap module installed correctly." % sys.version.split()[0] 
    128130     
    129 class Storage : 
     131class Storage(BaseStorage) : 
    130132    def __init__(self, pykotatool, host, dbname, user, passwd) : 
    131133        """Opens the LDAP connection.""" 
    132134        # raise PyKotaStorageError, "Sorry, the LDAP backend for PyKota is not yet implemented !" 
    133         self.closed = 1 
    134         self.tool = pykotatool 
    135         self.debug = pykotatool.config.getDebug() 
     135        BaseStorage.__init__(self, pykotatool) 
    136136        self.info = pykotatool.config.getLDAPInfo() 
    137137        try : 
     
    145145        else :     
    146146            self.closed = 0 
    147             if self.debug : 
    148                 self.tool.logger.log_message("Database opened (host=%s, dbname=%s, user=%s)" % (host, dbname, user), "debug") 
    149              
    150     def __del__(self) :         
    151         """Ensures that the database connection is closed.""" 
    152         self.close() 
    153          
     147            self.tool.logdebug("Database opened (host=%s, dbname=%s, user=%s)" % (host, dbname, user)) 
     148             
    154149    def close(self) :     
    155150        """Closes the database connection.""" 
     
    157152            del self.database 
    158153            self.closed = 1 
    159             if self.debug : 
    160                 self.tool.logger.log_message("Database closed.", "debug") 
     154            self.tool.logdebug("Database closed.") 
    161155         
    162156    def genUUID(self) :     
     
    169163    def beginTransaction(self) :     
    170164        """Starts a transaction.""" 
    171         if self.debug : 
    172             self.tool.logger.log_message("Transaction begins... WARNING : No transactions in LDAP !", "debug") 
     165        self.tool.logdebug("Transaction begins... WARNING : No transactions in LDAP !") 
    173166         
    174167    def commitTransaction(self) :     
    175168        """Commits a transaction.""" 
    176         if self.debug : 
    177             self.tool.logger.log_message("Transaction committed. WARNING : No transactions in LDAP !", "debug") 
     169        self.tool.logdebug("Transaction committed. WARNING : No transactions in LDAP !") 
    178170         
    179171    def rollbackTransaction(self) :      
    180172        """Rollbacks a transaction.""" 
    181         if self.debug : 
    182             self.tool.logger.log_message("Transaction aborted. WARNING : No transaction in LDAP !", "debug") 
     173        self.tool.logdebug("Transaction aborted. WARNING : No transaction in LDAP !") 
    183174         
    184175    def doSearch(self, key, fields=None, base="", scope=ldap.SCOPE_SUBTREE) : 
     
    186177        try : 
    187178            base = base or self.basedn 
    188             if self.debug : 
    189                 self.tool.logger.log_message("QUERY : Filter : %s, BaseDN : %s, Scope : %s, Attributes : %s" % (key, base, scope, fields), "debug") 
     179            self.tool.logdebug("QUERY : Filter : %s, BaseDN : %s, Scope : %s, Attributes : %s" % (key, base, scope, fields)) 
    190180            result = self.database.search_s(base or self.basedn, scope, key, fields) 
    191181        except ldap.LDAPError :     
    192182            raise PyKotaStorageError, _("Search for %s(%s) from %s(scope=%s) returned no answer.") % (key, fields, base, scope) 
    193183        else :      
    194             if self.debug : 
    195                 self.tool.logger.log_message("QUERY : Result : %s" % result, "debug") 
     184            self.tool.logdebug("QUERY : Result : %s" % result) 
    196185            return result 
    197186             
     
    199188        """Adds an entry in the LDAP directory.""" 
    200189        try : 
    201             if self.debug : 
    202                 self.tool.logger.log_message("QUERY : ADD(%s, %s)" % (dn, str(fields)), "debug") 
     190            self.tool.logdebug("QUERY : ADD(%s, %s)" % (dn, str(fields))) 
    203191            self.database.add_s(dn, modlist.addModlist(fields)) 
    204192        except ldap.LDAPError : 
     
    210198        """Deletes an entry from the LDAP directory.""" 
    211199        try : 
    212             if self.debug : 
    213                 self.tool.logger.log_message("QUERY : Delete(%s)" % dn, "debug") 
     200            self.tool.logdebug("QUERY : Delete(%s)" % dn) 
    214201            self.database.delete_s(dn) 
    215202        except ldap.LDAPError : 
     
    220207        try : 
    221208            oldentry = self.doSearch("objectClass=*", base=dn, scope=ldap.SCOPE_BASE) 
    222             if self.debug : 
    223                 self.tool.logger.log_message("QUERY : Modify(%s, %s ==> %s)" % (dn, oldentry[0][1], fields), "debug") 
     209            self.tool.logdebug("QUERY : Modify(%s, %s ==> %s)" % (dn, oldentry[0][1], fields)) 
    224210            self.database.modify_s(dn, modlist.modifyModlist(oldentry[0][1], fields, ignore_oldexistent=ignoreold)) 
    225211        except ldap.LDAPError : 
     
    228214            return dn 
    229215             
    230     def getUser(self, username) :     
     216    def getUserFromBackend(self, username) :     
    231217        """Extracts user information given its name.""" 
    232218        user = StorageUser(self, username) 
     
    262248        return user 
    263249        
    264     def getGroup(self, groupname) :     
     250    def getGroupFromBackend(self, groupname) :     
    265251        """Extracts group information given its name.""" 
    266252        group = StorageGroup(self, groupname) 
     
    282268        return group 
    283269        
    284     def getPrinter(self, printername) :         
     270    def getPrinterFromBackend(self, printername) :         
    285271        """Extracts printer information given its name.""" 
    286272        printer = StoragePrinter(self, printername) 
     
    294280            printer.Exists = 1 
    295281        return printer     
    296              
    297     def getUserGroups(self, user) :         
    298         """Returns the user's groups list.""" 
    299         groups = [] 
    300         result = self.doSearch("(&(objectClass=pykotaGroup)(%s=%s))" % (self.info["groupmembers"], user.Name), [self.info["grouprdn"]], base=self.info["groupbase"]) 
    301         if result : 
    302             for (groupid, fields) in result : 
    303                 groups.append(self.getGroup(fields.get(self.info["grouprdn"])[0])) 
    304         return groups         
    305          
    306     def getGroupMembers(self, group) :         
    307         """Returns the group's members list.""" 
    308         groupmembers = [] 
    309         result = self.doSearch("(&(objectClass=pykotaGroup)(|(pykotaGroupName=%s)(%s=%s)))" % (group.Name, self.info["grouprdn"], group.Name), [self.info["groupmembers"]], base=self.info["groupbase"]) 
    310         if result : 
    311             for username in result[0][1].get(self.info["groupmembers"], []) : 
    312                 groupmembers.append(self.getUser(username)) 
    313         return groupmembers         
    314          
    315     def getUserPQuota(self, user, printer) :         
     282         
     283    def getUserPQuotaFromBackend(self, user, printer) :         
    316284        """Extracts a user print quota.""" 
    317285        userpquota = StorageUserPQuota(self, user, printer) 
     
    344312        return userpquota 
    345313         
    346     def getGroupPQuota(self, group, printer) :         
     314    def getGroupPQuotaFromBackend(self, group, printer) :         
    347315        """Extracts a group print quota.""" 
    348316        grouppquota = StorageGroupPQuota(self, group, printer) 
     
    383351        return grouppquota 
    384352         
    385     def getPrinterLastJob(self, printer) :         
     353    def getPrinterLastJobFromBackend(self, printer) :         
    386354        """Extracts a printer's last job information.""" 
    387355        lastjob = StorageLastJob(self, printer) 
     
    409377                lastjob.Exists = 1 
    410378        return lastjob 
     379         
     380    def getUserGroups(self, user) :         
     381        """Returns the user's groups list.""" 
     382        groups = [] 
     383        result = self.doSearch("(&(objectClass=pykotaGroup)(%s=%s))" % (self.info["groupmembers"], user.Name), [self.info["grouprdn"]], base=self.info["groupbase"]) 
     384        if result : 
     385            for (groupid, fields) in result : 
     386                groups.append(self.getGroup(fields.get(self.info["grouprdn"])[0])) 
     387        return groups         
     388         
     389    def getGroupMembers(self, group) :         
     390        """Returns the group's members list.""" 
     391        groupmembers = [] 
     392        result = self.doSearch("(&(objectClass=pykotaGroup)(|(pykotaGroupName=%s)(%s=%s)))" % (group.Name, self.info["grouprdn"], group.Name), [self.info["groupmembers"]], base=self.info["groupbase"]) 
     393        if result : 
     394            for username in result[0][1].get(self.info["groupmembers"], []) : 
     395                groupmembers.append(self.getUser(username)) 
     396        return groupmembers         
    411397         
    412398    def getMatchingPrinters(self, printerpattern) : 
  • pykota/trunk/pykota/storages/pgstorage.py

    r1115 r1130  
    2121# 
    2222# $Log$ 
     23# Revision 1.13  2003/10/02 20:23:18  jalet 
     24# Storage caching mechanism added. 
     25# 
    2326# Revision 1.12  2003/08/17 14:20:25  jalet 
    2427# Bug fix by Oleg Biteryakov 
     
    6467# 
    6568 
    66 from pykota.storage import PyKotaStorageError 
    67 from pykota.storage import StorageObject,StorageUser,StorageGroup,StoragePrinter,StorageLastJob,StorageUserPQuota,StorageGroupPQuota 
     69from pykota.storage import PyKotaStorageError,BaseStorage,StorageObject,StorageUser,StorageGroup,StoragePrinter,StorageLastJob,StorageUserPQuota,StorageGroupPQuota 
    6870 
    6971try : 
     
    7476    raise PyKotaStorageError, "This python version (%s) doesn't seem to have the PygreSQL module installed correctly." % sys.version.split()[0] 
    7577 
    76 class Storage : 
     78class Storage(BaseStorage) : 
    7779    def __init__(self, pykotatool, host, dbname, user, passwd) : 
    7880        """Opens the PostgreSQL database connection.""" 
    79         self.tool = pykotatool 
    80         self.debug = pykotatool.config.getDebug() 
    81         self.closed = 1 
     81        BaseStorage.__init__(self, pykotatool) 
    8282        try : 
    8383            (host, port) = host.split(":") 
     
    9292        else :     
    9393            self.closed = 0 
    94             if self.debug : 
    95                 self.tool.logger.log_message("Database opened (host=%s, port=%s, dbname=%s, user=%s)" % (host, port, dbname, user), "debug") 
    96              
    97     def __del__(self) :         
    98         """Ensures that the database connection is closed.""" 
    99         self.close() 
    100          
     94            self.tool.logdebug("Database opened (host=%s, port=%s, dbname=%s, user=%s)" % (host, port, dbname, user)) 
     95             
    10196    def close(self) :     
    10297        """Closes the database connection.""" 
     
    10499            self.database.close() 
    105100            self.closed = 1 
    106             if self.debug : 
    107                 self.tool.logger.log_message("Database closed.", "debug") 
     101            self.tool.logdebug("Database closed.") 
    108102         
    109103    def beginTransaction(self) :     
    110104        """Starts a transaction.""" 
    111105        self.database.query("BEGIN;") 
    112         if self.debug : 
    113             self.tool.logger.log_message("Transaction begins...", "debug") 
     106        self.tool.logdebug("Transaction begins...") 
    114107         
    115108    def commitTransaction(self) :     
    116109        """Commits a transaction.""" 
    117110        self.database.query("COMMIT;") 
    118         if self.debug : 
    119             self.tool.logger.log_message("Transaction committed.", "debug") 
     111        self.tool.logdebug("Transaction committed.") 
    120112         
    121113    def rollbackTransaction(self) :      
    122114        """Rollbacks a transaction.""" 
    123115        self.database.query("ROLLBACK;") 
    124         if self.debug : 
    125             self.tool.logger.log_message("Transaction aborted.", "debug") 
     116        self.tool.logdebug("Transaction aborted.") 
    126117         
    127118    def doSearch(self, query) : 
     
    131122            query += ';' 
    132123        try : 
    133             if self.debug : 
    134                 self.tool.logger.log_message("QUERY : %s" % query, "debug") 
     124            self.tool.logdebug("QUERY : %s" % query) 
    135125            result = self.database.query(query) 
    136126        except pg.error, msg :     
     
    146136            query += ';' 
    147137        try : 
    148             if self.debug : 
    149                 self.tool.logger.log_message("QUERY : %s" % query, "debug") 
     138            self.tool.logdebug("QUERY : %s" % query) 
    150139            result = self.database.query(query) 
    151140        except pg.error, msg :     
     
    164153        return pg._quote(field, typ) 
    165154         
    166     def getUser(self, username) :     
     155    def getUserFromBackend(self, username) :     
    167156        """Extracts user information given its name.""" 
    168157        user = StorageUser(self, username) 
     
    178167        return user 
    179168        
    180     def getGroup(self, groupname) :     
     169    def getGroupFromBackend(self, groupname) :     
    181170        """Extracts group information given its name.""" 
    182171        group = StorageGroup(self, groupname) 
     
    194183        return group 
    195184        
    196     def getPrinter(self, printername) :         
     185    def getPrinterFromBackend(self, printername) :         
    197186        """Extracts printer information given its name.""" 
    198187        printer = StoragePrinter(self, printername) 
     
    206195            printer.Exists = 1 
    207196        return printer     
    208              
    209     def getUserGroups(self, user) :         
    210         """Returns the user's groups list.""" 
    211         groups = [] 
    212         result = self.doSearch("SELECT groupname FROM groupsmembers JOIN groups ON groupsmembers.groupid=groups.id WHERE userid=%s" % self.doQuote(user.ident)) 
    213         if result : 
    214             for record in result : 
    215                 groups.append(self.getGroup(record.get("groupname"))) 
    216         return groups         
    217          
    218     def getGroupMembers(self, group) :         
    219         """Returns the group's members list.""" 
    220         groupmembers = [] 
    221         result = self.doSearch("SELECT * FROM groupsmembers JOIN users ON groupsmembers.userid=users.id WHERE groupid=%s" % self.doQuote(group.ident)) 
    222         if result : 
    223             for record in result : 
    224                 user = StorageUser(self, record.get("username")) 
    225                 user.ident = record.get("userid") 
    226                 user.LimitBy = record.get("limitby") 
    227                 user.AccountBalance = record.get("balance") 
    228                 user.LifeTimePaid = record.get("lifetimepaid") 
    229                 user.Email = record.get("email") 
    230                 user.Exists = 1 
    231                 groupmembers.append(user) 
    232         return groupmembers         
    233          
    234     def getUserPQuota(self, user, printer) :         
     197         
     198    def getUserPQuotaFromBackend(self, user, printer) :         
    235199        """Extracts a user print quota.""" 
    236200        userpquota = StorageUserPQuota(self, user, printer) 
     
    248212        return userpquota 
    249213         
    250     def getGroupPQuota(self, group, printer) :         
     214    def getGroupPQuotaFromBackend(self, group, printer) :         
    251215        """Extracts a group print quota.""" 
    252216        grouppquota = StorageGroupPQuota(self, group, printer) 
     
    267231        return grouppquota 
    268232         
    269     def getPrinterLastJob(self, printer) :         
     233    def getPrinterLastJobFromBackend(self, printer) :         
    270234        """Extracts a printer's last job information.""" 
    271235        lastjob = StorageLastJob(self, printer) 
     
    282246            lastjob.Exists = 1 
    283247        return lastjob 
     248             
     249    def getUserGroups(self, user) :         
     250        """Returns the user's groups list.""" 
     251        groups = [] 
     252        result = self.doSearch("SELECT groupname FROM groupsmembers JOIN groups ON groupsmembers.groupid=groups.id WHERE userid=%s" % self.doQuote(user.ident)) 
     253        if result : 
     254            for record in result : 
     255                groups.append(self.getGroup(record.get("groupname"))) 
     256        return groups         
     257         
     258    def getGroupMembers(self, group) :         
     259        """Returns the group's members list.""" 
     260        groupmembers = [] 
     261        result = self.doSearch("SELECT * FROM groupsmembers JOIN users ON groupsmembers.userid=users.id WHERE groupid=%s" % self.doQuote(group.ident)) 
     262        if result : 
     263            for record in result : 
     264                user = StorageUser(self, record.get("username")) 
     265                user.ident = record.get("userid") 
     266                user.LimitBy = record.get("limitby") 
     267                user.AccountBalance = record.get("balance") 
     268                user.LifeTimePaid = record.get("lifetimepaid") 
     269                user.Email = record.get("email") 
     270                user.Exists = 1 
     271                groupmembers.append(user) 
     272        return groupmembers         
    284273         
    285274    def getMatchingPrinters(self, printerpattern) : 
  • pykota/trunk/pykota/tool.py

    r1113 r1130  
    2121# 
    2222# $Log$ 
     23# Revision 1.50  2003/10/02 20:23:18  jalet 
     24# Storage caching mechanism added. 
     25# 
    2326# Revision 1.49  2003/07/29 20:55:17  jalet 
    2427# 1.14 is out ! 
     
    234237        self.config = config.PyKotaConfig("/etc/pykota") 
    235238        self.logger = logger.openLogger(self) 
     239        self.debug = self.config.getDebug() 
    236240        self.storage = storage.openConnection(self) 
    237241        self.smtpserver = self.config.getSMTPServer() 
     242         
     243    def logdebug(self, message) :     
     244        """Logs something to debug output if debug is enabled.""" 
     245        if self.debug : 
     246            self.logger.log_message(message, "debug") 
    238247         
    239248    def clean(self) :     
  • pykota/trunk/pykota/version.py

    r1123 r1130  
    2121# 
    2222 
    23 __version__ = "1.15alpha4_unofficial" 
     23__version__ = "1.15alpha5_unofficial" 
    2424 
    2525__doc__ = """PyKota : a complete Printing Quota Solution for CUPS and LPRng."""