1 #!/usr/bin/env python 2 3 """ 4 A simple filesystem-based 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, PublisherBase, JournalBase 23 24 from datetime import datetime 25 from imiptools.config import STORE_DIR, PUBLISH_DIR, JOURNAL_DIR 26 from imiptools.data import make_calendar, parse_object, to_stream 27 from imiptools.dates import format_datetime, get_datetime, to_timezone 28 from imiptools.filesys import fix_permissions, FileBase 29 from imiptools.period import FreeBusyPeriod 30 from imiptools.text import parse_line 31 from os.path import isdir, isfile, join 32 from os import listdir, remove, rmdir 33 import codecs 34 35 class FileStoreBase(FileBase): 36 37 "A file store supporting user-specific locking and tabular data." 38 39 def acquire_lock(self, user, timeout=None): 40 FileBase.acquire_lock(self, timeout, user) 41 42 def release_lock(self, user): 43 FileBase.release_lock(self, user) 44 45 # Utility methods. 46 47 def _set_defaults(self, t, empty_defaults): 48 for i, default in empty_defaults: 49 if i >= len(t): 50 t += [None] * (i - len(t) + 1) 51 if not t[i]: 52 t[i] = default 53 return t 54 55 def _get_table(self, user, filename, empty_defaults=None, tab_separated=True): 56 57 """ 58 From the file for the given 'user' having the given 'filename', return 59 a list of tuples representing the file's contents. 60 61 The 'empty_defaults' is a list of (index, value) tuples indicating the 62 default value where a column either does not exist or provides an empty 63 value. 64 65 If 'tab_separated' is specified and is a false value, line parsing using 66 the imiptools.text.parse_line function will be performed instead of 67 splitting each line of the file using tab characters as separators. 68 """ 69 70 f = codecs.open(filename, "rb", encoding="utf-8") 71 try: 72 l = [] 73 for line in f.readlines(): 74 line = line.strip(" \r\n") 75 if tab_separated: 76 t = line.split("\t") 77 else: 78 t = parse_line(line) 79 if empty_defaults: 80 t = self._set_defaults(t, empty_defaults) 81 l.append(tuple(t)) 82 return l 83 finally: 84 f.close() 85 86 def _get_table_atomic(self, user, filename, empty_defaults=None, tab_separated=True): 87 88 """ 89 From the file for the given 'user' having the given 'filename', return 90 a list of tuples representing the file's contents. 91 92 The 'empty_defaults' is a list of (index, value) tuples indicating the 93 default value where a column either does not exist or provides an empty 94 value. 95 96 If 'tab_separated' is specified and is a false value, line parsing using 97 the imiptools.text.parse_line function will be performed instead of 98 splitting each line of the file using tab characters as separators. 99 """ 100 101 self.acquire_lock(user) 102 try: 103 return self._get_table(user, filename, empty_defaults, tab_separated) 104 finally: 105 self.release_lock(user) 106 107 def _set_table(self, user, filename, items, empty_defaults=None): 108 109 """ 110 For the given 'user', write to the file having the given 'filename' the 111 'items'. 112 113 The 'empty_defaults' is a list of (index, value) tuples indicating the 114 default value where a column either does not exist or provides an empty 115 value. 116 """ 117 118 f = codecs.open(filename, "wb", encoding="utf-8") 119 try: 120 for item in items: 121 self._set_table_item(f, item, empty_defaults) 122 finally: 123 f.close() 124 fix_permissions(filename) 125 126 def _set_table_item(self, f, item, empty_defaults=None): 127 128 "Set in table 'f' the given 'item', using any 'empty_defaults'." 129 130 if empty_defaults: 131 item = self._set_defaults(list(item), empty_defaults) 132 f.write("\t".join(item) + "\n") 133 134 def _set_table_atomic(self, user, filename, items, empty_defaults=None): 135 136 """ 137 For the given 'user', write to the file having the given 'filename' the 138 'items'. 139 140 The 'empty_defaults' is a list of (index, value) tuples indicating the 141 default value where a column either does not exist or provides an empty 142 value. 143 """ 144 145 self.acquire_lock(user) 146 try: 147 self._set_table(user, filename, items, empty_defaults) 148 finally: 149 self.release_lock(user) 150 151 class FileStore(FileStoreBase, StoreBase): 152 153 "A file store of tabular free/busy data and objects." 154 155 def __init__(self, store_dir=None): 156 FileBase.__init__(self, store_dir or STORE_DIR) 157 158 # Store object access. 159 160 def _get_object(self, user, filename): 161 162 """ 163 Return the parsed object for the given 'user' having the given 164 'filename'. 165 """ 166 167 self.acquire_lock(user) 168 try: 169 f = open(filename, "rb") 170 try: 171 return parse_object(f, "utf-8") 172 finally: 173 f.close() 174 finally: 175 self.release_lock(user) 176 177 def _set_object(self, user, filename, node): 178 179 """ 180 Set an object for the given 'user' having the given 'filename', using 181 'node' to define the object. 182 """ 183 184 self.acquire_lock(user) 185 try: 186 f = open(filename, "wb") 187 try: 188 to_stream(f, node) 189 finally: 190 f.close() 191 fix_permissions(filename) 192 finally: 193 self.release_lock(user) 194 195 return True 196 197 def _remove_object(self, filename): 198 199 "Remove the object with the given 'filename'." 200 201 try: 202 remove(filename) 203 except OSError: 204 return False 205 206 return True 207 208 def _remove_collection(self, filename): 209 210 "Remove the collection with the given 'filename'." 211 212 try: 213 rmdir(filename) 214 except OSError: 215 return False 216 217 return True 218 219 # User discovery. 220 221 def get_users(self): 222 223 "Return a list of users." 224 225 return listdir(self.store_dir) 226 227 # Event and event metadata access. 228 229 def get_events(self, user): 230 231 "Return a list of event identifiers." 232 233 filename = self.get_object_in_store(user, "objects") 234 if not filename or not isdir(filename): 235 return None 236 237 return [name for name in listdir(filename) if isfile(join(filename, name))] 238 239 def get_event_filename(self, user, uid, recurrenceid=None, dirname=None, username=None): 240 241 """ 242 Get the filename providing the event for the given 'user' with the given 243 'uid'. If the optional 'recurrenceid' is specified, a specific instance 244 or occurrence of an event is returned. 245 246 Where 'dirname' is specified, the given directory name is used as the 247 base of the location within which any filename will reside. 248 """ 249 250 if recurrenceid: 251 return self.get_recurrence_filename(user, uid, recurrenceid, dirname, username) 252 else: 253 return self.get_complete_event_filename(user, uid, dirname, username) 254 255 def get_event(self, user, uid, recurrenceid=None, dirname=None): 256 257 """ 258 Get the event for the given 'user' with the given 'uid'. If 259 the optional 'recurrenceid' is specified, a specific instance or 260 occurrence of an event is returned. 261 """ 262 263 filename = self.get_event_filename(user, uid, recurrenceid, dirname) 264 if not filename or not isfile(filename): 265 return None 266 267 return filename and self._get_object(user, filename) 268 269 def get_complete_event_filename(self, user, uid, dirname=None, username=None): 270 271 """ 272 Get the filename providing the event for the given 'user' with the given 273 'uid'. 274 275 Where 'dirname' is specified, the given directory name is used as the 276 base of the location within which any filename will reside. 277 278 Where 'username' is specified, the event details will reside in a file 279 bearing that name within a directory having 'uid' as its name. 280 """ 281 282 return self.get_object_in_store(user, dirname, "objects", uid, username) 283 284 def get_complete_event(self, user, uid): 285 286 "Get the event for the given 'user' with the given 'uid'." 287 288 filename = self.get_complete_event_filename(user, uid) 289 if not filename or not isfile(filename): 290 return None 291 292 return filename and self._get_object(user, filename) 293 294 def set_complete_event(self, user, uid, node): 295 296 "Set an event for 'user' having the given 'uid' and 'node'." 297 298 filename = self.get_object_in_store(user, "objects", uid) 299 if not filename: 300 return False 301 302 return self._set_object(user, filename, node) 303 304 def remove_parent_event(self, user, uid): 305 306 "Remove the parent event for 'user' having the given 'uid'." 307 308 filename = self.get_object_in_store(user, "objects", uid) 309 if not filename: 310 return False 311 312 return self._remove_object(filename) 313 314 def get_recurrences(self, user, uid): 315 316 """ 317 Get additional event instances for an event of the given 'user' with the 318 indicated 'uid'. Both active and cancelled recurrences are returned. 319 """ 320 321 return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid) 322 323 def get_active_recurrences(self, user, uid): 324 325 """ 326 Get additional event instances for an event of the given 'user' with the 327 indicated 'uid'. Cancelled recurrences are not returned. 328 """ 329 330 filename = self.get_object_in_store(user, "recurrences", uid) 331 if not filename or not isdir(filename): 332 return [] 333 334 return [name for name in listdir(filename) if isfile(join(filename, name))] 335 336 def get_cancelled_recurrences(self, user, uid): 337 338 """ 339 Get additional event instances for an event of the given 'user' with the 340 indicated 'uid'. Only cancelled recurrences are returned. 341 """ 342 343 filename = self.get_object_in_store(user, "cancellations", "recurrences", uid) 344 if not filename or not isdir(filename): 345 return [] 346 347 return [name for name in listdir(filename) if isfile(join(filename, name))] 348 349 def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None, username=None): 350 351 """ 352 For the event of the given 'user' with the given 'uid', return the 353 filename providing the recurrence with the given 'recurrenceid'. 354 355 Where 'dirname' is specified, the given directory name is used as the 356 base of the location within which any filename will reside. 357 358 Where 'username' is specified, the event details will reside in a file 359 bearing that name within a directory having 'uid' as its name. 360 """ 361 362 return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid, username) 363 364 def get_recurrence(self, user, uid, recurrenceid): 365 366 """ 367 For the event of the given 'user' with the given 'uid', return the 368 specific recurrence indicated by the 'recurrenceid'. 369 """ 370 371 filename = self.get_recurrence_filename(user, uid, recurrenceid) 372 if not filename or not isfile(filename): 373 return None 374 375 return filename and self._get_object(user, filename) 376 377 def set_recurrence(self, user, uid, recurrenceid, node): 378 379 "Set an event for 'user' having the given 'uid' and 'node'." 380 381 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 382 if not filename: 383 return False 384 385 return self._set_object(user, filename, node) 386 387 def remove_recurrence(self, user, uid, recurrenceid): 388 389 """ 390 Remove a special recurrence from an event stored by 'user' having the 391 given 'uid' and 'recurrenceid'. 392 """ 393 394 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 395 if not filename: 396 return False 397 398 return self._remove_object(filename) 399 400 def remove_recurrence_collection(self, user, uid): 401 402 """ 403 Remove the collection of recurrences stored by 'user' having the given 404 'uid'. 405 """ 406 407 recurrences = self.get_object_in_store(user, "recurrences", uid) 408 if recurrences: 409 return self._remove_collection(recurrences) 410 411 return True 412 413 # Free/busy period providers, upon extension of the free/busy records. 414 415 def _get_freebusy_providers(self, user): 416 417 """ 418 Return the free/busy providers for the given 'user'. 419 420 This function returns any stored datetime and a list of providers as a 421 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. 422 """ 423 424 filename = self.get_object_in_store(user, "freebusy-providers") 425 if not filename or not isfile(filename): 426 return None 427 428 # Attempt to read providers, with a declaration of the datetime 429 # from which such providers are considered as still being active. 430 431 t = self._get_table_atomic(user, filename, [(1, None)]) 432 try: 433 dt_string = t[0][0] 434 except IndexError: 435 return None 436 437 return dt_string, t[1:] 438 439 def _set_freebusy_providers(self, user, dt_string, t): 440 441 "Set the given provider timestamp 'dt_string' and table 't'." 442 443 filename = self.get_object_in_store(user, "freebusy-providers") 444 if not filename: 445 return False 446 447 t.insert(0, (dt_string,)) 448 self._set_table_atomic(user, filename, t, [(1, "")]) 449 return True 450 451 # Free/busy period access. 452 453 def get_freebusy(self, user, name=None): 454 455 "Get free/busy details for the given 'user'." 456 457 filename = self.get_object_in_store(user, name or "freebusy") 458 if not filename or not isfile(filename): 459 return [] 460 else: 461 return map(lambda t: FreeBusyPeriod(*t), 462 self._get_table_atomic(user, filename)) 463 464 def get_freebusy_for_other(self, user, other): 465 466 "For the given 'user', get free/busy details for the 'other' user." 467 468 filename = self.get_object_in_store(user, "freebusy-other", other) 469 if not filename or not isfile(filename): 470 return [] 471 else: 472 return map(lambda t: FreeBusyPeriod(*t), 473 self._get_table_atomic(user, filename)) 474 475 def set_freebusy(self, user, freebusy, name=None): 476 477 "For the given 'user', set 'freebusy' details." 478 479 filename = self.get_object_in_store(user, name or "freebusy") 480 if not filename: 481 return False 482 483 self._set_table_atomic(user, filename, 484 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 485 return True 486 487 def set_freebusy_for_other(self, user, freebusy, other): 488 489 "For the given 'user', set 'freebusy' details for the 'other' user." 490 491 filename = self.get_object_in_store(user, "freebusy-other", other) 492 if not filename: 493 return False 494 495 self._set_table_atomic(user, filename, 496 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 497 return True 498 499 # Tentative free/busy periods related to countering. 500 501 def get_freebusy_offers(self, user): 502 503 "Get free/busy offers for the given 'user'." 504 505 offers = [] 506 expired = [] 507 now = to_timezone(datetime.utcnow(), "UTC") 508 509 # Expire old offers and save the collection if modified. 510 511 self.acquire_lock(user) 512 try: 513 l = self.get_freebusy(user, "freebusy-offers") 514 for fb in l: 515 if fb.expires and get_datetime(fb.expires) <= now: 516 expired.append(fb) 517 else: 518 offers.append(fb) 519 520 if expired: 521 self.set_freebusy_offers(user, offers) 522 finally: 523 self.release_lock(user) 524 525 return offers 526 527 # Requests and counter-proposals. 528 529 def _get_requests(self, user, queue): 530 531 "Get requests for the given 'user' from the given 'queue'." 532 533 filename = self.get_object_in_store(user, queue) 534 if not filename or not isfile(filename): 535 return None 536 537 return self._get_table_atomic(user, filename, [(1, None), (2, None)]) 538 539 def get_requests(self, user): 540 541 "Get requests for the given 'user'." 542 543 return self._get_requests(user, "requests") 544 545 def _set_requests(self, user, requests, queue): 546 547 """ 548 For the given 'user', set the list of queued 'requests' in the given 549 'queue'. 550 """ 551 552 filename = self.get_object_in_store(user, queue) 553 if not filename: 554 return False 555 556 self._set_table_atomic(user, filename, requests, [(1, ""), (2, "")]) 557 return True 558 559 def set_requests(self, user, requests): 560 561 "For the given 'user', set the list of queued 'requests'." 562 563 return self._set_requests(user, requests, "requests") 564 565 def _set_request(self, user, request, queue): 566 567 """ 568 For the given 'user', set the given 'request' in the given 'queue'. 569 """ 570 571 filename = self.get_object_in_store(user, queue) 572 if not filename: 573 return False 574 575 self.acquire_lock(user) 576 try: 577 f = codecs.open(filename, "ab", encoding="utf-8") 578 try: 579 self._set_table_item(f, request, [(1, ""), (2, "")]) 580 finally: 581 f.close() 582 fix_permissions(filename) 583 finally: 584 self.release_lock(user) 585 586 return True 587 588 def set_request(self, user, uid, recurrenceid=None, type=None): 589 590 """ 591 For the given 'user', set the queued 'uid' and 'recurrenceid', 592 indicating a request, along with any given 'type'. 593 """ 594 595 return self._set_request(user, (uid, recurrenceid, type), "requests") 596 597 def get_counters(self, user, uid, recurrenceid=None): 598 599 """ 600 For the given 'user', return a list of users from whom counter-proposals 601 have been received for the given 'uid' and optional 'recurrenceid'. 602 """ 603 604 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 605 if not filename or not isdir(filename): 606 return False 607 608 return [name for name in listdir(filename) if isfile(join(filename, name))] 609 610 def get_counter(self, user, other, uid, recurrenceid=None): 611 612 """ 613 For the given 'user', return the counter-proposal from 'other' for the 614 given 'uid' and optional 'recurrenceid'. 615 """ 616 617 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 618 if not filename: 619 return False 620 621 return self._get_object(user, filename) 622 623 def set_counter(self, user, other, node, uid, recurrenceid=None): 624 625 """ 626 For the given 'user', store a counter-proposal received from 'other' the 627 given 'node' representing that proposal for the given 'uid' and 628 'recurrenceid'. 629 """ 630 631 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 632 if not filename: 633 return False 634 635 return self._set_object(user, filename, node) 636 637 def remove_counters(self, user, uid, recurrenceid=None): 638 639 """ 640 For the given 'user', remove all counter-proposals associated with the 641 given 'uid' and 'recurrenceid'. 642 """ 643 644 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 645 if not filename or not isdir(filename): 646 return False 647 648 removed = False 649 650 for other in listdir(filename): 651 counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 652 removed = removed or self._remove_object(counter_filename) 653 654 return removed 655 656 def remove_counter(self, user, other, uid, recurrenceid=None): 657 658 """ 659 For the given 'user', remove any counter-proposal from 'other' 660 associated with the given 'uid' and 'recurrenceid'. 661 """ 662 663 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 664 if not filename or not isfile(filename): 665 return False 666 667 return self._remove_object(filename) 668 669 # Event cancellation. 670 671 def cancel_event(self, user, uid, recurrenceid=None): 672 673 """ 674 Cancel an event for 'user' having the given 'uid'. If the optional 675 'recurrenceid' is specified, a specific instance or occurrence of an 676 event is cancelled. 677 """ 678 679 filename = self.get_event_filename(user, uid, recurrenceid) 680 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 681 682 if filename and cancelled_filename and isfile(filename): 683 return self.move_object(filename, cancelled_filename) 684 685 return False 686 687 def uncancel_event(self, user, uid, recurrenceid=None): 688 689 """ 690 Uncancel an event for 'user' having the given 'uid'. If the optional 691 'recurrenceid' is specified, a specific instance or occurrence of an 692 event is uncancelled. 693 """ 694 695 filename = self.get_event_filename(user, uid, recurrenceid) 696 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 697 698 if filename and cancelled_filename and isfile(cancelled_filename): 699 return self.move_object(cancelled_filename, filename) 700 701 return False 702 703 def remove_cancellation(self, user, uid, recurrenceid=None): 704 705 """ 706 Remove a cancellation for 'user' for the event having the given 'uid'. 707 If the optional 'recurrenceid' is specified, a specific instance or 708 occurrence of an event is affected. 709 """ 710 711 # Remove any parent event cancellation or a specific recurrence 712 # cancellation if indicated. 713 714 filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 715 716 if filename and isfile(filename): 717 return self._remove_object(filename) 718 719 return False 720 721 class FilePublisher(FileBase, PublisherBase): 722 723 "A publisher of objects." 724 725 def __init__(self, store_dir=None): 726 FileBase.__init__(self, store_dir or PUBLISH_DIR) 727 728 def set_freebusy(self, user, freebusy): 729 730 "For the given 'user', set 'freebusy' details." 731 732 filename = self.get_object_in_store(user, "freebusy") 733 if not filename: 734 return False 735 736 record = [] 737 rwrite = record.append 738 739 rwrite(("ORGANIZER", {}, user)) 740 rwrite(("UID", {}, user)) 741 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 742 743 for fb in freebusy: 744 if not fb.transp or fb.transp == "OPAQUE": 745 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 746 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 747 748 f = open(filename, "wb") 749 try: 750 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 751 finally: 752 f.close() 753 fix_permissions(filename) 754 755 return True 756 757 class FileJournal(FileStoreBase, JournalBase): 758 759 "A journal system to support quotas." 760 761 def __init__(self, store_dir=None): 762 FileBase.__init__(self, store_dir or JOURNAL_DIR) 763 764 # Quota and user identity/group discovery. 765 766 def get_quotas(self): 767 768 "Return a list of quotas." 769 770 return listdir(self.store_dir) 771 772 def get_quota_users(self, quota): 773 774 "Return a list of quota users." 775 776 filename = self.get_object_in_store(quota, "journal") 777 if not filename or not isdir(filename): 778 return [] 779 780 return listdir(filename) 781 782 # Groups of users sharing quotas. 783 784 def get_groups(self, quota): 785 786 "Return the identity mappings for the given 'quota' as a dictionary." 787 788 filename = self.get_object_in_store(quota, "groups") 789 if not filename or not isfile(filename): 790 return {} 791 792 return dict(self._get_table_atomic(quota, filename, tab_separated=False)) 793 794 def get_limits(self, quota): 795 796 """ 797 Return the limits for the 'quota' as a dictionary mapping identities or 798 groups to durations. 799 """ 800 801 filename = self.get_object_in_store(quota, "limits") 802 if not filename or not isfile(filename): 803 return None 804 805 return dict(self._get_table_atomic(quota, filename, tab_separated=False)) 806 807 # Free/busy period access for users within quota groups. 808 809 def get_freebusy(self, quota, user): 810 811 "Get free/busy details for the given 'quota' and 'user'." 812 813 filename = self.get_object_in_store(quota, "freebusy", user) 814 if not filename or not isfile(filename): 815 return [] 816 817 return map(lambda t: FreeBusyPeriod(*t), 818 self._get_table_atomic(quota, filename)) 819 820 def set_freebusy(self, quota, user, freebusy): 821 822 "For the given 'quota' and 'user', set 'freebusy' details." 823 824 filename = self.get_object_in_store(quota, "freebusy", user) 825 if not filename: 826 return False 827 828 self._set_table_atomic(quota, filename, 829 map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) 830 return True 831 832 # Journal entry methods. 833 834 def get_entries(self, quota, group): 835 836 """ 837 Return a list of journal entries for the given 'quota' for the indicated 838 'group'. 839 """ 840 841 filename = self.get_object_in_store(quota, "journal", group) 842 if not filename or not isfile(filename): 843 return [] 844 845 return map(lambda t: FreeBusyPeriod(*t), 846 self._get_table_atomic(quota, filename)) 847 848 def set_entries(self, quota, group, entries): 849 850 """ 851 For the given 'quota' and indicated 'group', set the list of journal 852 'entries'. 853 """ 854 855 filename = self.get_object_in_store(quota, "journal", group) 856 if not filename: 857 return False 858 859 self._set_table_atomic(quota, filename, 860 map(lambda fb: fb.as_tuple(strings_only=True), entries)) 861 return True 862 863 # vim: tabstop=4 expandtab shiftwidth=4