-
Notifications
You must be signed in to change notification settings - Fork 521
/
Copy pathutils.py
executable file
·474 lines (403 loc) · 14.4 KB
/
utils.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# Helpful functions for finding data about members and committees
CURRENT_CONGRESS = 115
states = {
'AK': 'Alaska',
'AL': 'Alabama',
'AR': 'Arkansas',
'AS': 'American Samoa',
'AZ': 'Arizona',
'CA': 'California',
'CO': 'Colorado',
'CT': 'Connecticut',
'DC': 'District of Columbia',
'DE': 'Delaware',
'FL': 'Florida',
'GA': 'Georgia',
'GU': 'Guam',
'HI': 'Hawaii',
'IA': 'Iowa',
'ID': 'Idaho',
'IL': 'Illinois',
'IN': 'Indiana',
'KS': 'Kansas',
'KY': 'Kentucky',
'LA': 'Louisiana',
'MA': 'Massachusetts',
'MD': 'Maryland',
'ME': 'Maine',
'MI': 'Michigan',
'MN': 'Minnesota',
'MO': 'Missouri',
'MP': 'Northern Mariana Islands',
'MS': 'Mississippi',
'MT': 'Montana',
'NA': 'National',
'NC': 'North Carolina',
'ND': 'North Dakota',
'NE': 'Nebraska',
'NH': 'New Hampshire',
'NJ': 'New Jersey',
'NM': 'New Mexico',
'NV': 'Nevada',
'NY': 'New York',
'OH': 'Ohio',
'OK': 'Oklahoma',
'OR': 'Oregon',
'PA': 'Pennsylvania',
'PR': 'Puerto Rico',
'RI': 'Rhode Island',
'SC': 'South Carolina',
'SD': 'South Dakota',
'TN': 'Tennessee',
'TX': 'Texas',
'UT': 'Utah',
'VA': 'Virginia',
'VI': 'Virgin Islands',
'VT': 'Vermont',
'WA': 'Washington',
'WI': 'Wisconsin',
'WV': 'West Virginia',
'WY': 'Wyoming',
'OL': 'Orleans',
'DK': 'Dakota',
'PI': 'Philippine Islands'
}
import urllib.request, urllib.error, urllib.parse
import os, errno, sys, traceback
import re, html.entities
import pprint
import rtyaml
from datetime import datetime, date
import time
import json
import lxml.html # for meta redirect parsing
import yaml
import smtplib
import email.utils
from email.mime.text import MIMEText
# read in an opt-in config file for supplying email settings
# returns None if it's not there, and this should always be handled gracefully
path = "email/config.yml"
if os.path.exists(path):
email_settings = yaml.load(open(path, 'r')).get('email', None)
else:
email_settings = None
def congress_from_legislative_year(year):
return ((year + 1) / 2) - 894
def legislative_year(date=None):
if not date:
date = datetime.now()
if date.month == 1:
if date.day == 1 or date.day == 2:
return date.year - 1
elif date.day == 3:
if isinstance(date,datetime):
if date.hour < 12:
return date.year -1
else:
return date.year
else:
return date.year
else:
return date.year
else:
return date.year
def congress_start_end_dates(congress):
# Get the start date and end date of the given Congress (i.g. 1 for the 1st Congress).
# Sadly, the date of the end of one Congress is identical with the date of the start
# of the next because the switchover time is at noon (at least since 1935).
# Also see get_congress_from_date.
start_year = 1789 + (congress-1)*2
end_year = start_year + 2
if congress < 73:
# The 1st Congress met on March 4, 1789, per an act of the Continental
# Congress adpoted Sept 13, 1788. The Constitutional term period would
# end two years later, of course. Looking at actual adjournment dates,
# it seems that Congress believed its term ended on March 3rd's.
if congress != 69:
return (date(start_year, 3, 4), date(end_year, 3, 3))
else:
# But the 69th Congress (and only that Congress) adjourned on a March 4,
# which means that they must have viewed their Constitutional term as
# expiring at the actual time of day that the first Congress began?
# Since we use March 4 as the term end dates for the 69th Congress in our
# data, we'll use that as the end date for the 69th Congress only.
return (date(start_year, 3, 4), date(end_year, 3, 4))
elif congress == 73:
# The end of the 73rd Congress was changed by the 20th Amendment. So
# it began on a March 4 but ended on the January 3rd (at noon) that
# preceded the usual March 3 (1935). (Congress adjourned in 1934 anyway.)
return (date(start_year, 3, 4), date(end_year, 1, 3))
else:
# Starting with the 74th Congress, Congresses begin and end on January
# 3rds at noon.
return (date(start_year, 1, 3), date(end_year, 1, 3))
def get_congress_from_date(d, range_type=None):
# This is the inverse of congress_start_end_dates.
#
# Return the Congress number that the date 'd' occurs in by first computing
# the 'legislative year' it occurs in, and then using some simple arithmetic
# counting back to 1789 (the first legislative year) and dividing by two
# (since Congresses are two years).
#
# Since Congresses start and end on the same date at noon (at least since
# 1935, but we treat it similarly for prior years), those dates are ambiguous.
# The caller passes range_type='start' to exclude the possibility that
# if the date is on a transition date that it is in the subsequent Congress,
# and vice versa for range_type='end'.
if (d.year % 2) == 0:
# Even years occur entirely within a Congress.
y = d.year
else:
# In odd-numbered years, the period before the transition date, and if
# range_type == 'end' the transition date itself, is assigned to the
# previous legislative year
# Get the transition date of this year.
if d.year < 1935:
# Through 1933, the transition date was March 4. Although most
# Congresses adjourned on March 3 or earlier probably to not
# have to deal with the question of what time the first session
# began.
td = date(d.year, 3, 4)
else:
# Since 1935, the transition date is Jan 3.
td = date(d.year, 1, 3)
# Check if d is before, after, or on the transition date.
if d < td:
y = d.year - 1
elif d > td:
y = d.year
else:
if range_type == "end":
# Assign this date to the previous Congress.
y = d.year - 1
elif range_type == "start":
# Assign this date to the next Congress.
y = d.year
else:
raise ValueError("Date {} is ambiguous; must pass range_type='start' or 'end'.".format(d))
# Now do some simple integer math to compute the Congress number.
return ((y + 1) // 2) - 894
def parse_date(date):
return datetime.strptime(date, "%Y-%m-%d").date()
def log(object):
if isinstance(object, str):
print(object)
else:
pprint(object)
def uniq(seq):
seen = set()
seen_add = seen.add
return [ x for x in seq if x not in seen and not seen_add(x)]
def args():
args = []
for token in sys.argv[1:]:
if not token.startswith("--"):
args.append(token)
return args
def flags():
options = {}
for token in sys.argv[1:]:
if token.startswith("--"):
if "=" in token:
key, value = token.split('=')
else:
key, value = token, True
key = key.split("--")[1]
if value == 'True': value = True
elif value == 'False': value = False
options[key.lower()] = value
return options
##### Data management
def data_dir():
return ".."
def load_data(path):
return yaml_load(os.path.join(data_dir(), path))
def save_data(data, path):
yaml_dump(data, os.path.join(data_dir(), path))
write(
json.dumps(data, default=format_datetime),
"../alternate_formats/%s" %path.replace(".yaml", ".json"))
##### Downloading
import scrapelib
scraper = scrapelib.Scraper(requests_per_minute=60, retry_attempts=3)
scraper.user_agent = "the @unitedstates project (https://github.com/unitedstates/congress-legislators)"
def cache_dir():
return "cache"
def download(url, destination=None, force=False, options=None):
if not destination and not force:
raise TypeError("destination must not be None if force is False.")
if not options:
options = {}
# get the path to cache the file, or None if destination is None
cache = os.path.join(cache_dir(), destination) if destination else None
if not force and os.path.exists(cache):
if options.get('debug', False):
log("Cached: (%s, %s)" % (cache, url))
with open(cache, 'r') as f:
body = f.read()
else:
try:
if options.get('debug', False):
log("Downloading: %s" % url)
if options.get('urllib', False):
response = urllib.request.urlopen(url)
body = response.read()
if not options.get('binary', False):
body = body.decode("utf-8") # guessing encoding
else:
response = scraper.get(url)
if not options.get('binary', False):
body = response.text
else:
body = response.content
except scrapelib.HTTPError:
log("Error downloading %s" % url)
return None
# don't allow 0-byte files
if (not body) or (not body.strip()):
return None
# the downloader can optionally parse the body as HTML
# and look for meta redirects. a bit expensive, so opt-in.
if options.get('check_redirects', False):
try:
html_tree = lxml.html.fromstring(body)
except ValueError:
log("Error parsing source from url {0}".format(url))
return None
meta = html_tree.xpath("//meta[translate(@http-equiv, 'REFSH', 'refsh') = 'refresh']/@content")
if meta:
attr = meta[0]
wait, text = attr.split(";")
if text.lower().startswith("url="):
new_url = text[4:]
if not new_url.startswith(url): #dont print if a local redirect
print("Found redirect for {}, downloading {} instead..".format(url, new_url))
options.pop('check_redirects')
body = download(new_url, None, True, options)
# cache content to disk
if cache: write(body, cache)
return body
from pytz import timezone
eastern_time_zone = timezone('US/Eastern')
def format_datetime(obj):
if isinstance(obj, datetime):
return eastern_time_zone.localize(obj.replace(microsecond=0)).isoformat()
elif isinstance(obj, str):
return obj
else:
return None
def write(content, destination):
# content must be a str instance (not bytes), will be written in utf-8 per open()'s default
mkdir_p(os.path.dirname(destination))
f = open(destination, 'w' + ('' if isinstance(content, str) else 'b'))
f.write(content)
f.close()
# mkdir -p in python, from:
# http://stackoverflow.com/questions/600268/mkdir-p-functionality-in-python
def mkdir_p(path):
try:
os.makedirs(path)
except OSError as exc: # Python >2.5
if exc.errno == errno.EEXIST:
pass
else:
raise
def format_exception(exception):
exc_type, exc_value, exc_traceback = sys.exc_info()
return "\n".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
# taken from http://effbot.org/zone/re-sub.htm#unescape-html
def unescape(text, encoding=None):
def remove_unicode_control(str):
remove_re = re.compile('[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]')
return remove_re.sub('', str)
def fixup(m):
text = m.group(0)
if text[:2] == "&#":
# character reference
if encoding == None:
try:
if text[:3] == "&#x":
return chr(int(text[3:-1], 16))
else:
return chr(int(text[2:-1]))
except ValueError:
pass
else:
try:
if text[:3] == "&#x":
return bytes([int(text[3:-1], 16)]).decode(encoding)
else:
return bytes([int(text[2:-1])]).decode(encoding)
except ValueError:
pass
else:
# named entity
try:
text = chr(html.entities.name2codepoint[text[1:-1]])
except KeyError:
pass
return text # leave as is
text = re.sub(r"&#?\w+;", fixup, text)
text = remove_unicode_control(text)
return text
##### YAML serialization ######
# Apply some common settings for loading/dumping YAML and cache the
# data in pickled format which is a LOT faster than YAML.
def yaml_load(path, use_cache=True):
# Loading YAML is ridiculously slow, so cache the YAML data
# in a pickled file which loads much faster.
# Check if the .pickle file exists and a hash stored inside it
# matches the hash of the YAML file, and if so unpickle it.
import pickle as pickle, os.path, hashlib
h = hashlib.sha1(open(path, 'rb').read()).hexdigest()
if use_cache and os.path.exists(path + ".pickle"):
try:
store = pickle.load(open(path + ".pickle", 'rb'))
if store["hash"] == h:
return store["data"]
except EOFError:
pass # bad .pickle file, pretend it doesn't exist
# No cached pickled data exists, so load the YAML file.
data = rtyaml.load(open(path))
# Store in a pickled file for fast access later.
pickle.dump({ "hash": h, "data": data }, open(path+".pickle", "wb"))
return data
def yaml_dump(data, path):
# write file
rtyaml.dump(data, open(path, "w"))
# Store in a pickled file for fast access later.
import pickle as pickle, hashlib
h = hashlib.sha1(open(path, 'rb').read()).hexdigest()
pickle.dump({ "hash": h, "data": data }, open(path+".pickle", "wb"))
# if email settings are supplied, email the text - otherwise, just print it
def admin(body):
try:
if isinstance(body, Exception):
body = format_exception(body)
print(body) # always print it
if email_settings:
send_email(body)
except Exception as exception:
print("Exception logging message to admin, halting as to avoid loop")
print(format_exception(exception))
# this should only be called if the settings are definitely there
def send_email(message):
print("Sending email to %s..." % email_settings['to'])
# adapted from http://www.doughellmann.com/PyMOTW/smtplib/
msg = MIMEText(message)
msg.set_unixfrom('author')
msg['To'] = email.utils.formataddr(('Recipient', email_settings['to']))
msg['From'] = email.utils.formataddr((email_settings['from_name'], email_settings['from']))
msg['Subject'] = "%s - %i" % (email_settings['subject'], int(time.time()))
server = smtplib.SMTP(email_settings['hostname'])
try:
server.ehlo()
if email_settings['starttls'] and server.has_extn('STARTTLS'):
server.starttls()
server.ehlo()
server.login(email_settings['user_name'], email_settings['password'])
server.sendmail(email_settings['from'], [email_settings['to']], msg.as_string())
finally:
server.quit()
print("Sent email to %s." % email_settings['to'])