1 #!/usr/bin/env python 2 3 """ 4 Web interface data abstractions. 5 6 Copyright (C) 2014, 2015, 2017 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 datetime import datetime, timedelta 23 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ 24 format_datetime, get_datetime, \ 25 get_datetime_attributes, get_end_of_day, \ 26 to_date, to_utc_datetime, to_timezone 27 from imiptools.period import RecurringPeriod 28 29 class PeriodError(Exception): 30 pass 31 32 class EventPeriod(RecurringPeriod): 33 34 """ 35 A simple period plus attribute details, compatible with RecurringPeriod, and 36 intended to represent information obtained from an iCalendar resource. 37 """ 38 39 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, 40 end_attr=None, form_start=None, form_end=None, 41 replacement=False, cancelled=False, recurrenceid=None): 42 43 """ 44 Initialise a period with the given 'start' and 'end' datetimes. 45 46 The optional 'tzid' provides time zone information, and the optional 47 'origin' indicates the kind of period this object describes. 48 49 The optional 'start_attr' and 'end_attr' provide metadata for the start 50 and end datetimes respectively, and 'form_start' and 'form_end' are 51 values provided as textual input. 52 53 The 'replacement' flag indicates whether the period is provided by a 54 separate recurrence instance. 55 56 The 'cancelled' flag indicates whether a separate recurrence is 57 cancelled. 58 59 The 'recurrenceid' describes the original identity of the period, 60 regardless of whether it is separate or not. 61 """ 62 63 RecurringPeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 64 self.form_start = form_start 65 self.form_end = form_end 66 67 # Information about whether a separate recurrence provides this period 68 # and the original period identity. 69 70 self.replacement = replacement 71 self.cancelled = cancelled 72 self.recurrenceid = recurrenceid 73 74 def as_tuple(self): 75 return self.start, self.end, self.tzid, self.origin, self.start_attr, \ 76 self.end_attr, self.form_start, self.form_end, self.replacement, \ 77 self.cancelled, self.recurrenceid 78 79 def __repr__(self): 80 return "EventPeriod%r" % (self.as_tuple(),) 81 82 def copy(self): 83 return EventPeriod(*self.as_tuple()) 84 85 def _get_recurrenceid_item(self): 86 87 # Convert any stored identifier to the current time zone. 88 # NOTE: This should not be necessary, but is done for consistency with 89 # NOTE: the datetime properties. 90 91 dt = get_datetime(self.recurrenceid) 92 dt = to_timezone(dt, self.tzid) 93 return dt, get_datetime_attributes(dt) 94 95 def get_recurrenceid(self): 96 if not self.recurrenceid: 97 return RecurringPeriod.get_recurrenceid(self) 98 return self.recurrenceid 99 100 def get_recurrenceid_item(self): 101 if not self.recurrenceid: 102 return RecurringPeriod.get_recurrenceid_item(self) 103 return self._get_recurrenceid_item() 104 105 def as_event_period(self): 106 return self 107 108 def get_start_item(self): 109 return self.get_start(), self.get_start_attr() 110 111 def get_end_item(self): 112 return self.get_end(), self.get_end_attr() 113 114 # Form data compatibility methods. 115 116 def get_form_start(self): 117 if not self.form_start: 118 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 119 return self.form_start 120 121 def get_form_end(self): 122 if not self.form_end: 123 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 124 return self.form_end 125 126 def as_form_period(self): 127 return FormPeriod( 128 self.get_form_start(), 129 self.get_form_end(), 130 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 131 isinstance(self.start, datetime) or isinstance(self.end, datetime), 132 self.tzid, 133 self.origin, 134 self.replacement, 135 self.cancelled, 136 self.recurrenceid 137 ) 138 139 def get_form_date(self, dt, attr=None): 140 return FormDate( 141 format_datetime(to_date(dt)), 142 isinstance(dt, datetime) and str(dt.hour) or None, 143 isinstance(dt, datetime) and str(dt.minute) or None, 144 isinstance(dt, datetime) and str(dt.second) or None, 145 attr and attr.get("TZID") or None, 146 dt, attr 147 ) 148 149 class FormPeriod(RecurringPeriod): 150 151 "A period whose information originates from a form." 152 153 def __init__(self, start, end, end_enabled=True, times_enabled=True, 154 tzid=None, origin=None, replacement=False, cancelled=False, 155 recurrenceid=None): 156 self.start = start 157 self.end = end 158 self.end_enabled = end_enabled 159 self.times_enabled = times_enabled 160 self.tzid = tzid 161 self.origin = origin 162 self.replacement = replacement 163 self.cancelled = cancelled 164 self.recurrenceid = recurrenceid 165 166 def as_tuple(self): 167 return self.start, self.end, self.end_enabled, self.times_enabled, \ 168 self.tzid, self.origin, self.replacement, self.cancelled, \ 169 self.recurrenceid 170 171 def __repr__(self): 172 return "FormPeriod%r" % (self.as_tuple(),) 173 174 def copy(self): 175 return FormPeriod(*self.as_tuple()) 176 177 def as_event_period(self, index=None): 178 179 """ 180 Return a converted version of this object as an event period suitable 181 for iCalendar usage. If 'index' is indicated, include it in any error 182 raised in the conversion process. 183 """ 184 185 dtstart, dtstart_attr = self.get_start_item() 186 if not dtstart: 187 if index is not None: 188 raise PeriodError(("dtstart", index)) 189 else: 190 raise PeriodError("dtstart") 191 192 dtend, dtend_attr = self.get_end_item() 193 if not dtend: 194 if index is not None: 195 raise PeriodError(("dtend", index)) 196 else: 197 raise PeriodError("dtend") 198 199 if dtstart > dtend: 200 if index is not None: 201 raise PeriodError(("dtstart", index), ("dtend", index)) 202 else: 203 raise PeriodError("dtstart", "dtend") 204 205 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 206 self.origin, dtstart_attr, dtend_attr, 207 self.start, self.end, self.replacement, 208 self.cancelled, self.recurrenceid) 209 210 # Period data methods. 211 212 def get_start(self): 213 return self.start and self.start.as_datetime(self.times_enabled) or None 214 215 def get_end(self): 216 217 # Handle specified end datetimes. 218 219 if self.end_enabled: 220 dtend = self.end.as_datetime(self.times_enabled) 221 if not dtend: 222 return None 223 224 # Handle same day times. 225 226 elif self.times_enabled: 227 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 228 dtend = formdate.as_datetime(self.times_enabled) 229 if not dtend: 230 return None 231 232 # Otherwise, treat the end date as the start date. Datetimes are 233 # handled by making the event occupy the rest of the day. 234 235 else: 236 dtstart, dtstart_attr = self.get_start_item() 237 if dtstart: 238 if isinstance(dtstart, datetime): 239 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 240 else: 241 dtend = dtstart 242 else: 243 return None 244 245 return dtend 246 247 def get_start_attr(self): 248 return self.start and self.start.get_attributes(self.times_enabled) or {} 249 250 def get_end_attr(self): 251 return self.end and self.end.get_attributes(self.times_enabled) or {} 252 253 # Form data methods. 254 255 def get_form_start(self): 256 return self.start 257 258 def get_form_end(self): 259 return self.end 260 261 def as_form_period(self): 262 return self 263 264 class FormDate: 265 266 "Date information originating from form information." 267 268 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 269 self.date = date 270 self.hour = hour 271 self.minute = minute 272 self.second = second 273 self.tzid = tzid 274 self.dt = dt 275 self.attr = attr 276 277 def as_tuple(self): 278 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 279 280 def reset(self): 281 self.dt = None 282 283 def __repr__(self): 284 return "FormDate%r" % (self.as_tuple(),) 285 286 def get_component(self, value): 287 return (value or "").rjust(2, "0")[:2] 288 289 def get_hour(self): 290 return self.get_component(self.hour) 291 292 def get_minute(self): 293 return self.get_component(self.minute) 294 295 def get_second(self): 296 return self.get_component(self.second) 297 298 def get_date_string(self): 299 return self.date or "" 300 301 def get_datetime_string(self): 302 if not self.date: 303 return "" 304 305 hour = self.hour; minute = self.minute; second = self.second 306 307 if hour or minute or second: 308 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 309 else: 310 time = "" 311 312 return "%s%s" % (self.date, time) 313 314 def get_tzid(self): 315 return self.tzid 316 317 def as_datetime(self, with_time=True): 318 319 "Return a datetime for this object." 320 321 # Return any original datetime details. 322 323 if self.dt: 324 return self.dt 325 326 # Otherwise, construct a datetime. 327 328 s, attr = self.as_datetime_item(with_time) 329 if s: 330 return get_datetime(s, attr) 331 else: 332 return None 333 334 def as_datetime_item(self, with_time=True): 335 336 """ 337 Return a (datetime string, attr) tuple for the datetime information 338 provided by this object, where both tuple elements will be None if no 339 suitable date or datetime information exists. 340 """ 341 342 s = None 343 if with_time: 344 s = self.get_datetime_string() 345 attr = self.get_attributes(True) 346 if not s: 347 s = self.get_date_string() 348 attr = self.get_attributes(False) 349 if not s: 350 return None, None 351 return s, attr 352 353 def get_attributes(self, with_time=True): 354 355 "Return attributes for the date or datetime represented by this object." 356 357 if with_time: 358 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 359 else: 360 return {"VALUE" : "DATE"} 361 362 def event_period_from_period(period): 363 364 """ 365 Convert a 'period' to one suitable for use in an iCalendar representation. 366 In an "event period" representation, the end day of any date-level event is 367 encoded as the "day after" the last day actually involved in the event. 368 """ 369 370 if isinstance(period, EventPeriod): 371 return period 372 elif isinstance(period, FormPeriod): 373 return period.as_event_period() 374 else: 375 dtstart, dtstart_attr = period.get_start_item() 376 dtend, dtend_attr = period.get_end_item() 377 378 if not isinstance(period, RecurringPeriod): 379 dtend = end_date_to_calendar(dtend) 380 381 return EventPeriod(dtstart, dtend, period.tzid, period.origin, 382 dtstart_attr, dtend_attr, 383 recurrenceid=format_datetime(to_utc_datetime(dtstart))) 384 385 def event_periods_from_periods(periods): 386 return map(event_period_from_period, periods) 387 388 def form_period_from_period(period): 389 390 """ 391 Convert a 'period' into a representation usable in a user-editable form. 392 In a "form period" representation, the end day of any date-level event is 393 presented in a "natural" form, not the iCalendar "day after" form. 394 """ 395 396 if isinstance(period, EventPeriod): 397 return period.as_form_period() 398 elif isinstance(period, FormPeriod): 399 return period 400 else: 401 return event_period_from_period(period).as_form_period() 402 403 def form_periods_from_periods(periods): 404 return map(form_period_from_period, periods) 405 406 407 408 # Event period processing. 409 410 def periods_from_updated_periods(updated_periods, fn): 411 412 """ 413 Return periods from the given 'updated_periods' created using 'fn, setting 414 replacement, cancelled and recurrence identifier details. 415 """ 416 417 periods = [] 418 419 for sp, p in updated_periods: 420 if p: 421 period = fn(p) 422 if sp != p: 423 period.replacement = True 424 else: 425 period = fn(sp) 426 period.replacement = True 427 period.cancelled = True 428 429 # Replace the recurrence identifier with that of the original period. 430 431 period.recurrenceid = sp.get_recurrenceid() 432 periods.append(period) 433 434 return periods 435 436 def event_periods_from_updated_periods(updated_periods): 437 return periods_from_updated_periods(updated_periods, event_period_from_period) 438 439 def form_periods_from_updated_periods(updated_periods): 440 return periods_from_updated_periods(updated_periods, form_period_from_period) 441 442 def get_main_period(periods): 443 for p in periods: 444 if p.origin == "DTSTART": 445 return p 446 return None 447 448 def get_recurrence_periods(periods): 449 l = [] 450 for p in periods: 451 if p.origin != "DTSTART": 452 l.append(p) 453 return l 454 455 def periods_by_recurrence(periods): 456 457 """ 458 Return a mapping from recurrence identifier to period for 'periods' along 459 with a collection of unmapped periods. 460 """ 461 462 d = {} 463 new = [] 464 465 for p in periods: 466 if not p.recurrenceid: 467 new.append(p) 468 else: 469 d[p.recurrenceid] = p 470 471 return d, new 472 473 def combine_periods(old, new): 474 475 "Combine 'old' and 'new' periods for comparison." 476 477 old_by_recurrenceid, _new_periods = periods_by_recurrence(old) 478 new_by_recurrenceid, new_periods = periods_by_recurrence(new) 479 480 combined = [] 481 482 for recurrenceid, op in old_by_recurrenceid.items(): 483 np = new_by_recurrenceid.get(recurrenceid) 484 if np and not np.cancelled: 485 combined.append((op, np)) 486 else: 487 combined.append((op, None)) 488 489 for np in new_periods: 490 combined.append((None, np)) 491 492 return combined 493 494 def classify_periods(updated_periods): 495 496 """ 497 Using the 'updated_periods', being a list of (stored, current) periods, 498 return a tuple containing collections of new, changed, unchanged, removed 499 periods. 500 """ 501 502 new, changed, unchanged, removed = get_changed_periods(updated_periods) 503 504 changed = set(changed).difference(removed) 505 unchanged = set(unchanged).difference(removed) 506 507 return new, list(changed), list(unchanged), removed 508 509 def get_changed_periods(updated_periods): 510 511 """ 512 Using the 'updated_periods', being a list of (stored, current) periods, 513 return a tuple containing collections of new, changed, unchanged and removed 514 periods. 515 516 Note that changed and unchanged indicate the presence or absence of 517 differences between the original event periods and the current periods, not 518 whether any editing operations have changed the periods. 519 """ 520 521 new = [] 522 changed = [] 523 unchanged = [] 524 removed = [] 525 526 for sp, p in updated_periods: 527 if sp: 528 if not p or p.cancelled: 529 removed.append(sp) 530 elif p != sp or p.replacement: 531 changed.append(p) 532 else: 533 unchanged.append(p) 534 elif p: 535 new.append(p) 536 537 return new, changed, unchanged, removed 538 539 def classify_operations(new, changed, unchanged, removed, is_organiser, is_shared): 540 541 """ 542 Classify the operations for the update of an event. Return the unscheduled 543 periods, rescheduled periods, excluded periods, and the periods to be set in 544 the object to replace the existing stored periods. 545 """ 546 547 active_periods = new + unchanged + changed 548 549 # As organiser... 550 551 if is_organiser: 552 to_exclude = [] 553 554 # For unshared events... 555 # All modifications redefine the event. 556 557 # For shared events... 558 # New periods should cause the event to be redefined. 559 560 if not is_shared or new: 561 to_unschedule = [] 562 to_reschedule = [] 563 to_set = active_periods 564 565 # Changed periods should be rescheduled separately. 566 # Removed periods should be cancelled separately. 567 568 else: 569 to_unschedule = removed 570 to_reschedule = changed 571 to_set = [] 572 573 # As attendee... 574 575 else: 576 to_unschedule = [] 577 578 # Changed periods without new or removed periods are proposed as 579 # separate changes. 580 581 if not new and not removed: 582 to_exclude = [] 583 to_reschedule = changed 584 to_set = [] 585 586 # Otherwise, the event is defined in terms of new periods and 587 # exceptions for removed periods. 588 589 else: 590 to_exclude = removed 591 to_reschedule = [] 592 to_set = active_periods 593 594 return to_unschedule, to_reschedule, to_exclude, to_set 595 596 597 598 # Form field extraction and serialisation. 599 600 def get_date_control_inputs(args, name, tzid_name=None): 601 602 """ 603 Return a tuple of date control inputs taken from 'args' for field names 604 starting with 'name'. 605 606 If 'tzid_name' is specified, the time zone information will be acquired 607 from fields starting with 'tzid_name' instead of 'name'. 608 """ 609 610 return args.get("%s-date" % name, []), \ 611 args.get("%s-hour" % name, []), \ 612 args.get("%s-minute" % name, []), \ 613 args.get("%s-second" % name, []), \ 614 args.get("%s-tzid" % (tzid_name or name), []) 615 616 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 617 618 """ 619 Return a form date object representing fields taken from 'args' starting 620 with 'name'. 621 622 If 'multiple' is set to a true value, many date objects will be returned 623 corresponding to a collection of datetimes. 624 625 If 'tzid_name' is specified, the time zone information will be acquired 626 from fields starting with 'tzid_name' instead of 'name'. 627 628 If 'tzid' is specified, it will provide the time zone where no explicit 629 time zone information is indicated in the field data. 630 """ 631 632 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 633 634 # Handle absent values by employing None values. 635 636 field_values = map(None, dates, hours, minutes, seconds, tzids) 637 638 if not field_values and not multiple: 639 all_values = FormDate() 640 else: 641 all_values = [] 642 for date, hour, minute, second, tzid_field in field_values: 643 value = FormDate(date, hour, minute, second, tzid_field or tzid) 644 645 # Return a single value or append to a collection of all values. 646 647 if not multiple: 648 return value 649 else: 650 all_values.append(value) 651 652 return all_values 653 654 def set_date_control_values(formdates, args, name, tzid_name=None): 655 656 """ 657 Using the values of the given 'formdates', replace form fields in 'args' 658 starting with 'name'. 659 660 If 'tzid_name' is specified, the time zone information will be stored in 661 fields starting with 'tzid_name' instead of 'name'. 662 """ 663 664 args["%s-date" % name] = [] 665 args["%s-hour" % name] = [] 666 args["%s-minute" % name] = [] 667 args["%s-second" % name] = [] 668 args["%s-tzid" % (tzid_name or name)] = [] 669 670 for d in formdates: 671 args["%s-date" % name].append(d and d.date or "") 672 args["%s-hour" % name].append(d and d.hour or "") 673 args["%s-minute" % name].append(d and d.minute or "") 674 args["%s-second" % name].append(d and d.second or "") 675 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 676 677 def get_period_control_values(args, start_name, end_name, 678 end_enabled_name, times_enabled_name, 679 origin=None, origin_name=None, 680 replacement_name=None, cancelled_name=None, 681 recurrenceid_name=None, tzid=None): 682 683 """ 684 Return period values from fields found in 'args' prefixed with the given 685 'start_name' (for start dates), 'end_name' (for end dates), 686 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 687 (to enable times for periods). 688 689 If 'origin' is specified, a single period with the given origin is 690 returned. If 'origin_name' is specified, fields containing the name will 691 provide origin information. 692 693 If specified, fields containing 'replacement_name' will indicate periods 694 provided by separate recurrences, fields containing 'cancelled_name' 695 will indicate periods that are replacements and cancelled, and fields 696 containing 'recurrenceid_name' will indicate periods that have existing 697 recurrence details from an event. 698 699 If 'tzid' is specified, it will provide the time zone where no explicit 700 time zone information is indicated in the field data. 701 """ 702 703 # Get the end datetime and time presence settings. 704 705 all_end_enabled = args.get(end_enabled_name, []) 706 all_times_enabled = args.get(times_enabled_name, []) 707 708 # Get the origins of period data and whether the periods are replacements. 709 710 if origin: 711 all_origins = [origin] 712 else: 713 all_origins = origin_name and args.get(origin_name, []) or [] 714 715 all_replacements = replacement_name and args.get(replacement_name, []) or [] 716 all_cancelled = cancelled_name and args.get(cancelled_name, []) or [] 717 all_recurrenceids = recurrenceid_name and args.get(recurrenceid_name, []) or [] 718 719 # Get the start and end datetimes. 720 721 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 722 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 723 724 # Construct period objects for each start, end, origin combination. 725 726 periods = [] 727 728 for index, (start, end, found_origin, recurrenceid) in \ 729 enumerate(map(None, all_starts, all_ends, all_origins, all_recurrenceids)): 730 731 # Obtain period settings from separate controls. 732 733 end_enabled = str(index) in all_end_enabled 734 times_enabled = str(index) in all_times_enabled 735 replacement = str(index) in all_replacements 736 cancelled = str(index) in all_cancelled 737 738 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 739 found_origin or origin, replacement, cancelled, 740 recurrenceid) 741 periods.append(period) 742 743 # Return a single period if a single origin was specified. 744 745 if origin: 746 return periods[0] 747 else: 748 return periods 749 750 def set_period_control_values(periods, args, start_name, end_name, 751 end_enabled_name, times_enabled_name, 752 origin_name=None, replacement_name=None, 753 cancelled_name=None, recurrenceid_name=None): 754 755 """ 756 Using the given 'periods', replace form fields in 'args' prefixed with the 757 given 'start_name' (for start dates), 'end_name' (for end dates), 758 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 759 (to enable times for periods). 760 761 If 'origin_name' is specified, fields containing the name will provide 762 origin information, fields containing 'replacement_name' will indicate 763 periods provided by separate recurrences, fields containing 'cancelled_name' 764 will indicate periods that are replacements and cancelled, and fields 765 containing 'recurrenceid_name' will indicate periods that have existing 766 recurrence details from an event. 767 """ 768 769 # Record period settings separately. 770 771 args[end_enabled_name] = [] 772 args[times_enabled_name] = [] 773 774 # Record origin and replacement information if naming is defined. 775 776 if origin_name: 777 args[origin_name] = [] 778 779 if replacement_name: 780 args[replacement_name] = [] 781 782 if cancelled_name: 783 args[cancelled_name] = [] 784 785 if recurrenceid_name: 786 args[recurrenceid_name] = [] 787 788 all_starts = [] 789 all_ends = [] 790 791 for index, period in enumerate(periods): 792 793 # Encode period settings in controls. 794 795 if period.end_enabled: 796 args[end_enabled_name].append(str(index)) 797 if period.times_enabled: 798 args[times_enabled_name].append(str(index)) 799 800 # Add origin information where controls are present to record it. 801 802 if origin_name: 803 args[origin_name].append(period.origin or "") 804 805 # Add replacement information where controls are present to record it. 806 807 if replacement_name and period.replacement: 808 args[replacement_name].append(str(index)) 809 810 # Add cancelled recurrence information where controls are present to 811 # record it. 812 813 if cancelled_name and period.cancelled: 814 args[cancelled_name].append(str(index)) 815 816 # Add recurrence identifiers where controls are present to record it. 817 818 if recurrenceid_name: 819 args[recurrenceid_name].append(period.recurrenceid or "") 820 821 # Collect form date information for addition below. 822 823 all_starts.append(period.get_form_start()) 824 all_ends.append(period.get_form_end()) 825 826 # Set the controls for the dates. 827 828 set_date_control_values(all_starts, args, start_name) 829 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 830 831 832 833 # Utilities. 834 835 def filter_duplicates(l): 836 837 """ 838 Return collection 'l' filtered for duplicate values, retaining the given 839 element ordering. 840 """ 841 842 s = set() 843 f = [] 844 845 for value in l: 846 if value not in s: 847 s.add(value) 848 f.append(value) 849 850 return f 851 852 def remove_from_collection(l, indexes, fn): 853 854 """ 855 Remove from collection 'l' all values present at the given 'indexes' where 856 'fn' applied to each referenced value returns a true value. Values where 857 'fn' returns a false value are added to a list of deferred removals which is 858 returned. 859 """ 860 861 still_to_remove = [] 862 correction = 0 863 864 for i in indexes: 865 try: 866 i = int(i) - correction 867 value = l[i] 868 except (IndexError, ValueError): 869 continue 870 871 if fn(value): 872 del l[i] 873 correction += 1 874 else: 875 still_to_remove.append(value) 876 877 return still_to_remove 878 879 # vim: tabstop=4 expandtab shiftwidth=4