-
Notifications
You must be signed in to change notification settings - Fork 2
/
app.py
287 lines (223 loc) · 8.32 KB
/
app.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
"""
XKCD Excuse Generator API created using Hug Framework
"""
from binascii import hexlify, unhexlify, Error as BinAsciiError
from io import BytesIO
import os
import bugsnag
import hug
from PIL import Image, ImageDraw, ImageFont
from slugify import slugify
api = hug.API(__name__)
api.http.add_middleware(hug.middleware.CORSMiddleware(
api, allow_origins=['*'], max_age=600))
# since base excuse image is fixed, this values are also constant
IMAGE_WIDTH = 413
# Y text coordinates
WHO_TEXT_Y = 12
LEGIT_TEXT_Y = 38
WHY_TEXT_Y = 85
WHAT_TEXT_Y = 222
dir_path = os.path.dirname(os.path.realpath(__file__))
bugsnag_client = bugsnag.Client(api_key=os.environ.get('BUGSNAG_KEY'))
@bugsnag_client.capture
@hug.get(
versions=1,
examples=[
'who=programmer&why=my%20code%20is%20compiling&what=compiling',
'who=serverless%20dev&why=my%20function%20is%20uploading&what=uploading',
'who=devops&why=my%20docker%20image%20is%20building&what=docker'
]
)
def excuse(request, response, who: hug.types.text='', why: hug.types.text='', what: hug.types.text='') -> dict:
"""
API view that returns JSON with url to rendered image or errors if there
were any.
Caching of JSON response is done in Cloudflare CDN's Page Rules section.
:param request: request object
:param who: who's excuse
:param why: what is the excuse
:param what: what are they saying
:returns: data dict with url to image or with errors
"""
who, why, what = _sanitize_input(who), _sanitize_input(why), _sanitize_input(what)
data = get_excuse_image(who, why, what)
if isinstance(data, Image.Image):
who_hex, why_hex, what_hex = _encode_hex(who, why, what)
image_url = '{scheme}://{domain}/media/{who}-{why}-{what}.png'.format(
scheme=request.scheme,
domain=request.netloc,
who=who_hex,
why=why_hex,
what=what_hex
)
return {
'data': {
'image_url': image_url,
}
}
else:
response.status = hug.HTTP_400
response.cache_control = ['s-maxage=1800', 'maxage=60', 'public']
return {
'errors': data
}
@bugsnag_client.capture
@hug.local()
@hug.get(
'/media/{who_hex}-{why_hex}-{what_hex}.png',
output=hug.output_format.png_image
)
def img(response, who_hex: hug.types.text, why_hex: hug.types.text, what_hex: hug.types.text):
"""
Media image view that displays image directly from app.
:param who_hex: hex representation of user's text
:param why_hex: hex representation of user's text
:param what_hex: hex representation of user's text
:returns: hug response
"""
try:
who, why, what = _decode_hex(who_hex, why_hex, what_hex)
except (BinAsciiError, UnicodeDecodeError):
raise hug.HTTPError(hug.HTTP_404, 'message', 'invalid image path')
image = get_excuse_image(who, why, what)
# setting cache control headers (will be increased later on):
# s-maxage for CloudFlare CDN - 3 hours
# maxage for clients - 1 minute
response.cache_control = ['s-maxage=1800', 'maxage=60', 'public']
if isinstance(image, Image.Image):
return image
else:
raise hug.HTTPError(hug.HTTP_404, 'message', 'invalid image path')
@bugsnag_client.capture
def get_excuse_image(who: str, why: str, what: str):
"""
Load excuse template and write on it.
If there are errors (non-existant text, some text too long),
return list of errors.
:param who: who's excuse
:param why: what is the excuse
:param what: what are they saying
:returns: pillow Image object with excuse written on it
"""
errors = []
errors = _check_user_input_not_empty(errors, who, 1010)
errors = _check_user_input_not_empty(errors, why, 1020)
errors = _check_user_input_not_empty(errors, what, 1030)
who = 'The #1 {} excuse'.format(who).upper()
legit = 'for legitimately slacking off:'.upper()
why = '"{}."'.format(why)
what = '{}!'.format(what)
who_font = _get_text_font(24)
legit_font = _get_text_font(24)
why_font = _get_text_font(22)
what_font = _get_text_font(18)
errors = _check_user_input_size(errors, IMAGE_WIDTH, who, who_font, 1011)
errors = _check_user_input_size(errors, IMAGE_WIDTH, why, why_font, 1021)
errors = _check_user_input_size(errors, 135, what, what_font, 1031)
if errors:
return errors
# in the beginning this is an image without an excuse
image = Image.open(os.path.join(dir_path, 'blank_excuse.png'), 'r')\
.convert('RGBA')
draw = ImageDraw.Draw(image, 'RGBA')
draw.text((_get_text_x_position(IMAGE_WIDTH, who, who_font), WHO_TEXT_Y),
who, fill=(0, 0, 0, 200), font=who_font)
draw.text((_get_text_x_position(IMAGE_WIDTH, legit, legit_font), LEGIT_TEXT_Y),
legit, fill=(0, 0, 0, 200), font=legit_font)
draw.text((_get_text_x_position(IMAGE_WIDTH, why, why_font), WHY_TEXT_Y),
why, fill=(0, 0, 0, 200), font=why_font)
draw.text((_get_text_x_position(IMAGE_WIDTH, what, what_font, 24), WHAT_TEXT_Y),
what, fill=(0, 0, 0, 200), font=what_font)
buffer = BytesIO()
image.save(buffer, format="png")
return image
@bugsnag_client.capture
def _get_text_font(size: int) -> ImageFont:
"""
Loads font and sets font size for text on image
:param size: font size
:returns: ImageFont object with desired font size set
"""
return ImageFont.truetype('xkcd-script.ttf', size)
@bugsnag_client.capture
def _check_user_input_not_empty(
errors: list, text: str, error_code: int) -> list:
"""
Checks if user input size can actually fit in image.
If not, add an error to existing list of errors.
:param errors: list of errors
:param text: user's input
:param error_code: internal error code
:returns: list of errors
"""
if not text or text.strip() == '':
errors.append({
'code': error_code,
'message': 'This field is required.'
})
return errors
@bugsnag_client.capture
def _check_user_input_size(errors: list, max_width: float, text: str,
text_font: ImageFont, error_code: int) -> list:
"""
Checks if user input size can actually fit in image.
If not, add an error to existing list of errors.
:param errors: list of errors
:param max_width: max size of text
:param text: user's input
:param text_font: font to be displayed on image
:param error_code: internal error code
:returns: list of errors
"""
if text_font.getsize(text)[0] > max_width:
errors.append({
'code': error_code,
'message': 'Text too long.'
})
return errors
@bugsnag_client.capture
def _get_text_x_position(image_width: int, text: str, text_font: ImageFont, offset: int=None) -> float:
"""
Calculate starting X coordinate for given text and text size.
:param text: user's text
:param text_font:
:param offset: how much to move from center of the image to the right
:returns: text's X coordinate
"""
offset = 0 if offset is None else offset
return image_width - (image_width / 2 + text_font.getsize(text)[0] / 2) - offset
@bugsnag_client.capture
def _sanitize_input(input: str) -> str:
"""
Sanitizing input so that it can be hexlifyied.
Removes extra spacing, slugifies all non-ascii chars, makes everything
uppercase.
:param input: dirty user input from get param
:returns: cleaned user input
"""
regex_pattern = r'[^\x00-\x7F]+'
return slugify(
input.strip(' .!'), separator=' ', regex_pattern=regex_pattern).upper()
@bugsnag_client.capture
def _decode_hex(*texts) -> list:
"""
Transforms all attrs to regular (human-readable) strings.
:param texts: list of strings to be decoded
:returns: list of hex encoded strings
"""
return [unhexlify(text).decode() for text in texts]
@bugsnag_client.capture
def _encode_hex(*texts) -> list:
"""
Transforms all attrs to hex encoded strings.
:param texts: list of string to be encoded
:returns: list of hex values
"""
return [hexlify(bytes(text, 'utf-8')).decode() for text in texts]
def bugsnag_unhandled_exception(e, event, context): # pragma: no cover
"""
Sending exceptions to Bugsnag.
"""
bugsnag_client(e, event)
return True # Prevent invocation retry