Passed
Push — master ( 19f245...563a46 )
by Jace
01:59
created

memegen.domain.template.Template.keywords()   A

Complexity

Conditions 4

Size

Total Lines 8
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

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