memegen.domain.template.Template.styles()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
import os
2
import hashlib
3
import shutil
4
from pathlib import Path
5
from contextlib import suppress
6
import tempfile
7
8
import requests
9
from PIL import Image
10
import log
11
12
from .text import Text
13
14
15
DEFAULT_REQUEST_HEADERS = {
16
    'User-Agent': "Googlebot/2.1 (+http://www.googlebot.com/bot.html)",
17
}
18
19
20
class Template:
21
    """Blank image to generate a meme."""
22
23
    DEFAULT = 'default'
24
    EXTENSIONS = ('.png', '.jpg')
25
26
    SAMPLE_LINES = ["YOUR TEXT", "GOES HERE"]
27
28
    VALID_LINK_FLAG = '.valid_link.tmp'
29
30
    MIN_HEIGHT = 240
31
    MIN_WIDTH = 240
32
33
    def __init__(self, key, name=None, lines=None, aliases=None, link=None,
34
                 root=None):
35
        self.key = key
36
        self.name = name or ""
37
        self.lines = lines or [""]
38
        self.aliases = aliases or []
39
        self.link = link or ""
40
        self.root = root or ""
41
42
    def __str__(self):
43
        return self.key
44
45
    def __eq__(self, other):
46
        return self.key == other.key
47
48
    def __ne__(self, other):
49
        return self.key != other.key
50
51
    def __lt__(self, other):
52
        return self.name < other.name
53
54
    @property
55
    def dirpath(self):
56
        return os.path.join(self.root, self.key)
57
58
    @property
59
    def path(self):
60
        return self.get_path()
61
62
    @property
63
    def default_text(self):
64
        return Text(self.lines)
65
66
    @property
67
    def default_path(self):
68
        return self.default_text.path or Text.EMPTY
69
70
    @property
71
    def sample_text(self):
72
        return self.default_text or Text(self.SAMPLE_LINES)
73
74
    @property
75
    def sample_path(self):
76
        return self.sample_text.path
77
78
    @property
79
    def aliases_lowercase(self):
80
        return [self.strip(a, keep_special=True) for a in self.aliases]
81
82
    @property
83
    def aliases_stripped(self):
84
        return [self.strip(a, keep_special=False) for a in self.aliases]
85
86
    @property
87
    def styles(self):
88
        return sorted(self._styles())
89
90
    def _styles(self):
91
        """Yield all template style names."""
92
        for filename in os.listdir(self.dirpath):
93
            name, ext = os.path.splitext(filename.lower())
94
            if ext in self.EXTENSIONS and name != self.DEFAULT:
95
                yield name
96
97
    @property
98
    def keywords(self):
99
        words = set()
100
        for fields in [self.key, self.name] + self.aliases + self.lines:
101
            for word in fields.lower().replace(Text.SPACE, ' ').split(' '):
102
                if word:
103
                    words.add(word)
104
        return words
105
106
    @staticmethod
107
    def strip(text, keep_special=False):
108
        text = text.lower().strip().replace(' ', '-')
109
        if not keep_special:
110
            for char in ('-', '_', '!', "'"):
111
                text = text.replace(char, '')
112
        return text
113
114
    def get_path(self, style_or_url=None, *, download=True):
115
        path = None
116
117
        if style_or_url and '://' in style_or_url:
118
            if download:
119
                path = download_image(style_or_url)
120
                if path is None:
121
                    path = self._find_path_for_style(self.DEFAULT)
122
123
        else:
124
            names = [n.lower() for n in [style_or_url, self.DEFAULT] if n]
125
            path = self._find_path_for_style(*names)
126
127
        return path
128
129
    def _find_path_for_style(self, *names):
130
        for name in names:
131
            for extension in self.EXTENSIONS:
132
                path = Path(self.dirpath, name + extension)
133
                with suppress(OSError):
134
                    if path.is_file():
135
                        return path
136
        return None
137
138
    def search(self, query):
139
        """Count the number of times a query exists in relevant fields."""
140
        if query is None:
141
            return -1
142
143
        count = 0
144
145
        for field in [self.key, self.name] + self.aliases + self.lines:
146
            count += field.lower().count(query.lower())
147
148
        return count
149
150
    def validate(self, validators=None):
151
        if validators is None:
152
            validators = [
153
                self.validate_meta,
154
                self.validate_link,
155
                self.validate_size,
156
            ]
157
        for validator in validators:
158
            if not validator():
159
                return False
160
        return True
161
162
    def validate_meta(self):
163
        if not self.lines:
164
            self._error("has no default lines of text")
165
            return False
166
        if not self.name:
167
            self._error("has no name")
168
            return False
169
        if not self.name[0].isalnum():
170
            self._error(f"name '{self.name}' should start with alphanumeric")
171
            return False
172
        if not self.path:
173
            self._error("has no default image")
174
            return False
175
        return True
176
177
    def validate_link(self):
178
        if not self.link:
179
            return True
180
181
        flag = Path(self.dirpath, self.VALID_LINK_FLAG)
182
        with suppress(IOError):
183
            with flag.open() as f:
184
                if f.read() == self.link:
185
                    log.info(f"Link already checked: {self.link}")
186
                    return True
187
188
        log.info(f"Checking link {self.link}")
189
        try:
190
            response = requests.head(self.link, timeout=5,
191
                                     headers=DEFAULT_REQUEST_HEADERS)
192
        except requests.exceptions.ReadTimeout:
193
            log.warning("Connection timed out")
194
            return True  # assume URL is OK; it will be checked again
195
196
        if response.status_code in [403, 429, 503]:
197
            self._warn(f"link is unavailable ({response.status_code})")
198
        elif response.status_code >= 400:
199
            self._error(f"link is invalid ({response.status_code})")
200
            return False
201
202
        with flag.open('w') as f:
203
            f.write(self.link)
204
        return True
205
206
    def validate_size(self):
207
        im = Image.open(self.path)
208
        w, h = im.size
209
        if w < self.MIN_WIDTH or h < self.MIN_HEIGHT:
210
            log.error("Image must be at least "
211
                      f"{self.MIN_WIDTH}x{self.MIN_HEIGHT} (is {w}x{h})")
212
            return False
213
        return True
214
215
    def _warn(self, message):
216
        log.warning(f"Template '{self}' " + message)
217
218
    def _error(self, message):
219
        log.error(f"Template '{self}' " + message)
220
221
222
class Placeholder:
223
    """Default image for missing templates."""
224
225
    FALLBACK_PATH = str(Path(__file__)
226
                        .parents[1]
227
                        .joinpath('static', 'images', 'missing.png'))
228
229
    path = None
230
231
    def __init__(self, key):
232
        self.key = key
233
234
    @classmethod
235
    def get_path(cls, url=None, download=True):
236
        path = None
237
238
        if url and download:
239
            path = download_image(url)
240
241
        if path is None:
242
            path = cls.FALLBACK_PATH
243
244
        return path
245
246
247
def download_image(url):
248
    if not url or '://' not in url:
249
        raise ValueError(f"Not a URL: {url!r}")
250
251
    path = Path(tempfile.gettempdir(),
252
                hashlib.md5(url.encode('utf-8')).hexdigest())
253
254
    if path.is_file():
255
        log.debug(f"Already downloaded: {url}")
256
        return path
257
258
    try:
259
        response = requests.get(url, stream=True, timeout=5,
260
                                headers=DEFAULT_REQUEST_HEADERS)
261
    except ValueError:
262
        log.error(f"Invalid link: {url}")
263
        return None
264
    except requests.exceptions.RequestException:
265
        log.error(f"Bad connection: {url}")
266
        return None
267
268
    if response.status_code == 200:
269
        log.info(f"Downloading {url}")
270
        with open(str(path), 'wb') as outfile:
271
            response.raw.decode_content = True
272
            shutil.copyfileobj(response.raw, outfile)
273
        return path
274
275
    log.error(f"Unable to download: {url}")
276
    return None
277