Merge lp:~marco-gallotta/ibid/flight into lp:~ibid-core/ibid/old-trunk-1.6

Proposed by marcog
Status: Merged
Approved by: Jonathan Hitchcock
Approved revision: not available
Merged at revision: 855
Proposed branch: lp:~marco-gallotta/ibid/flight
Merge into: lp:~ibid-core/ibid/old-trunk-1.6
Diff against target: 362 lines (+315/-6)
2 files modified
ibid/plugins/geography.py (+301/-3)
ibid/utils/__init__.py (+14/-3)
To merge this branch: bzr merge lp:~marco-gallotta/ibid/flight
Reviewer Review Type Date Requested Status
Jonathan Hitchcock Approve
Michael Gorven Approve
Stefano Rivera Approve
Review via email: mp+16964@code.launchpad.net

This proposal supersedes a proposal from 2010-01-07.

To post a comment you must log in.
Revision history for this message
marcog (marco-gallotta) wrote : Posted in a previous version of this proposal

I think the flight plugin is mature enough for some reviews. It's quite possible some gaping bugs are still out there, but it seems to be robust enough.

Revision history for this message
Stefano Rivera (stefanor) wrote : Posted in a previous version of this proposal

Whoops, conflicts in the diff.

Revision history for this message
Stefano Rivera (stefanor) wrote :

+from xml.etree import ElementTree

unused

Query: airport for cape town
Response: Airport code must be 3 or 4 characters
^ You should allow "for" as well as "in"

assume lenght 4
^ comment typo

One way flights?

Query: airports in london
Response: Found the following airports: London (YXU/CYXU), Luton (LTN/EGGW), Gatwick (LGW/EGKK), City (LCY/EGLC), Heathrow (LHR/EGLL), Stansted (STN/EGSS), East London (ELS/FAEL) and Groton New London (GON/KGON)

^ names like "city" are going to be a problem here. Does travelocity have an "all london airports" meta-airport? Most flight search engines I've used do.

Query: cheapest flight from cape town to lhr from 15th to 20th
Response: Departing flight: South African Airways Flight 220 departing 8:10pm from Capetown, South Africa (CPT), arriving 6:55am Sat, Jan 16 at (LHR) (flight time 12hrs 45min, Nonstop) costs $1,421 per person
Response: Returning flight: United Flight 8818 operated by Lufthansa / 9248 operated by Lufthansa departing 5:05pm from London, Great Britain (LHR), arriving 11:20am Thu, Jan 21 at (CPT) (flight time 16hrs 15min, 1 Stop) costs $4,124 per person
Response: Full results: http://travel.travelocity.com/flights/AirSearch.do;jsessionid=37871D566EEB62B4EEA856665138C410.p0263?SEQ=1262869510635072010

^ Don't return 3 responses, do it in one.

Yeah results in local currency would also be awesome, but that can happen in phase 2 :)

review: Needs Fixing
lp:~marco-gallotta/ibid/flight updated
860. By marcog

Fix typos; allow "airport *for* cape town

861. By marcog

Condense responses into one

862. By marcog

Remove unused imports

Revision history for this message
marcog (marco-gallotta) wrote :

> +from xml.etree import ElementTree
>
> unused
>
> Query: airport for cape town
> Response: Airport code must be 3 or 4 characters
> ^ You should allow "for" as well as "in"
>
> assume lenght 4
> ^ comment typo

Fixed all the above

> One way flights?

This is going to add more to an already complex query syntax, so I'd prefer to leave this for phase 2.

> Query: airports in london
> Response: Found the following airports: London (YXU/CYXU), Luton (LTN/EGGW),
> Gatwick (LGW/EGKK), City (LCY/EGLC), Heathrow (LHR/EGLL), Stansted (STN/EGSS),
> East London (ELS/FAEL) and Groton New London (GON/KGON)
>
> ^ names like "city" are going to be a problem here. Does travelocity have an
> "all london airports" meta-airport? Most flight search engines I've used do.

I want to grok on how best to handle this. I'm currently considering that if it matches he entire city name or entire airport name, then return that airport only. However, this could cause problems if it matches but isn't the expected result. I'm currently contemplating putting this on hold for phase 2.

Related to this, we should probably find a working method of matching unicode names with ascii queries (like you and I originally attempted).

> Query: cheapest flight from cape town to lhr from 15th to 20th
> Response: Departing flight: South African Airways Flight 220 departing 8:10pm
> from Capetown, South Africa (CPT), arriving 6:55am Sat, Jan 16 at (LHR)
> (flight time 12hrs 45min, Nonstop) costs $1,421 per person
> Response: Returning flight: United Flight 8818 operated
> by Lufthansa / 9248 operated by Lufthansa departing 5:05pm from London, Great
> Britain (LHR), arriving 11:20am Thu, Jan 21 at (CPT) (flight time
> 16hrs 15min, 1 Stop) costs $4,124 per person
> Response: Full results: http://travel.travelocity.com/flights/AirSearch.do;jse
> ssionid=37871D566EEB62B4EEA856665138C410.p0263?SEQ=1262869510635072010
>
> ^ Don't return 3 responses, do it in one.

Done!

> Yeah results in local currency would also be awesome, but that can happen in
> phase 2 :)

Yep, phase 2. Would be nice to use the currency stuff in the lookup plugin.

lp:~marco-gallotta/ibid/flight updated
863. By marcog

Make use of etree's findtext and path querying

Revision history for this message
Stefano Rivera (stefanor) wrote :

I'd merge the airport and flight features.

Otherwise I'm happy

review: Approve
lp:~marco-gallotta/ibid/flight updated
864. By marcog

Merge flight and airport features

865. By marcog

Use dict substitutions for responses

Revision history for this message
marcog (marco-gallotta) wrote :

> I'd merge the airport and flight features.

Done.

Revision history for this message
Michael Gorven (mgorven) wrote :

> + date = parse(date)

parse() will throw a ValueError if the date is invalid -- you should catch
this and return a suitable error.

 review approve

review: Approve
lp:~marco-gallotta/ibid/flight updated
866. By marcog

Catch ValueError thrown by parse() when the date is invalid

Revision history for this message
marcog (marco-gallotta) wrote :

> > + date = parse(date)
>
> parse() will throw a ValueError if the date is invalid -- you should catch
> this and return a suitable error.

Done

Revision history for this message
Jonathan Hitchcock (vhata) wrote :

Line 76:

  query = [unicode(q) for q in query.lower().split(' ') if q]

That can just be:

  query = [unicode(q) for q in query.lower().split()]

(This note brought to you from the Minor Quibbles Now, Proper Reviews Later Dept.)

Revision history for this message
Stefano Rivera (stefanor) wrote :

If Vhata is being pedantic, here are some more pedantisms:

> query = [unicode(q) for q in query.lower().split(' ') if q]

There are a few more of those. Also, that probably doesn't need to be unicode()d.

While you are at it, convert all the unicode-touching string to u'string'

> [d for d in etree.getiterator('div') if d.get(u'class') == 'e_content']

that can be etree.findAll('div', attrs={'class': u'e_content'})

> for td in tr.getiterator('td'):
> if td.get(u'class').strip() in ['tfPrice', 'tfPriceOrButton']:

another very similar one

> table = [t for t in etree.getiterator('table') if t.get(u'id') == 'tfGrid'][0]
> trs = [t for t in table.getiterator('tr')]

and again. and 223

> if priority == 'cheapest':

priority.lower()

lp:~marco-gallotta/ibid/flight updated
867. By marcog

Make strings unicode in places where they weren't; split(' ') if s -> string.split()

Revision history for this message
marcog (marco-gallotta) wrote :

> > query = [unicode(q) for q in query.lower().split(' ') if q]
>
> There are a few more of those. Also, that probably doesn't need to be
> unicode()d.
>
> While you are at it, convert all the unicode-touching string to u'string'

Done

> > [d for d in etree.getiterator('div') if d.get(u'class') == 'e_content']
>
> that can be etree.findAll('div', attrs={'class': u'e_content'})
>
> > for td in tr.getiterator('td'):
> > if td.get(u'class').strip() in ['tfPrice', 'tfPriceOrButton']:
>
> another very similar one
>
> > table = [t for t in etree.getiterator('table') if t.get(u'id') ==
> 'tfGrid'][0]
> > trs = [t for t in table.getiterator('tr')]
>
> and again. and 223

As we discussed, etree doesn't a findall that takes an attrs arg.

> > if priority == 'cheapest':
>
> priority.lower()

Done

lp:~marco-gallotta/ibid/flight updated
868. By marcog

Make parsing a little more resiliant

869. By marcog

strftime needs an ascii string for pre-2.6

Revision history for this message
Jonathan Hitchcock (vhata) wrote :

Please merge trunk's plugin-reorg into this branch, and then merge your plugin into the geography plugin (preserving as much history as you can, please).

review: Needs Fixing
Revision history for this message
marcog (marco-gallotta) wrote :

> Please merge trunk's plugin-reorg into this branch, and then merge your plugin
> into the geography plugin (preserving as much history as you can, please).

Merged

lp:~marco-gallotta/ibid/flight updated
870. By marcog

Merge trunk and move flight into geography

Revision history for this message
Jonathan Hitchcock (vhata) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'ibid/plugins/geography.py'
--- ibid/plugins/geography.py 2010-01-18 18:38:39 +0000
+++ ibid/plugins/geography.py 2010-01-18 22:43:10 +0000
@@ -1,5 +1,5 @@
1from math import acos, sin, cos, radians1from math import acos, sin, cos, radians
2from urllib import quote2from urllib import quote, urlencode
3from urlparse import urljoin3from urlparse import urljoin
4import re4import re
5import logging5import logging
@@ -8,11 +8,15 @@
8from os import walk8from os import walk
9from dateutil.parser import parse9from dateutil.parser import parse
10from dateutil.tz import gettz, tzlocal, tzoffset10from dateutil.tz import gettz, tzlocal, tzoffset
11import csv
12from sys import maxint
13
14from dateutil.parser import parse
1115
12from ibid.plugins import Processor, match16from ibid.plugins import Processor, match
13from ibid.utils import json_webservice, human_join, format_date17from ibid.utils import json_webservice, human_join, format_date, cacheable_download
14from ibid.utils.html import get_html_parse_tree18from ibid.utils.html import get_html_parse_tree
15from ibid.config import Option, DictOption19from ibid.config import Option, DictOption, IntOption
16from ibid.compat import defaultdict20from ibid.compat import defaultdict
1721
18log = logging.getLogger('plugins.geography')22log = logging.getLogger('plugins.geography')
@@ -349,4 +353,298 @@
349 def time(self, event, place):353 def time(self, event, place):
350 self.convert(event, None, None, place)354 self.convert(event, None, None, place)
351355
356help['flight'] = u'Search for flights on travelocity'
357class Flight:
358 def __init__(self):
359 self.flight, self.depart_time, self.depart_ap, self.arrive_time, \
360 self.arrive_ap, self.duration, self.stops, self.price = \
361 [], None, None, None, None, None, None, None
362
363 def int_price(self):
364 try:
365 return int(self.price[1:])
366 except ValueError:
367 return maxint
368
369 def int_duration(self):
370 hours, minutes = 0, 0
371 match = re.search(r'(\d+)hr', self.duration)
372 if match:
373 hours = int(match.group(1))
374 match = re.search(r'(\d+)min', self.duration)
375 if match:
376 minutes = int(match.group(1))
377 return int(hours)*60 + int(minutes)
378
379MONTH_SHORT = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
380MONTH_LONG = ('January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December')
381OTHER_STUFF = ('am', 'pm', 'st', 'nd', 'rd', 'th', 'morning', 'afternoon', 'evening', 'anytime')
382DATE = r'(?:[0-9.:/hT -]|%s)+' % '|'.join(MONTH_SHORT+MONTH_LONG+OTHER_STUFF)
383
384class FlightException(Exception):
385 pass
386
387class FlightSearch(Processor):
388 """airport [in] <name|location|code>
389 [<cheapest|quickest>] flight from <departure> to <destination> from <depart_date> [anytime|morning|afternoon|evening|<time>] to <return_date> [anytime|morning|afternoon|evening|<time>]"""
390
391 feature = 'flight'
392
393 airports_url = u'http://openflights.svn.sourceforge.net/viewvc/openflights/openflights/data/airports.dat'
394 max_results = IntOption('max_results', 'Maximum number of results to list', 5)
395
396 airports = {}
397
398 def read_airport_data(self):
399 # File is listed as ISO 8859-1 (Latin-1) encoded on
400 # http://openflights.org/data.html, but from decoding it appears to
401 # actually be UTF8
402 filename = cacheable_download(self.airports_url, u'flight/airports.dat')
403 reader = csv.reader(open(filename), delimiter=',', quotechar='"')
404 for row in reader:
405 self.airports[int(row[0])] = [unicode(r, u'utf-8') for r in row[1:]]
406
407 def _airport_search(self, query, search_loc = True):
408 if not self.airports:
409 self.read_airport_data()
410 if search_loc:
411 ids = self._airport_search(query, False)
412 if len(ids) == 1:
413 return ids
414 query = [q for q in query.lower().split()]
415 else:
416 query = [query.lower()]
417 ids = []
418 for id, airport in self.airports.items():
419 if search_loc:
420 data = (u' '.join(c.lower() for c in airport[:5])).split()
421 elif len(query[0]) == 3:
422 data = [airport[3].lower()]
423 else: # assume length 4 (won't break if not)
424 data = [airport[4].lower()]
425 if len(filter(lambda q: q in data, query)) == len(query):
426 ids.append(id)
427 return ids
428
429 def repr_airport(self, id):
430 airport = self.airports[id]
431 code = u''
432 if airport[3] or airport[4]:
433 code = u' (%s)' % u'/'.join(filter(lambda c: c, airport[3:5]))
434 return u'%s%s' % (airport[0], code)
435
436 @match(r'^airports?\s+((?:in|for)\s+)?(.+)$')
437 def airport_search(self, event, search_loc, query):
438 search_loc = search_loc is not None
439 if not search_loc and not 3 <= len(query) <= 4:
440 event.addresponse(u'Airport code must be 3 or 4 characters')
441 return
442 ids = self._airport_search(query, search_loc)
443 if len(ids) == 0:
444 event.addresponse(u"Sorry, I don't know that airport")
445 elif len(ids) == 1:
446 id = ids[0]
447 airport = self.airports[id]
448 code = u'unknown code'
449 if airport[3] and airport[4]:
450 code = u'codes %s and %s' % (airport[3], airport[4])
451 elif airport[3]:
452 code = u'code %s' % airport[3]
453 elif airport[4]:
454 code = u'code %s' % airport[4]
455 event.addresponse(u'%(airport)s in %(city)s, %(country)s has %(code)s', {
456 u'airport': airport[0],
457 u'city': airport[1],
458 u'country': airport[2],
459 u'code': code,
460 })
461 else:
462 event.addresponse(u'Found the following airports: %s', human_join(self.repr_airport(id) for id in ids)[:480])
463
464 def _flight_search(self, event, dpt, to, dep_date, ret_date):
465 airport_dpt = self._airport_search(dpt)
466 airport_to = self._airport_search(to)
467 if len(airport_dpt) == 0:
468 event.addresponse(u"Sorry, I don't know the airport you want to leave from")
469 return
470 if len(airport_to) == 0:
471 event.addresponse(u"Sorry, I don't know the airport you want to fly to")
472 return
473 if len(airport_dpt) > 1:
474 event.addresponse(u'The following airports match the departure: %s', human_join(self.repr_airport(id) for id in airport_dpt)[:480])
475 return
476 if len(airport_to) > 1:
477 event.addresponse(u'The following airports match the destination: %s', human_join(self.repr_airport(id) for id in airport_to)[:480])
478 return
479
480 dpt = airport_dpt[0]
481 to = airport_to[0]
482
483 def to_travelocity_date(date):
484 date = date.lower()
485 time = None
486 for period in [u'anytime', u'morning', u'afternoon', u'evening']:
487 if period in date:
488 time = period.title()
489 date = date.replace(period, u'')
490 break
491 try:
492 date = parse(date)
493 except ValueError:
494 raise FlightException(u"Sorry, I can't understand the date %s" % date)
495 if time is None:
496 if date.hour == 0 and date.minute == 0:
497 time = u'Anytime'
498 else:
499 time = date.strftime('%I:00')
500 if time[0] == u'0':
501 time = time[1:]
502 if date.hour < 12:
503 time += u'am'
504 else:
505 time += u'pm'
506 date = date.strftime('%m/%d/%Y')
507 return (date, time)
508
509 (dep_date, dep_time) = to_travelocity_date(dep_date)
510 (ret_date, ret_time) = to_travelocity_date(ret_date)
511
512 params = {}
513 params[u'leavingFrom'] = self.airports[dpt][3]
514 params[u'goingTo'] = self.airports[to][3]
515 params[u'leavingDate'] = dep_date
516 params[u'dateLeavingTime'] = dep_time
517 params[u'returningDate'] = ret_date
518 params[u'dateReturningTime'] = ret_time
519 etree = get_html_parse_tree('http://travel.travelocity.com/flights/InitialSearch.do', data=urlencode(params), treetype='etree')
520 while True:
521 script = [script for script in etree.getiterator(u'script')][1]
522 matches = script.text and re.search(r'var finurl = "(.*)"', script.text)
523 if matches:
524 url = u'http://travel.travelocity.com/flights/%s' % matches.group(1)
525 etree = get_html_parse_tree(url, treetype=u'etree')
526 else:
527 break
528
529 # Handle error
530 div = [d for d in etree.getiterator(u'div') if d.get(u'class') == u'e_content']
531 if len(div):
532 error = div[0].find(u'h3').text
533 raise FlightException(error)
534
535 departing_flights = self._parse_travelocity(etree)
536 return_url = None
537 table = [t for t in etree.getiterator(u'table') if t.get(u'id') == u'tfGrid'][0]
538 for tr in table.getiterator(u'tr'):
539 for td in tr.getiterator(u'td'):
540 if td.get(u'class').strip() in [u'tfPrice', u'tfPriceOrButton']:
541 onclick = td.find(u'div/button').get(u'onclick')
542 match = re.search(r"location.href='\.\./flights/(.+)'", onclick)
543 url_page = match.group(1)
544 match = re.search(r'^(.*?)[^/]*$', url)
545 url_base = match.group(1)
546 return_url = url_base + url_page
547
548 etree = get_html_parse_tree(return_url, treetype=u'etree')
549 returning_flights = self._parse_travelocity(etree)
550
551 return (departing_flights, returning_flights, url)
552
553 def _parse_travelocity(self, etree):
554 flights = []
555 table = [t for t in etree.getiterator(u'table') if t.get(u'id') == u'tfGrid'][0]
556 trs = [t for t in table.getiterator(u'tr')]
557 tr_index = 1
558 while tr_index < len(trs):
559 tds = []
560 while True:
561 new_tds = [t for t in trs[tr_index].getiterator(u'td')]
562 tds.extend(new_tds)
563 tr_index += 1
564 if len(filter(lambda t: t.get(u'class').strip() == u'tfAirlineSeatsMR', new_tds)):
565 break
566 flight = Flight()
567 for td in tds:
568 if td.get(u'class').strip() == u'tfAirline':
569 anchor = td.find(u'a')
570 if anchor is not None:
571 airline = anchor.text.strip()
572 else:
573 airline = td.text.split(u'\n')[0].strip()
574 flight.flight.append(u'%s %s' % (airline, td.findtext(u'div').strip()))
575 if td.get(u'class').strip() == u'tfDepart' and td.text:
576 flight.depart_time = td.text.split(u'\n')[0].strip()
577 flight.depart_ap = u'%s %s' % (td.findtext(u'div').strip(),
578 td.findtext(u'div/span').strip())
579 if td.get(u'class').strip() == u'tfArrive' and td.text:
580 flight.arrive_time = td.text.split(u'\n')[0].strip()
581 span = td.find(u'span')
582 if span is not None and span.get(u'class').strip() == u'tfNextDayDate':
583 flight.arrive_time = u'%s %s' % (flight.arrive_time, span.text.strip()[2:])
584 span = [s for s in td.find(u'div').getiterator(u'span')][1]
585 flight.arrive_ap = u'%s %s' % (td.findtext(u'div').strip(),
586 span.text.strip())
587 else:
588 flight.arrive_ap = u'%s %s' % (td.findtext(u'div').strip(),
589 td.findtext(u'div/span').strip())
590 if td.get(u'class').strip() == u'tfTime' and td.text:
591 flight.duration = td.text.strip()
592 flight.stops = td.findtext(u'span/a').strip()
593 if td.get(u'class').strip() in [u'tfPrice', u'tfPriceOr'] and td.text:
594 flight.price = td.text.strip()
595 flight.flight = human_join(flight.flight)
596 flights.append(flight)
597
598 return flights
599
600 @match(r'^(?:(cheapest|quickest)\s+)?flights?\s+from\s+(.+)\s+to\s+(.+)\s+from\s+(%s)\s+to\s+(%s)$' % (DATE, DATE))
601 def flight_search(self, event, priority, dpt, to, dep_date, ret_date):
602 try:
603 flights = self._flight_search(event, dpt, to, dep_date, ret_date)
604 except FlightException, e:
605 event.addresponse(unicode(e))
606 return
607 if flights is None:
608 return
609 if len(flights[0]) == 0:
610 event.addresponse(u'No matching departure flights found')
611 return
612 if len(flights[1]) == 0:
613 event.addresponse(u'No matching return flights found')
614 return
615
616 cmp = None
617 if priority is not None:
618 priority = priority.lower()
619 if priority == u'cheapest':
620 cmp = lambda a, b: a.int_price() < b.int_price()
621 elif priority == u'quickest':
622 cmp = lambda a, b: a.int_duration() < b.int_duration()
623 if cmp:
624 # select best flight based on priority
625 for i in xrange(2):
626 flights[i].sort(cmp=cmp)
627 del flights[i][1:]
628 response = []
629 for i, flight_type in zip(xrange(2), [u'Departing', u'Returning']):
630 if len(flights[i]) > 1:
631 response.append(u'%s flights:' % flight_type)
632 for flight in flights[i][:self.max_results]:
633 leading = u''
634 if len(flights[i]) == 1:
635 leading = u'%s flight: ' % flight_type
636 response.append(u'%(leading)s%(flight)s departing %(depart_time)s from %(depart_airport)s, arriving %(arrive_time)s at %(arrive_airport)s (flight time %(duration)s, %(stops)s) costs %(price)s per person' % {
637 'leading': leading,
638 'flight': flight.flight,
639 'depart_time': flight.depart_time,
640 'depart_airport': flight.depart_ap,
641 'arrive_time': flight.arrive_time,
642 'arrive_airport': flight.arrive_ap,
643 'duration': flight.duration,
644 'stops': flight.stops,
645 'price': flight.price or 'unknown'
646 })
647 response.append(u'Full results: %s' % flights[2])
648 event.addresponse(u'\n'.join(response), conflate=False)
649
352# vi: set et sta sw=4 ts=4:650# vi: set et sta sw=4 ts=4:
353651
=== modified file 'ibid/utils/__init__.py'
--- ibid/utils/__init__.py 2010-01-06 23:19:42 +0000
+++ ibid/utils/__init__.py 2010-01-18 22:43:10 +0000
@@ -80,9 +80,14 @@
80 req.add_header('User-Agent', 'Ibid/' + (ibid_version() or 'dev'))80 req.add_header('User-Agent', 'Ibid/' + (ibid_version() or 'dev'))
8181
82 if exists:82 if exists:
83 modified = os.path.getmtime(cachefile)83 if os.path.isfile(cachefile + '.etag'):
84 modified = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(modified))84 f = file(cachefile + '.etag', 'r')
85 req.add_header("If-Modified-Since", modified)85 req.add_header("If-None-Match", f.readline().strip())
86 f.close()
87 else:
88 modified = os.path.getmtime(cachefile)
89 modified = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(modified))
90 req.add_header("If-Modified-Since", modified)
8691
87 try:92 try:
88 connection = urllib2.urlopen(req)93 connection = urllib2.urlopen(req)
@@ -106,6 +111,12 @@
106 gzipper = GzipFile(fileobj=compressedstream)111 gzipper = GzipFile(fileobj=compressedstream)
107 data = gzipper.read()112 data = gzipper.read()
108113
114 etag = connection.headers.get('etag')
115 if etag:
116 f = file(cachefile + '.etag', 'w')
117 f.write(etag + '\n')
118 f.close()
119
109 outfile = file(cachefile, 'wb')120 outfile = file(cachefile, 'wb')
110 outfile.write(data)121 outfile.write(data)
111 outfile.close()122 outfile.close()

Subscribers

People subscribed via source and target branches