memegen.domain.template.Template.validate_link()   C
last analyzed

Complexity

Conditions 9

Size

Total Lines 28
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 11.9991

Importance

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