python: Add donors2csv script.

This commit is contained in:
Brett Smith 2018-05-31 16:19:50 -04:00
parent 4d3b8b0673
commit 2e2c783657

795
python/donors2csv Executable file
View file

@ -0,0 +1,795 @@
#!/usr/bin/env python3
"""donors2csv: Generate a spreadsheet of donor information.
This reads a file of contact information from PayPal and cross-references it
against a Ledger to generate a spreadsheet of donor information.
The reason it uses the PayPal contact information file and not the supporters
database is because the contact information is updated with every new donation,
while addresses in the supporters database are not (as of May 2018).
"""
#
# Copyright 2018 Brett Smith <brettcsmith@brettcsmith.org>
# Licensed under the GNU Affero General Public License, version 3,
# or (at your option) any later version.
import argparse
import collections
import contextlib
import csv
import datetime
import decimal
import itertools
import logging
import operator
import pathlib
import re
import subprocess
import sys
try:
import babel.numbers
from import2ledger import strparse
from import2ledger.hooks import add_entity
except ImportError:
ENTITY_HOOK = None
else:
ENTITY_HOOK = add_entity.AddEntityHook(None)
logger = logging.getLogger('conservancy.contacts')
ISO_DATE_FMT = '%Y-%m-%d'
REPORT_ATTRIBUTES = (
'entity',
'display_name',
'first_name',
'last_name',
'email',
'address_name',
'address',
'city',
'region',
'postcode',
'country_code',
'country_name',
'payer_country',
'payment_count',
'payment_total',
'payment_programs',
'first_payment_date',
'first_payment_amount',
'first_payment_program',
'last_payment_date',
'last_payment_amount',
'last_payment_program',
)
class Payee:
PAYPAL_FIELDS = {
'PayerDisplayName': 'display_name',
'ADD_Name': 'address_name',
'LastName': 'last_name',
'FirstName': 'first_name',
'Payer': 'email',
'CityName': 'city',
'StateOrProvince': 'region',
'PostalCode': 'postcode',
'Country': 'country_code',
'CountryName': 'country_name',
'PayerCountry': 'payer_country',
}
def __init__(self, entity):
self.entity = entity
self.display_name = None
self.address_name = None
self.last_name = None
self.first_name = None
self.email = None
self.address = None
self.city = None
self.region = None
self.postcode = None
self.country_code = None
self.country_name = None
self.payer_country = None
self.payment_count = 0
self.payment_total = decimal.Decimal()
self.payment_programs = set()
self.first_payment_date = None
self.first_payment_amount = None
self.first_payment_program = None
self.last_payment_date = None
self.last_payment_amount = None
self.last_payment_program = None
def update_from_paypal_contact(self, contact_data):
for data_key, attr_name in self.PAYPAL_FIELDS.items():
try:
setattr(self, attr_name, contact_data[data_key])
except KeyError:
pass
address = []
for n in itertools.count(1):
try:
address.append(contact_data['Street{}'.format(n)].strip())
except KeyError:
break
if address:
self.address = '\n'.join(address)
def add_payment(self, date, amount, program):
self.payment_count += 1
self.payment_total += amount
self.payment_programs.add(program)
if (self.first_payment_date is None) or (self.first_payment_date > date):
self.first_payment_date = date
self.first_payment_amount = amount
self.first_payment_program = program
if (self.last_payment_date is None) or (self.last_payment_date < date):
self.last_payment_date = date
self.last_payment_amount = amount
self.last_payment_program = program
class PayeeCache:
def __init__(self):
self._cache = {}
@staticmethod
def _entity_from(thing):
return getattr(thing, 'entity', thing)
def __contains__(self, elem):
return self._entity_from(elem) in self._cache
def __iter__(self):
return iter(self._cache.values())
def get_or_create(self, entity, factory=Payee):
try:
return self._cache[entity]
except KeyError:
self._cache[entity] = factory(entity)
return self._cache[entity]
class CSVReport:
_format_date = operator.methodcaller('strftime', '%Y-%m-%d')
_format_unsorted = lambda seq: '\n'.join(sorted(seq))
_format_usd = lambda d: babel.numbers.format_currency(abs(d), 'USD')
FORMATTERS = {
'payment_programs': _format_unsorted,
'payment_total': _format_usd,
'first_payment_date': _format_date,
'first_payment_amount': _format_usd,
'last_payment_date': _format_date,
'last_payment_amount': _format_usd,
}
def __init__(self, attr_names, column_names=None):
if column_names is None:
column_names = [name.replace('_', ' ').title()
for name in attr_names]
self.attr_names = attr_names
self.column_names = column_names
def _format_cell(self, payee, attr_name):
value = getattr(payee, attr_name)
if value is None:
return value
try:
formatter = self.FORMATTERS[attr_name]
except KeyError:
return value
else:
return formatter(value)
def write(self, out_file, payees):
writer = csv.writer(out_file)
writer.writerow(self.column_names)
for payee in payees:
writer.writerow(tuple(self._format_cell(payee, attr_name)
for attr_name in self.attr_names))
Country = collections.namedtuple(
'Country', ('code_un', 'code2', 'code3', 'name', 'capital'),
)
NAPostcode = collections.namedtuple(
'NAPostcode', ('code2', 'name'),
)
def _read_misc_data(factory, lines):
return [factory(line.rstrip('\n').split(':')) for line in lines if
line and (not line.isspace()) and (not line.startswith('#'))]
# The big strings below are the contents of the corresponding GNU miscfiles.
# Note that they're under GPLv2+.
COUNTRIES = _read_misc_data(Country._make, """
# UN Code number: 2 letter ISO abbrev : 3 letter ISO abbrev : name : capital
004:AF:AFG:Afghanistan:Kabul
248:AX:ALA:Åland Islands:Mariehamn
008:AL:ALB:Albania:Tirana
012:DZ:DZA:Algeria:Algiers
016:AS:ASM:American Samoa:Pago Pago
020:AD:AND:Andorra:Andorra la Vella
024:AO:AGO:Angola:Luanda
660:AI:AIA:Anguilla:The Valley
010:AQ:ATA:Antarctica:
028:AG:ATG:Antigua and Barbuda:St. John's
032:AR:ARG:Argentina:Buenos Aires
051:AM:ARM:Armenia:Yerevan
533:AW:ABW:Aruba:Oranjestad
036:AU:AUS:Australia:Canberra
040:AT:AUT:Austria:Vienna
031:AZ:AZE:Azerbaijan:Baku
044:BS:BHS:Bahamas:Nassau
048:BH:BHR:Bahrain:Manama
050:BD:BGD:Bangladesh:Dhaka
052:BB:BRB:Barbados:Bridgetown
112:BY:BLR:Belarus:Minsk
056:BE:BEL:Belgium:Brussels
084:BZ:BLZ:Belize:Belmopan
204:BJ:BEN:Benin:Porto-Novo (official capital), Cotonou (seat of government)
060:BM:BMU:Bermuda:Hamilton
064:BT:BTN:Bhutan:Thimphu
068:BO:BOL:Bolivia:La Paz (seat of government), Sucre (legal capital and seat of judiciary)
070:BA:BIH:Bosnia and Herzegovina:Sarajevo
072:BW:BWA:Botswana:Gaborone
074:BV:BVT:Bouvet Island:
076:BR:BRA:Brazil:Brasilia
086:IO:IOT:British Indian Ocean Territory:Diego Garcia
092:VG:VGB:British Virgin Islands:Road Town
096:BN:BRN:Brunei Darussalam:Bandar Seri Begawan
100:BG:BGR:Bulgaria:Sofia
854:BF:BFA:Burkina Faso:Ouagadougou
108:BI:BDI:Burundi:Bujumbura
116:KH:KHM:Cambodia:Phnom Penh
120:CM:CMR:Cameroon:Yaoundé
124:CA:CAN:Canada:Ottawa
132:CV:CPV:Cape Verde:Praia
136:KY:CYM:Cayman Islands:George Town
140:CF:CAF:Central African Republic:Bangui
148:TD:TCD:Chad:N'Djamena
830:::Channel Islands:
152:CL:CHL:Chile:Santiago
156:CN:CHN:China:Beijing
162:CX:CXR:Christmas Island:The Settlement
166:CC:CCK:Cocos (Keeling) Islands:West Island
170:CO:COL:Colombia:Bogota
174:KM:COM:Comoros:Moroni
178:CG:COG:Congo:Brazzaville
184:CK:COK:Cook Islands:Avarua
188:CR:CRI:Costa Rica:San José
384:CI:CIV:Côte d'Ivoire:Yamoussoukro (official capital), Abidjan (administrative center)
191:HR:HRV:Croatia:Zagreb
192:CU:CUB:Cuba:Havana
196:CY:CYP:Cyprus:Nicosia
203:CZ:CZE:Czech Republic:Prague
408:KP:PRK:Democratic People's Republic of Korea:Pyongyang
180:CD:COD:Democratic Republic of the Congo:Kinshasa
208:DK:DNK:Denmark:Copenhagen
262:DJ:DJI:Djibouti:Djibouti
212:DM:DMA:Dominica:Roseau
214:DO:DOM:Dominican Republic:Santo Domingo
218:EC:ECU:Ecuador:Quito
818:EG:EGY:Egypt:Cairo
222:SV:SLV:El Salvador:San Salvador
226:GQ:GNQ:Equatorial Guinea:Malabo
232:ER:ERI:Eritrea:Asmara
233:EE:EST:Estonia:Tallinn
231:ET:ETH:Ethiopia:Addis Ababa
234:FO:FRO:Faeroe Islands:Tórshavn
238:FK:FLK:Falkland Islands (Malvinas):Stanley
583:FM:FSM:Federated States of Micronesia:Palikir
242:FJ:FJI:Fiji:Suva
246:FI:FIN:Finland:Helsinki
250:FR:FRA:France:Paris
254:GF:GUF:French Guiana:Cayenne
258:PF:PYF:French Polynesia:Papeete
260:TF:ATF:French Southern Territories:
266:GA:GAB:Gabon:Libreville
270:GM:GMB:Gambia:Banjul
268:GE:GEO:Georgia:T'bilisi
276:DE:DEU:Germany:Berlin
288:GH:GHA:Ghana:Accra
292:GI:GIB:Gibraltar:Gibraltar
300:GR:GRC:Greece:Athens
304:GL:GRL:Greenland:Nuuk
308:GD:GRD:Grenada:St. George's
312:GP:GLP:Guadeloupe:Basse-Terre
316:GU:GUM:Guam:Hagåtña
320:GT:GTM:Guatemala:Guatemala
324:GN:GIN:Guinea:Conakry
624:GW:GNB:Guinea-Bissau:Bissau
328:GY:GUY:Guyana:Georgetown
332:HT:HTI:Haiti:Port-au-Prince
334:HM:HMD:Heard Island and McDonald Islands:
336:VA:VAT:Holy See:Vatican City
340:HN:HND:Honduras:Tegucigalpa
344:HK:HKG:Hong Kong Special Administrative Region of China:
348:HU:HUN:Hungary:Budapest
352:IS:ISL:Iceland:Reykjavik
356:IN:IND:India:New Delhi
360:ID:IDN:Indonesia:Jakarta
364:IR:IRN:Iran:Tehran
368:IQ:IRQ:Iraq:Baghdad
372:IE:IRL:Ireland:Dublin
833:::Isle of Man:Douglas
376:IL:ISR:Israel:Jerusalem
380:IT:ITA:Italy:Rome
388:JM:JAM:Jamaica:Kingston
392:JP:JPN:Japan:Tokyo
400:JO:JOR:Jordan:'Amman
398:KZ:KAZ:Kazakhstan:Astana
404:KE:KEN:Kenya:Nairobi
296:KI:KIR:Kiribati:Tarawa
414:KW:KWT:Kuwait:Kuwait
417:KG:KGZ:Kyrgyzstan:Bishkek
418:LA:LAO:Lao People's Democratic Republic:Vientiane
428:LV:LVA:Latvia:Riga
422:LB:LBN:Lebanon:Beirut
426:LS:LSO:Lesotho:Maseru
430:LR:LBR:Liberia:Monrovia
434:LY:LBY:Libyan Arab Jamahiriya:Tripoli
438:LI:LIE:Liechtenstein:Vaduz
440:LT:LTU:Lithuania:Vilnius
442:LU:LUX:Luxembourg:Luxembourg
446:MO:MAC:Macau Special Administrative Region of China:
450:MG:MDG:Madagascar:Antananarivo
454:MW:MWI:Malawi:Lilongwe
458:MY:MYS:Malaysia:Kuala Lumpur (official), Putrajaya (administrative)
462:MV:MDV:Maldives:Malé
466:ML:MLI:Mali:Bamako
470:MT:MLT:Malta:Valletta
584:MH:MHL:Marshall Islands:Majuro
474:MQ:MTQ:Martinique:Fort-de-France
478:MR:MRT:Mauritania:Nouakchott
480:MU:MUS:Mauritius:Port Louis
175:YT:MYT:Mayotte:Mamoutzou
484:MX:MEX:Mexico:Mexico
492:MC:MCO:Monaco:Monaco
496:MN:MNG:Mongolia:Ulaanbaatar
500:MS:MSR:Montserrat:Plymouth (abandoned), Brades Estate (interim)
504:MA:MAR:Morocco:Rabat
508:MZ:MOZ:Mozambique:Maputo
104:MM:MMR:Myanmar:Rangoon
516:NA:NAM:Namibia:Windhoek
520:NR:NRU:Nauru:
524:NP:NPL:Nepal:Kathmandu
528:NL:NLD:Netherlands:Amsterdam (official), The Hague (seat of government)
530:AN:ANT:Netherlands Antilles:Willemstad
540:NC:NCL:New Caledonia:Noumea
554:NZ:NZL:New Zealand:Wellington
558:NI:NIC:Nicaragua:Managua
562:NE:NER:Niger:Niamey
566:NG:NGA:Nigeria:Abuja
570:NU:NIU:Niue:Alofi
574:NF:NFK:Norfolk Island:Kingston
580:MP:MNP:Northern Mariana Islands:Saipan
578:NO:NOR:Norway:Oslo
275:PS:PSE:Occupied Palestinian Territory:
512:OM:OMN:Oman:Muscat
586:PK:PAK:Pakistan:Islamabad
585:PW:PLW:Palau:Koror
591:PA:PAN:Panama:Panama
598:PG:PNG:Papua New Guinea:Port Moresby
600:PY:PRY:Paraguay:Asunción
604:PE:PER:Peru:Lima
608:PH:PHL:Philippines:Manila
612:PN:PCN:Pitcairn:Adamstown
616:PL:POL:Poland:Warsaw
620:PT:PRT:Portugal:Lisbon
630:PR:PRI:Puerto Rico:San Juan
634:QA:QAT:Qatar:Doha
410:KR:KOR:Republic of Korea:Seoul
498:MD:MDA:Republic of Moldova:Chișinău
638:RE:REU:Réunion:Saint-Denis
642:RO:ROU:Romania:Bucharest
643:RU:RUS:Russian Federation:Moscow
646:RW:RWA:Rwanda:Kigali
654:SH:SHN:Saint Helena:Jamestown
659:KN:KNA:Saint Kitts and Nevis:Basseterre
662:LC:LCA:Saint Lucia:Castries
666:PM:SPM:Saint Pierre and Miquelon:Saint-Pierre
670:VC:VCT:Saint Vincent and the Grenadines:Kingstown
882:WS:WSM:Samoa:Apia
674:SM:SMR:San Marino:San Marino
678:ST:STP:São Tomé and Príncipe:São Tomé
682:SA:SAU:Saudi Arabia:Riyadh
686:SN:SEN:Senegal:Dakar
891:CS:SCG:Serbia and Montenegro:Belgrade
690:SC:SYC:Seychelles:Victoria
694:SL:SLE:Sierra Leone:Freetown
702:SG:SGP:Singapore:Singapore
703:SK:SVK:Slovakia:Bratislava
705:SI:SVN:Slovenia:Ljubljana
090:SB:SLB:Solomon Islands:Honiara
706:SO:SOM:Somalia:Mogadishu
710:ZA:ZAF:South Africa:Pretoria (official), Cape Town (legislative), Bloemfontein (judicial)
239:GS:SGS:South Georgia and the South Sandwich Islands:
724:ES:ESP:Spain:Madrid
144:LK:LKA:Sri Lanka:Colombo (official), Sri Jayewardenepura Kotte (legislative)
736:SD:SDN:Sudan:Khartoum
740:SR:SUR:Suriname:Paramaribo
744:SJ:SJM:Svalbard and Jan Mayen Islands:Longyearbyen
748:SZ:SWZ:Swaziland:Mbabane (official), Lobamba (royal and legislative)
752:SE:SWE:Sweden:Stockholm
756:CH:CHE:Switzerland:Bern
760:SY:SYR:Syrian Arab Republic:Damascus
158:TW:TWN:Taiwan, Province of China:Taipei
762:TJ:TJK:Tajikistan:Dushanbe
764:TH:THA:Thailand:Bangkok
807:MK:MKD:The former Yugoslav Republic of Macedonia:Skopje
626:TL:TLS:Timor-Leste:Dili
768:TG:TGO:Togo:Lome
772:TK:TKL:Tokelau:
776:TO:TON:Tonga:Nuku'alofa
780:TT:TTO:Trinidad and Tobago:Port-of-Spain
788:TN:TUN:Tunisia:Tunis
792:TR:TUR:Turkey:Ankara
795:TM:TKM:Turkmenistan:Ashgabat
796:TC:TCA:Turks and Caicos Islands:Cockburn Town
798:TV:TUV:Tuvalu:Funafuti (offcial), Vaiaku Village (administrative)
800:UG:UGA:Uganda:Kampala
804:UA:UKR:Ukraine:Kiev
784:AE:ARE:United Arab Emirates:Abu Dhabi
826:GB:GBR:United Kingdom of Great Britain and Northern Ireland:London
834:TZ:TZA:United Republic of Tanzania:Dodoma
581:UM:UMI:United States Minor Outlying Islands:
840:US:USA:United States of America:Washington, DC
850:VI:VIR:United States Virgin Islands:Charlotte Amalie
858:UY:URY:Uruguay:Montevideo
860:UZ:UZB:Uzbekistan:Tashkent
548:VU:VUT:Vanuatu:Port Vila
862:VE:VEN:Venezuela:Caracas
704:VN:VNM:Viet Nam:Hanoi
876:WF:WLF:Wallis and Futuna Islands:Mata-Utu
732:EH:ESH:Western Sahara:Laâyoune
887:YE:YEM:Yemen:Sanaa
894:ZM:ZMB:Zambia:Lusaka
716:ZW:ZWE:Zimbabwe:Harare
""".splitlines())
NA_POSTCODES = _read_misc_data(NAPostcode._make, """
# Postal codes
#
# Code : Region
# American States
AL:Alabama
AK:Alaska
AZ:Arizona
AR:Arkansas
CA:California
CO:Colorado
CT:Connecticut
DE:Delaware
FL:Florida
GA:Georgia
HI:Hawaii
ID:Idaho
IL:Illinois
IN:Indiana
IA:Iowa
KS:Kansas
KY:Kentucky
LA:Louisiana
ME:Maine
MD:Maryland
MA:Massachusetts
MI:Michigan
MN:Minnesota
MS:Mississippi
MO:Missouri
MT:Montana
NE:Nebraska
NV:Nevada
NH:New Hampshire
NJ:New Jersey
NM:New Mexico
NY:New York
NC:North Carolina
ND:North Dakota
OH:Ohio
OK:Oklahoma
OR:Oregon
PA:Pennsylvania
RI:Rhode Island
SC:South Carolina
SD:South Dakota
TN:Tennessee
TX:Texas
UT:Utah
VT:Vermont
VA:Virginia
WA:Washington
WV:West Virginia
WI:Wisconsin
WY:Wyoming
# American Possessions
AS:American Samoa
DC:District of Columbia
FM:Federated States of Micronesia
GU:Guam
MH:Marshall Islands
MP:Northern Mariana Islands
PW:Palau
PR:Puerto Rico
VI:Virgin Islands
# American military
AE:Armed Forces Africa
AA:Armed Forces Americas
AE:Armed Forces Canada
AE:Armed Forces Europe
AE:Armed Forces Middle East
AP:Armed Forces Pacific
# Canadian Provinces
AB:Alberta
BC:British Columbia
MB:Manitoba
NB:New Brunswick
NL:Newfoundland
NS:Nova Scotia
NT:Northwest Territories
NU:Nunavut
ON:Ontario
PE:Prince Edward Island
QC:Quebec
SK:Saskatchewan
YT:Yukon
""".splitlines())
class Filters:
SEARCH_ALIASES = {
'state': 'region',
'province': 'region',
}
def __init__(self):
self.tests = {
'entity_defined': lambda payee: payee.entity is not None,
}
self.regions = set()
self.country_names = set()
self.country_codes = set()
self.since_date = None
def parse_and_add(self, s):
key, _, value = s.partition('=')
if (not key) or (not value):
raise ValueError("malformed match criteria {!r}".format(s))
key = key.lower()
key = self.SEARCH_ALIASES.get(key, key)
try:
self.tests[key] = getattr(self, '{}_test'.format(key))(value)
except AttributeError:
raise ValueError("unknown match criteria {!r}".format(key))
def _normalize_s(self, s):
if s is None:
return None
else:
return re.sub(r'\s+', ' ', s.strip().lower())
def _matching_locations(self, needle, haystack):
return [loc for loc in haystack if any(s.lower() == needle for s in loc)]
def region_test(self, value):
needle = self._normalize_s(value)
regions = self._matching_locations(needle, NA_POSTCODES)
if regions:
self.regions.update(region.code2.lower() for region in regions)
self.regions.update(region.name.lower() for region in regions)
else:
self.regions.add(needle)
return self.filter_on_region
def filter_on_region(self, payee):
return self._normalize_s(payee.region) in self.regions
def country_test(self, value):
needle = self._normalize_s(value)
countries = self._matching_locations(needle, COUNTRIES)
if countries:
self.country_codes.update(country.code2.lower() for country in countries)
self.country_names.update(country.name.lower() for country in countries)
else:
self.country_codes.add(needle)
self.country_names.add(needle)
return self.filter_on_country
def filter_on_country(self, payee):
return (
self._normalize_s(payee.country_code) in self.country_codes
or self._normalize_s(payee.country_name) in self.country_names
)
def since_test(self, s):
since_date = strparse.date(s, ISO_DATE_FMT)
self.since_date = s
return lambda payee: (
payee.last_payment_date is not None
and payee.last_payment_date >= since_date
)
def payee_passes(self, payee):
return all(test(payee) for test in self.tests.values())
def filter_payees(self, payees):
for payee in payees:
if self.payee_passes(payee):
yield payee
def read_paypal_contacts(in_file, payees):
contact_data = {}
for line in in_file:
if re.match(r'^#+$', line):
try:
contact_data['payee'] = '{FirstName} {LastName}'.format_map(contact_data)
except KeyError:
pass
else:
ENTITY_HOOK.run(contact_data)
payee = payees.get_or_create(contact_data['entity'])
payee.update_from_paypal_contact(contact_data)
contact_data = {}
else:
key, _, value = line.partition(':')
contact_data[key] = value.strip()
def read_ledger_payments(in_file, payees):
for row in csv.reader(in_file):
date = strparse.date(row[1], ISO_DATE_FMT)
amount = decimal.Decimal(row[2].lstrip('$ ').replace(',', ''))
payee = payees.get_or_create(row[0])
payee.add_payment(date, amount, row[3])
def parse_arguments(arglist):
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""You can use the following search criteria (omit the brackets):
country=[2-letter or 3-letter country code, or name]
state=[2-letter North American postal code, or name]
province=[name]
since=[YYYY-MM-DD]
For example, to find all donors in Kentucky who donated since mid-2017:
donors2csv paypal-contacts.txt state=ky since=2017-07-01
Only donors that match all given criteria are included in the output.
""",
)
parser.add_argument(
'--loglevel',
choices=['debug', 'info', 'warning', 'error', 'critical'],
default='warning',
help=argparse.SUPPRESS, # Loggingi not implemented yet
# help="Show log messages from this level (default %(default)s)",
)
parser.add_argument(
'--ledger-file',
type=pathlib.Path,
help="Path to the Ledger file to cross-reference against."
" Default guessed from the contacts file path.",
)
parser.add_argument(
'--output-file', '-O',
type=pathlib.Path,
help="Path to write the CSV spreadsheet."
" Default determined from match criteria.",
)
parser.add_argument(
'--overwrite',
action='store_const',
dest='output_mode',
const='w',
default='x',
help="Overwrite the --output-file if it already exists",
)
parser.add_argument(
'contacts_file',
type=pathlib.Path,
help="Path to a paypal-contacts.txt file",
)
parser.add_argument(
'conditions',
nargs=argparse.REMAINDER,
help="Only include contacts that match these criteria",
)
args = parser.parse_args(arglist)
args.filters = Filters()
for filter_s in args.conditions:
try:
args.filters.parse_and_add(filter_s)
except ValueError as error:
parser.error("{}: {}".format(filter_s, error.args[0]))
args.loglevel = getattr(logging, args.loglevel.upper())
if args.ledger_file is None:
ledger_name = args.contacts_file.name.replace('-paypal-contacts', '')
args.ledger_file = args.contacts_file.with_name(ledger_name).with_suffix('.ledger')
if args.output_file is None:
output_parts = []
regions_count = len(args.filters.regions)
if regions_count == 1:
output_parts.append(next(iter(args.filters.regions)))
elif regions_count > 1:
output_parts.append('-'.join(s.upper() for s in sorted(args.filters.regions) if len(s) == 2))
if args.filters.country_codes:
output_parts.append('-'.join(s.upper() for s in sorted(args.filters.country_codes)))
output_parts.append('Conservancy-contacts')
if args.filters.since_date:
output_parts.append('since-' + args.filters.since_date)
args.output_file = pathlib.Path('_'.join(output_parts)).with_suffix('.csv')
return args
def setup_logger(logger, loglevel, stream):
formatter = logging.Formatter('%(name)s: %(levelname)s: %(message)s')
handler = logging.StreamHandler(stream)
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(loglevel)
def main(arglist=None, stdout=sys.stdout, stderr=sys.stderr):
args = parse_arguments(arglist)
if ENTITY_HOOK is None:
print(
"Error: Failed to find the import2ledger module for supporting code.",
"Please download it from",
"<https://k.sfconservancy.org/NPO-Accounting/import2ledger>",
"and follow the instructions in the README to install it.",
sep='\n', file=stderr,
)
return 4
setup_logger(logger, args.loglevel, stderr)
payees = PayeeCache()
with args.contacts_file.open() as contacts_file:
read_paypal_contacts(contacts_file, payees)
with subprocess.Popen(
['ledger', '--args-only', 'csv',
'-V',
'--file', str(args.ledger_file),
'--csv-format', "%(quoted(meta('Entity'))),%(format_date(date, '%Y-%m-%d')),%(quoted(display_amount)),%(quoted(meta('Program')))\n",
'Income:Conservancy:Donations',
],
stdout=subprocess.PIPE,
universal_newlines=True,
) as proc:
read_ledger_payments(proc.stdout, payees)
if proc.returncode != 0:
logger.error("couldn't read payment data: ledger exited %s", proc.returncode)
return 5
report = CSVReport(REPORT_ATTRIBUTES)
with contextlib.ExitStack() as stack:
if str(args.output_file) == '-':
out_file = stdout
else:
out_file = stack.enter_context(args.output_file.open(args.output_mode))
report.write(out_file, args.filters.filter_payees(payees))
print("Output saved to", str(args.output_file), file=stderr)
return 0
if __name__ == '__main__':
try:
exit(main())
except OSError as error:
error_parts = ["Error"]
if error.filename:
error_parts.append("Could not open {}".format(error.filename))
error_parts.append(error.strerror)
print(": ".join(error_parts), file=sys.stderr)
exit(3)