imip-agent

imiptools/stores/database.py

1083:e4d943f37608
2016-03-09 Paul Boddie Added initial support for database-resident data stores. freebusy-collections
     1 #!/usr/bin/env python     2      3 """     4 A database store of calendar data.     5      6 Copyright (C) 2014, 2015, 2016 Paul Boddie <paul@boddie.org.uk>     7      8 This program is free software; you can redistribute it and/or modify it under     9 the terms of the GNU General Public License as published by the Free Software    10 Foundation; either version 3 of the License, or (at your option) any later    11 version.    12     13 This program is distributed in the hope that it will be useful, but WITHOUT    14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    15 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    16 details.    17     18 You should have received a copy of the GNU General Public License along with    19 this program.  If not, see <http://www.gnu.org/licenses/>.    20 """    21     22 from imiptools.stores import StoreBase, JournalBase    23     24 from datetime import datetime    25 from imiptools.data import parse_string, to_string    26 from imiptools.dates import format_datetime, get_datetime, to_timezone    27 from imiptools.period import FreeBusyPeriod, FreeBusyDatabaseCollection    28 from imiptools.sql import DatabaseOperations    29     30 class DatabaseStoreBase:    31     32     "A database store supporting user-specific locking."    33     34     def acquire_lock(self, user, timeout=None):    35         FileBase.acquire_lock(self, timeout, user)    36     37     def release_lock(self, user):    38         FileBase.release_lock(self, user)    39     40 class DatabaseStore(DatabaseStoreBase, StoreBase, DatabaseOperations):    41     42     "A database store of tabular free/busy data and objects."    43     44     def __init__(self, connection, paramstyle=None):    45         DatabaseOperations.__init__(self, paramstyle=paramstyle)    46         self.connection = connection    47         self.cursor = connection.cursor()    48     49     # User discovery.    50     51     def get_users(self):    52     53         "Return a list of users."    54     55         query = "select distinct store_user from freebusy"    56         self.cursor.execute(query)    57         return [r[0] for r in self.cursor.fetchall()]    58     59     # Event and event metadata access.    60     61     def get_events(self, user):    62     63         "Return a list of event identifiers."    64     65         columns = ["store_user", "status"]    66         values = [user, "active"]    67     68         query, values = self.get_query(    69             "select object_uid from objects :condition",    70             columns, values)    71     72         self.cursor.execute(query, values)    73         return [r[0] for r in self.cursor.fetchall()]    74     75     def get_all_events(self, user):    76     77         "Return a set of (uid, recurrenceid) tuples for all events."    78     79         query, values = self.get_query(    80             "select object_uid, null as object_recurrenceid from objects :condition "    81             "union all "    82             "select object_uid, object_recurrenceid from recurrences :condition",    83             ["store_user"], [user])    84     85         self.cursor.execute(query, values)    86         return self.cursor.fetchall()    87     88     def get_event_table(self, recurrenceid=None, dirname=None):    89     90         "Get the table providing events for any specified 'dirname'."    91     92         if recurrenceid:    93             return self.get_recurrence_table(dirname)    94         else:    95             return self.get_complete_event_table(dirname)    96     97     def get_event_table_filters(self, dirname=None):    98     99         "Get filter details for any specified 'dirname'."   100    101         if dirname == "cancellations":   102             return ["status"], ["cancelled"]   103         else:   104             return [], []   105    106     def get_event(self, user, uid, recurrenceid=None, dirname=None):   107    108         """   109         Get the event for the given 'user' with the given 'uid'. If   110         the optional 'recurrenceid' is specified, a specific instance or   111         occurrence of an event is returned.   112         """   113    114         table = self.get_event_table(recurrenceid, dirname)   115         columns, values = self.get_event_table_filters(dirname)   116    117         if recurrenceid:   118             columns += ["store_user", "object_uid", "object_recurrenceid"]   119             values += [user, uid, recurrenceid]   120         else:   121             columns += ["store_user", "object_uid"]   122             values += [user, uid]   123    124         query, values = self.get_query(   125             "select object_text from %(table)s :condition" % {   126                 "table" : table   127                 },   128             columns, values)   129    130         self.cursor.execute(query, values)   131         result = self.cursor.fetchone()   132         return result and parse_string(result[0], "utf-8")   133    134     def get_complete_event_table(self, dirname=None):   135    136         "Get the table providing events for any specified 'dirname'."   137    138         if dirname == "counters":   139             return "countered_objects"   140         else:   141             return "objects"   142    143     def get_complete_event(self, user, uid):   144    145         "Get the event for the given 'user' with the given 'uid'."   146    147         columns = ["store_user", "object_uid"]   148         values = [user, uid]   149    150         query, values = self.get_query(   151             "select object_text from objects :condition",   152             columns, values)   153    154         self.cursor.execute(query, values)   155         result = self.cursor.fetchone()   156         return result and parse_string(result[0], "utf-8")   157    158     def set_complete_event(self, user, uid, node):   159    160         "Set an event for 'user' having the given 'uid' and 'node'."   161    162         columns = ["store_user", "object_uid"]   163         values = [user, uid]   164         setcolumns = ["object_text", "status"]   165         setvalues = [to_string(node, "utf-8"), "active"]   166    167         query, values = self.get_query(   168             "update objects :set :condition",   169             columns, values, setcolumns, setvalues)   170    171         self.cursor.execute(query, values)   172    173         if self.cursor.rowcount > 0 or self.get_complete_event(user, uid):   174             return True   175    176         columns = ["store_user", "object_uid", "object_text", "status"]   177         values = [user, uid, to_string(node, "utf-8"), "active"]   178    179         query, values = self.get_query(   180             "insert into objects (:columns) values (:values)",   181             columns, values)   182    183         self.cursor.execute(query, values)   184         return True   185    186     def remove_parent_event(self, user, uid):   187    188         "Remove the parent event for 'user' having the given 'uid'."   189    190         columns = ["store_user", "object_uid"]   191         values = [user, uid]   192    193         query, values = self.get_query(   194             "delete from objects :condition",   195             columns, values)   196    197         self.cursor.execute(query, values)   198         return self.cursor.rowcount > 0   199    200     def get_active_recurrences(self, user, uid):   201    202         """   203         Get additional event instances for an event of the given 'user' with the   204         indicated 'uid'. Cancelled recurrences are not returned.   205         """   206    207         columns = ["store_user", "object_uid", "status"]   208         values = [user, uid, "active"]   209    210         query, values = self.get_query(   211             "select object_recurrenceid from recurrences :condition",   212             columns, values)   213    214         self.cursor.execute(query, values)   215         return [t[0] for t in self.cursor.fetchall() or []]   216    217     def get_cancelled_recurrences(self, user, uid):   218    219         """   220         Get additional event instances for an event of the given 'user' with the   221         indicated 'uid'. Only cancelled recurrences are returned.   222         """   223    224         columns = ["store_user", "object_uid", "status"]   225         values = [user, uid, "cancelled"]   226    227         query, values = self.get_query(   228             "select object_recurrenceid from recurrences :condition",   229             columns, values)   230    231         self.cursor.execute(query, values)   232         return [t[0] for t in self.cursor.fetchall() or []]   233    234     def get_recurrence_table(self, dirname=None):   235    236         "Get the table providing recurrences for any specified 'dirname'."   237    238         if dirname == "counters":   239             return "countered_recurrences"   240         else:   241             return "recurrences"   242    243     def get_recurrence(self, user, uid, recurrenceid):   244    245         """   246         For the event of the given 'user' with the given 'uid', return the   247         specific recurrence indicated by the 'recurrenceid'.   248         """   249    250         columns = ["store_user", "object_uid", "object_recurrenceid"]   251         values = [user, uid, recurrenceid]   252    253         query, values = self.get_query(   254             "select object_text from recurrences :condition",   255             columns, values)   256    257         self.cursor.execute(query, values)   258         result = self.cursor.fetchone()   259         return result and parse_string(result[0], "utf-8")   260    261     def set_recurrence(self, user, uid, recurrenceid, node):   262    263         "Set an event for 'user' having the given 'uid' and 'node'."   264    265         columns = ["store_user", "object_uid", "object_recurrenceid"]   266         values = [user, uid, recurrenceid]   267         setcolumns = ["object_text", "status"]   268         setvalues = [to_string(node, "utf-8"), "active"]   269    270         query, values = self.get_query(   271             "update recurrences :set :condition",   272             columns, values, setcolumns, setvalues)   273    274         self.cursor.execute(query, values)   275    276         if self.cursor.rowcount > 0 or self.get_recurrence(user, uid, recurrenceid):   277             return True   278    279         columns = ["store_user", "object_uid", "object_recurrenceid", "object_text", "status"]   280         values = [user, uid, recurrenceid, to_string(node, "utf-8"), "active"]   281    282         query, values = self.get_query(   283             "insert into recurrences (:columns) values (:values)",   284             columns, values)   285    286         self.cursor.execute(query, values)   287         return True   288    289     def remove_recurrence(self, user, uid, recurrenceid):   290    291         """   292         Remove a special recurrence from an event stored by 'user' having the   293         given 'uid' and 'recurrenceid'.   294         """   295    296         columns = ["store_user", "object_uid", "object_recurrenceid"]   297         values = [user, uid, recurrenceid]   298    299         query, values = self.get_query(   300             "delete from recurrences :condition",   301             columns, values)   302    303         self.cursor.execute(query, values)   304         return True   305    306     def remove_recurrences(self, user, uid):   307    308         """   309         Remove all recurrences for an event stored by 'user' having the given   310         'uid'.   311         """   312    313         columns = ["store_user", "object_uid"]   314         values = [user, uid]   315    316         query, values = self.get_query(   317             "delete from recurrences :condition",   318             columns, values)   319    320         self.cursor.execute(query, values)   321         return True   322    323     # Free/busy period providers, upon extension of the free/busy records.   324    325     def _get_freebusy_providers(self, user):   326    327         """   328         Return the free/busy providers for the given 'user'.   329    330         This function returns any stored datetime and a list of providers as a   331         2-tuple. Each provider is itself a (uid, recurrenceid) tuple.   332         """   333    334         columns = ["store_user"]   335         values = [user]   336    337         query, values = self.get_query(   338             "select object_uid, object_recurrenceid from freebusy_providers :condition",   339             columns, values)   340    341         self.cursor.execute(query, values)   342         providers = self.cursor.fetchall()   343    344         columns = ["store_user"]   345         values = [user]   346    347         query, values = self.get_query(   348             "select start from freebusy_provider_datetimes :condition",   349             columns, values)   350    351         self.cursor.execute(query, values)   352         result = self.cursor.fetchone()   353         dt_string = result and result[0]   354    355         return dt_string, providers   356    357     def _set_freebusy_providers(self, user, dt_string, t):   358    359         "Set the given provider timestamp 'dt_string' and table 't'."   360    361         # NOTE: Locking?   362    363         columns = ["store_user"]   364         values = [user]   365    366         query, values = self.get_query(   367             "delete from freebusy_providers :condition",   368             columns, values)   369    370         self.cursor.execute(query, values)   371    372         columns = ["store_user", "object_uid", "object_recurrenceid"]   373    374         for uid, recurrenceid in t:   375             values = [user, uid, recurrenceid]   376    377             query, values = self.get_query(   378                 "insert into freebusy_providers (:columns) values (:values)",   379                 columns, values)   380    381             self.cursor.execute(query, values)   382    383         columns = ["store_user"]   384         values = [user]   385         setcolumns = ["start"]   386         setvalues = [dt_string]   387    388         query, values = self.get_query(   389             "update freebusy_provider_datetimes :set :condition",   390             columns, values, setcolumns, setvalues)   391    392         self.cursor.execute(query, values)   393    394         if self.cursor.rowcount > 0:   395             return True   396    397         columns = ["store_user", "start"]   398         values = [user, dt_string]   399    400         query, values = self.get_query(   401             "insert into freebusy_provider_datetimes (:columns) values (:values)",   402             columns, values)   403    404         self.cursor.execute(query, values)   405         return True   406    407     # Free/busy period access.   408    409     def get_freebusy(self, user, name=None, mutable=False):   410    411         "Get free/busy details for the given 'user'."   412    413         table = name or "freebusy"   414         return FreeBusyDatabaseCollection(self.cursor, table, ["store_user"], [user], mutable, self.paramstyle)   415    416     def get_freebusy_for_other(self, user, other, mutable=False):   417    418         "For the given 'user', get free/busy details for the 'other' user."   419    420         table = "freebusy"   421         return FreeBusyDatabaseCollection(self.cursor, table, ["store_user", "other"], [user, other], mutable, self.paramstyle)   422    423     def set_freebusy(self, user, freebusy, name=None):   424    425         "For the given 'user', set 'freebusy' details."   426    427         table = name or "freebusy"   428    429         if not isinstance(freebusy, FreeBusyDatabaseCollection) or freebusy.table_name != table:   430             fbc = FreeBusyDatabaseCollection(self.cursor, table, ["store_user"], [user], True, self.paramstyle)   431             fbc += freebusy   432    433         return True   434    435     def set_freebusy_for_other(self, user, freebusy, other):   436    437         "For the given 'user', set 'freebusy' details for the 'other' user."   438    439         table = "freebusy"   440    441         if not isinstance(freebusy, FreeBusyDatabaseCollection) or freebusy.table_name != table:   442             fbc = FreeBusyDatabaseCollection(self.cursor, table, ["store_user", "other"], [user, other], True, self.paramstyle)   443             fbc += freebusy   444    445         return True   446    447     # Tentative free/busy periods related to countering.   448    449     def get_freebusy_offers(self, user, mutable=False):   450    451         "Get free/busy offers for the given 'user'."   452    453         # Expire old offers and save the collection if modified.   454    455         now = format_datetime(to_timezone(datetime.utcnow(), "UTC"))   456         columns = ["store_user", "expires"]   457         values = [user, now]   458    459         query, values = self.get_query(   460             "delete from freebusy_offers :condition",   461             columns, values)   462    463         self.cursor.execute(query, values)   464    465         return self.get_freebusy(user, "freebusy_offers", mutable)   466    467     # Requests and counter-proposals.   468    469     def get_requests(self, user):   470    471         "Get requests for the given 'user'."   472    473         columns = ["store_user"]   474         values = [user]   475    476         query, values = self.get_query(   477             "select object_uid, object_recurrenceid from requests :condition",   478             columns, values)   479    480         self.cursor.execute(query, values)   481         return self.cursor.fetchall()   482    483     def set_requests(self, user, requests):   484    485         "For the given 'user', set the list of queued 'requests'."   486    487         # NOTE: Locking?   488    489         columns = ["store_user"]   490         values = [user]   491    492         query, values = self.get_query(   493             "delete from requests :condition",   494             columns, values)   495    496         self.cursor.execute(query, values)   497    498         for uid, recurrenceid, type in requests:   499             columns = ["store_user", "object_uid", "object_recurrenceid", "request_type"]   500             values = [user, uid, recurrenceid, type]   501    502             query, values = self.get_query(   503                 "insert into requests (:columns) values (:values)",   504                 columns, values)   505    506             self.cursor.execute(query, values)   507    508         return True   509    510     def set_request(self, user, uid, recurrenceid=None, type=None):   511    512         """   513         For the given 'user', set the queued 'uid' and 'recurrenceid',   514         indicating a request, along with any given 'type'.   515         """   516    517         columns = ["store_user", "object_uid", "object_recurrenceid", "request_type"]   518         values = [user, uid, recurrenceid, type]   519    520         query, values = self.get_query(   521             "insert into requests (:columns) values (:values)",   522             columns, values)   523    524         self.cursor.execute(query, values)   525         return True   526    527     def get_counters(self, user, uid, recurrenceid=None):   528    529         """   530         For the given 'user', return a list of users from whom counter-proposals   531         have been received for the given 'uid' and optional 'recurrenceid'.   532         """   533    534         table = self.get_event_table(recurrenceid, "counters")   535    536         if recurrenceid:   537             columns = ["store_user", "object_uid", "object_recurrenceid"]   538             values = [user, uid, recurrenceid]   539         else:   540             columns = ["store_user", "object_uid"]   541             values = [user, uid]   542    543         query, values = self.get_query(   544             "select other from %(table)s :condition" % {   545                 "table" : table   546                 },   547             columns, values)   548    549         self.cursor.execute(query, values)   550         return self.cursor.fetchall()   551    552     def get_counter(self, user, other, uid, recurrenceid=None):   553    554         """   555         For the given 'user', return the counter-proposal from 'other' for the   556         given 'uid' and optional 'recurrenceid'.   557         """   558    559         table = self.get_event_table(recurrenceid, "counters")   560    561         if recurrenceid:   562             columns = ["store_user", "other", "object_uid", "object_recurrenceid"]   563             values = [user, other, uid, recurrenceid]   564         else:   565             columns = ["store_user", "other", "object_uid"]   566             values = [user, other, uid]   567    568         query, values = self.get_query(   569             "select object_text from %(table)s :condition" % {   570                 "table" : table   571                 },   572             columns, values)   573    574         self.cursor.execute(query, values)   575         result = self.cursor.fetchall()   576         return result and parse_string(result[0], "utf-8")   577    578     def set_counter(self, user, other, node, uid, recurrenceid=None):   579    580         """   581         For the given 'user', store a counter-proposal received from 'other' the   582         given 'node' representing that proposal for the given 'uid' and   583         'recurrenceid'.   584         """   585    586         table = self.get_event_table(recurrenceid, "counters")   587    588         columns = ["store_user", "other", "object_uid", "object_recurrenceid", "object_text"]   589         values = [user, other, uid, recurrenceid, to_string(node, "utf-8")]   590    591         query, values = self.get_query(   592             "insert into %(table)s (:columns) values (:values)" % {   593                 "table" : table   594                 },   595             columns, values)   596    597         self.cursor.execute(query, values)   598         return True   599    600     def remove_counters(self, user, uid, recurrenceid=None):   601    602         """   603         For the given 'user', remove all counter-proposals associated with the   604         given 'uid' and 'recurrenceid'.   605         """   606    607         table = self.get_event_table(recurrenceid, "counters")   608    609         if recurrenceid:   610             columns = ["store_user", "object_uid", "object_recurrenceid"]   611             values = [user, uid, recurrenceid]   612         else:   613             columns = ["store_user", "object_uid"]   614             values = [user, uid]   615    616         query, values = self.get_query(   617             "delete from %(table)s :condition" % {   618                 "table" : table   619                 },   620             columns, values)   621    622         self.cursor.execute(query, values)   623         return True   624    625     def remove_counter(self, user, other, uid, recurrenceid=None):   626    627         """   628         For the given 'user', remove any counter-proposal from 'other'   629         associated with the given 'uid' and 'recurrenceid'.   630         """   631    632         table = self.get_event_table(recurrenceid, "counters")   633    634         if recurrenceid:   635             columns = ["store_user", "other", "object_uid", "object_recurrenceid"]   636             values = [user, other, uid, recurrenceid]   637         else:   638             columns = ["store_user", "other", "object_uid"]   639             values = [user, other, uid]   640    641         query, values = self.get_query(   642             "delete from %(table)s :condition" % {   643                 "table" : table   644                 },   645             columns, values)   646    647         self.cursor.execute(query, values)   648         return True   649    650     # Event cancellation.   651    652     def cancel_event(self, user, uid, recurrenceid=None):   653    654         """   655         Cancel an event for 'user' having the given 'uid'. If the optional   656         'recurrenceid' is specified, a specific instance or occurrence of an   657         event is cancelled.   658         """   659    660         table = self.get_event_table(recurrenceid)   661    662         if recurrenceid:   663             columns = ["store_user", "object_uid", "object_recurrenceid"]   664             values = [user, uid, recurrenceid]   665         else:   666             columns = ["store_user", "object_uid"]   667             values = [user, uid]   668    669         setcolumns = ["status"]   670         setvalues = ["cancelled"]   671    672         query, values = self.get_query(   673             "update %(table)s :set :condition" % {   674                 "table" : table   675                 },   676             columns, values, setcolumns, setvalues)   677    678         self.cursor.execute(query, values)   679         return True   680    681     def uncancel_event(self, user, uid, recurrenceid=None):   682    683         """   684         Uncancel an event for 'user' having the given 'uid'. If the optional   685         'recurrenceid' is specified, a specific instance or occurrence of an   686         event is uncancelled.   687         """   688    689         table = self.get_event_table(recurrenceid)   690    691         if recurrenceid:   692             columns = ["store_user", "object_uid", "object_recurrenceid"]   693             values = [user, uid, recurrenceid]   694         else:   695             columns = ["store_user", "object_uid"]   696             values = [user, uid]   697    698         setcolumns = ["status"]   699         setvalues = ["active"]   700    701         query, values = self.get_query(   702             "update %(table)s :set :condition" % {   703                 "table" : table   704                 },   705             columns, values, setcolumns, setvalues)   706    707         self.cursor.execute(query, values)   708         return True   709    710     def remove_cancellation(self, user, uid, recurrenceid=None):   711    712         """   713         Remove a cancellation for 'user' for the event having the given 'uid'.   714         If the optional 'recurrenceid' is specified, a specific instance or   715         occurrence of an event is affected.   716         """   717    718         table = self.get_event_table(recurrenceid)   719    720         if recurrenceid:   721             columns = ["store_user", "object_uid", "object_recurrenceid", "status"]   722             values = [user, uid, recurrenceid, "cancelled"]   723         else:   724             columns = ["store_user", "object_uid", "status"]   725             values = [user, uid, "cancelled"]   726    727         query, values = self.get_query(   728             "delete from %(table)s :condition" % {   729                 "table" : table   730                 },   731             columns, values)   732    733         self.cursor.execute(query, values)   734         return True   735    736 class DatabaseJournal(DatabaseStoreBase, JournalBase):   737    738     "A journal system to support quotas."   739    740     # Quota and user identity/group discovery.   741    742     def get_quotas(self):   743    744         "Return a list of quotas."   745    746         query = "select distinct journal_quota from quota_freebusy"   747         self.cursor.execute(query)   748         return [r[0] for r in self.cursor.fetchall()]   749    750     def get_quota_users(self, quota):   751    752         "Return a list of quota users."   753    754         columns = ["quota"]   755         values = [quota]   756    757         query, values = self.get_query(   758             "select distinct user_group from quota_freebusy :condition",   759             columns, values)   760    761         self.cursor.execute(query)   762         return [r[0] for r in self.cursor.fetchall()]   763    764     # Groups of users sharing quotas.   765    766     def get_groups(self, quota):   767    768         "Return the identity mappings for the given 'quota' as a dictionary."   769    770         columns = ["quota"]   771         values = [quota]   772    773         query, values = self.get_query(   774             "select store_user, user_group from user_groups :condition",   775             columns, values)   776    777         self.cursor.execute(query)   778         return dict(self.cursor.fetchall())   779    780     def get_limits(self, quota):   781    782         """   783         Return the limits for the 'quota' as a dictionary mapping identities or   784         groups to durations.   785         """   786    787         columns = ["quota"]   788         values = [quota]   789    790         query, values = self.get_query(   791             "select user_group, quota_limit from quota_limits :condition",   792             columns, values)   793    794         self.cursor.execute(query)   795         return dict(self.cursor.fetchall())   796    797     # Free/busy period access for users within quota groups.   798    799     def get_freebusy(self, quota, user, mutable=False):   800    801         "Get free/busy details for the given 'quota' and 'user'."   802    803         table = "user_freebusy"   804         return FreeBusyDatabaseCollection(self.cursor, table, ["quota", "store_user"], [quota, user], mutable, self.paramstyle)   805    806     def set_freebusy(self, quota, user, freebusy):   807    808         "For the given 'quota' and 'user', set 'freebusy' details."   809    810         table = "user_freebusy"   811    812         if not isinstance(freebusy, FreeBusyDatabaseCollection) or freebusy.table_name != table:   813             fbc = FreeBusyDatabaseCollection(self.cursor, table, ["quota", "store_user"], [quota, user], True, self.paramstyle)   814             fbc += freebusy   815    816         return True   817    818     # Journal entry methods.   819    820     def get_entries(self, quota, group, mutable=False):   821    822         """   823         Return a list of journal entries for the given 'quota' for the indicated   824         'group'.   825         """   826    827         table = "quota_freebusy"   828         return FreeBusyDatabaseCollection(self.cursor, table, ["quota", "user_group"], [quota, group], mutable, self.paramstyle)   829    830     def set_entries(self, quota, group, entries):   831    832         """   833         For the given 'quota' and indicated 'group', set the list of journal   834         'entries'.   835         """   836    837         table = "quota_freebusy"   838    839         if not isinstance(entries, FreeBusyDatabaseCollection) or entries.table_name != table:   840             fbc = FreeBusyDatabaseCollection(self.cursor, table, ["quota", "user_group"], [quota, group], True, self.paramstyle)   841             fbc += entries   842    843         return True   844    845 # vim: tabstop=4 expandtab shiftwidth=4