-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsecret-santa.py
executable file
·213 lines (186 loc) · 7.77 KB
/
secret-santa.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
#!/usr/bin/env python3
import random
import sys
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import os
if not (sys.version_info[0] >= 3):
raise Exception("Python version 3.0 or greater required")
class Mailer(object):
"""Responsible for sending emails, managed an SMTP client"""
def __init__(self, host, port, username, password=None, startls=False, dryrun=False):
self.host = host
self.port = port
self.startls = startls,
self.username = username
self.password = password
self.dryrun = dryrun
self.client = None
def _check_password(self):
"""Prompts for the password if it wasn't passed in the c'tor"""
if self.password is None:
import getpass
self.password = getpass.getpass("Password for {0} ({1})".format(self.username, self.host))
def connect(self):
"""Connects the SMTP client to the mail server"""
self._check_password()
if not self.dryrun:
assert self.client is None
self.client = smtplib.SMTP(self.host, self.port)
self.client.ehlo()
if self.startls:
self.client.starttls()
self.client.ehlo()
self.client.login(self.username, self.password)
def send(self, to, subject, text):
"""Sends an email using the (already connected) SMTP client"""
msg = MIMEMultipart()
msg['From'] = self.username
msg['To'] = to
msg['Subject'] = subject
msg.attach(MIMEText(text))
if not self.dryrun:
assert self.client is not None
self.client.sendmail(self.username, to, msg.as_string())
else :
print(text)
def disconnect(self):
"""Disconnects the SMTP client from the mail server"""
# Should be client.quit(), but that crashes...
if not self.dryrun:
assert self.client is not None
self.client.close()
def __enter__(self):
self.connect()
def __exit__(self, exc_type, exc_value, traceback):
self.disconnect()
class SantaException(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class Santa(object):
"""Represents a person who can act as a secret Santa to another person"""
def __init__(self, name, email, exclusions = None):
"""Name is the person's name, email is their email address, and
exclusions (optional) is a list of the names of people who this person
should not be santa to (spouses etc)"""
if name is None:
raise SantaException("Name is required")
self.name = name
if email is None:
raise SantaException("Email is required")
self.email = email
self.exclusions = set()
if not (exclusions is None):
self.exclusions |= set(exclusions)
def __str__(self):
return self.name
class SantaAssignments(object):
"""Constructs the secret Santa assignments."""
def __init__(self, santas, max_cpu = 10000):
"""Pseudo-randomly assigns a single secret Santa to every Santa,
subject to the constraints imposed by each Santa's
exclusions. If given, max_cpu bounds the CPU time which may be
spent finding a satisfactory assignment."""
self._santas = santas
self._max_cpu = max_cpu
self._check()
self._avoid_self_santa()
self._assign()
def _check(self):
"""Do some consistency checks on the Santas, throw a SantaException if
a problem is detected"""
emails = set()
names = set()
for santa in self._santas:
# check emails are unique
if santa.email in emails:
exit = True
raise SantaException("Emails not unique: There are two (or more) Santas with address {0}".format(p.email))
emails.add(santa.email)
# check names are unique
if santa.name in names:
exit = True
raise SantaException("Names not unique: There are two (or more) Santas called {0}".format(p.name))
names.add(santa.name)
# check exclusions
for santa in self._santas:
for excl in santa.exclusions:
if not (excl in names):
exit = True;
raise SantaException(
"Santa {0} has exclusion {1} who doesn't exist".format(santa.name, excl))
def _avoid_self_santa(self):
"""Prevent Santas from being Santa to themsevles"""
for santa in self._santas:
santa.exclusions.add(santa.name)
def _assign(self):
"""Assign a Santa to every other santa"""
if self._max_cpu is None:
def cpu_time():
return 0
else:
import resource
def cpu_time():
return resource.getrusage(resource.RUSAGE_SELF)[0]
start = cpu_time()
while True:
if start - cpu_time() > self._max_cpu:
raise SantaException(
"Unable to find suitable assignments within allocated CPU time")
recievers = [santa.name for santa in self._santas]
random.shuffle(recievers);
self.assignments = list(zip(self._santas, recievers))
# Now check that the exclusion constraints are satisfied
bad = False
for santa, reciever in self.assignments:
if reciever in santa.exclusions:
bad = True
if not bad:
break
def _body(self, template, santa, reciever):
return template.format(santa=santa.name, reciever=reciever)
def email(self, mail_conf, template):
with (mail_conf):
for santa, reciever in self.assignments:
mail_conf.send(santa.email, "Secret Santa", self._body(template, santa, reciever))
def print_assignments(self):
for santa, reciever in self.assignments:
print(santa.name, "is santa to", reciever)
def print_assignment_emails(self):
for santa, reciever in self.assignments:
print(body(santa, reciever))
if __name__ == '__main__':
CONF = {}
actions = {'print': lambda sa: sa.print_assignments(),
'email_check': lambda sa: sa.email(CONF['MAIL_CONF'], CONF['EMAIL_CHECK']),
'email': lambda sa: sa.email(CONF['MAIL_CONF'], CONF['EMAIL_ASSIGNMENT'])
}
import argparse
parser = argparse.ArgumentParser(description="Generates assignments for a 'secret santa'.")
parser.add_argument('action',
metavar='ACTION',
type=str,
choices=list(actions.keys()),
help="""The action/subcommand:
'print' Generates assignments and prints them to the console.
'email_check' Sends a preparatory email message to each of the santas, which
can be useful to confirm the email addresses are all correct.
'email' Generates assignments and sends an email to each Santa
informing them who they're to be Santa to.""")
parser.add_argument('conf_file',
metavar='CONFIG',
type=str,
help="A config file containing the Santa's details and email information")
parser.add_argument('--dry-run',
dest="dryrun",
action="store_const",
const=True,
default=False,
help="Do everything except actually send the email")
args = parser.parse_args()
exec(compile(open(args.conf_file).read(), args.conf_file, 'exec'), {'Mailer': Mailer, 'Santa': Santa}, CONF)
CONF['MAIL_CONF'].dryrun = args.dryrun
actions[args.action](SantaAssignments(CONF['SANTAS']))