comparison tests/mockutil.py @ 411:e7b865f8f335

bake: Enable multiprocess baking. Baking is now done by running a worker per CPU, and sending jobs to them. This changes several things across the codebase: * Ability to not cache things related to pages other than the 'main' page (i.e. the page at the bottom of the execution stack). * Decouple the baking process from the bake records, so only the main process keeps track (and modifies) the bake record. * Remove the need for 'batch page getters' and loading a page directly from the page factories. There are various smaller changes too included here, including support for scope performance timers that are saved with the bake record and can be printed out to the console. Yes I got carried away. For testing, the in-memory 'mock' file-system doesn't work anymore, since we're spawning processes, so this is replaced by a 'tmpfs' file-system which is saved in temporary files on disk and deleted after tests have run.
author Ludovic Chabant <ludovic@chabant.com>
date Fri, 12 Jun 2015 17:09:19 -0700
parents 65db6df28120
children fd694f1297c7
comparison
equal deleted inserted replaced
410:d1a472464e57 411:e7b865f8f335
1 import io
2 import time
3 import errno
4 import random
5 import codecs
6 import shutil
7 import os.path 1 import os.path
8 import mock 2 import mock
9 import yaml
10 from piecrust.app import PieCrust, PieCrustConfiguration 3 from piecrust.app import PieCrust, PieCrustConfiguration
11 from piecrust.page import Page 4 from piecrust.page import Page
12 from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page 5 from piecrust.rendering import QualifiedPage, PageRenderingContext, render_page
13
14
15 resources_path = os.path.abspath(
16 os.path.join(
17 os.path.dirname(__file__),
18 '..', 'piecrust', 'resources'))
19 6
20 7
21 def get_mock_app(config=None): 8 def get_mock_app(config=None):
22 app = mock.MagicMock(spec=PieCrust) 9 app = mock.MagicMock(spec=PieCrust)
23 app.config = PieCrustConfiguration() 10 app.config = PieCrustConfiguration()
35 ctx = PageRenderingContext(qp) 22 ctx = PageRenderingContext(qp)
36 rp = render_page(ctx) 23 rp = render_page(ctx)
37 return rp.content 24 return rp.content
38 25
39 26
40 class _MockFsEntry(object): 27 from .tmpfs import (
41 def __init__(self, contents): 28 TempDirFileSystem as mock_fs,
42 self.contents = contents 29 TempDirScope as mock_fs_scope)
43 self.metadata = {'mtime': time.time()}
44 30
45
46 class _MockFsEntryWriter(object):
47 def __init__(self, entry, mode='rt'):
48 self._entry = entry
49 self._mode = mode
50
51 if 'b' in mode:
52 data = entry.contents
53 if isinstance(data, str):
54 data = data.encode('utf8')
55 self._stream = io.BytesIO(data)
56 else:
57 self._stream = io.StringIO(entry.contents)
58
59 def __getattr__(self, name):
60 return getattr(self._stream, name)
61
62 def __enter__(self):
63 return self
64
65 def __exit__(self, exc_type, exc_value, exc_tb):
66 if 'w' in self._mode:
67 if 'a' in self._mode:
68 self._entry.contents += self._stream.getvalue()
69 else:
70 self._entry.contents = self._stream.getvalue()
71 self._entry.metadata['mtime'] = time.time()
72 self._stream.close()
73
74
75 class mock_fs(object):
76 def __init__(self, default_spec=True):
77 self._root = 'root_%d' % random.randrange(1000)
78 self._fs = {self._root: {}}
79 if default_spec:
80 self.withDir('counter')
81 self.withFile('kitchen/config.yml',
82 "site:\n title: Mock Website\n")
83
84 def path(self, p):
85 p = p.replace('\\', '/')
86 if p in ['/', '', None]:
87 return '/%s' % self._root
88 return '/%s/%s' % (self._root, p.lstrip('/'))
89
90 def getApp(self, cache=True):
91 root_dir = self.path('/kitchen')
92 return PieCrust(root_dir, cache=cache, debug=True)
93
94 def withDir(self, path):
95 path = path.replace('\\', '/')
96 path = path.lstrip('/')
97 path = '/%s/%s' % (self._root, path)
98 self._createDir(path)
99 return self
100
101 def withFile(self, path, contents):
102 path = path.replace('\\', '/')
103 path = path.lstrip('/')
104 path = '/%s/%s' % (self._root, path)
105 self._createFile(path, contents)
106 return self
107
108 def withAsset(self, path, contents):
109 return self.withFile('kitchen/' + path, contents)
110
111 def withAssetDir(self, path):
112 return self.withDir('kitchen/' + path)
113
114 def withConfig(self, config):
115 return self.withFile(
116 'kitchen/config.yml',
117 yaml.dump(config))
118
119 def withThemeConfig(self, config):
120 return self.withFile(
121 'kitchen/theme/theme_config.yml',
122 yaml.dump(config))
123
124 def withPage(self, url, config=None, contents=None):
125 config = config or {}
126 contents = contents or "A test page."
127 text = "---\n"
128 text += yaml.dump(config)
129 text += "---\n"
130 text += contents
131
132 name, ext = os.path.splitext(url)
133 if not ext:
134 url += '.md'
135 url = url.lstrip('/')
136 return self.withAsset(url, text)
137
138 def withPageAsset(self, page_url, name, contents=None):
139 contents = contents or "A test asset."
140 url_base, ext = os.path.splitext(page_url)
141 dirname = url_base + '-assets'
142 return self.withAsset('%s/%s' % (dirname, name),
143 contents)
144
145 def withPages(self, num, url_factory, config_factory=None,
146 contents_factory=None):
147 for i in range(num):
148 if isinstance(url_factory, str):
149 url = url_factory.format(idx=i, idx1=(i + 1))
150 else:
151 url = url_factory(i)
152
153 config = None
154 if config_factory:
155 config = config_factory(i)
156
157 contents = None
158 if contents_factory:
159 contents = contents_factory(i)
160
161 self.withPage(url, config, contents)
162 return self
163
164 def getStructure(self, path=None):
165 root = self._fs[self._root]
166 if path:
167 root = self._getEntry(self.path(path))
168 if root is None:
169 raise Exception("No such path: %s" % path)
170 if not isinstance(root, dict):
171 raise Exception("Path is not a directory: %s" % path)
172
173 res = {}
174 for k, v in root.items():
175 self._getStructureRecursive(v, res, k)
176 return res
177
178 def getFileEntry(self, path):
179 entry = self._getEntry(self.path(path))
180 if entry is None:
181 raise Exception("No such file: %s" % path)
182 if not isinstance(entry, _MockFsEntry):
183 raise Exception("Path is not a file: %s" % path)
184 return entry.contents
185
186 def _getStructureRecursive(self, src, target, name):
187 if isinstance(src, _MockFsEntry):
188 target[name] = src.contents
189 return
190
191 e = {}
192 for k, v in src.items():
193 self._getStructureRecursive(v, e, k)
194 target[name] = e
195
196 def _getEntry(self, path):
197 cur = self._fs
198 path = path.replace('\\', '/').lstrip('/')
199 bits = path.split('/')
200 for p in bits:
201 try:
202 cur = cur[p]
203 except KeyError:
204 return None
205 return cur
206
207 def _createDir(self, path):
208 cur = self._fs
209 path = path.replace('\\', '/').strip('/')
210 bits = path.split('/')
211 for b in bits:
212 if b not in cur:
213 cur[b] = {}
214 cur = cur[b]
215 return self
216
217 def _createFile(self, path, contents):
218 cur = self._fs
219 path = path.replace('\\', '/').lstrip('/')
220 bits = path.split('/')
221 for b in bits[:-1]:
222 if b not in cur:
223 cur[b] = {}
224 cur = cur[b]
225 cur[bits[-1]] = _MockFsEntry(contents)
226 return self
227
228 def _deleteEntry(self, path):
229 parent = self._getEntry(os.path.dirname(path))
230 assert parent is not None
231 name = os.path.basename(path)
232 assert name in parent
233 del parent[name]
234
235
236 class mock_fs_scope(object):
237 def __init__(self, fs, open_patches=None):
238 self.open_patches = open_patches or []
239 self._fs = fs
240 self._patchers = []
241 self._originals = {}
242
243 @property
244 def root(self):
245 return self._fs._root
246
247 def __enter__(self):
248 self._startMock()
249 return self
250
251 def __exit__(self, type, value, traceback):
252 self._endMock()
253
254 def _startMock(self):
255 # TODO: sadly, there seems to be no way to replace `open` everywhere?
256 modules = self.open_patches + [
257 '__main__',
258 'piecrust.records',
259 'jinja2.utils']
260 for m in modules:
261 self._createMock('%s.open' % m, open, self._open, create=True)
262
263 self._createMock('codecs.open', codecs.open, self._codecsOpen)
264 self._createMock('os.listdir', os.listdir, self._listdir)
265 self._createMock('os.makedirs', os.makedirs, self._makedirs)
266 self._createMock('os.remove', os.remove, self._remove)
267 self._createMock('os.rename', os.rename, self._rename)
268 self._createMock('os.path.exists', os.path.exists, self._exists)
269 self._createMock('os.path.isdir', os.path.isdir, self._isdir)
270 self._createMock('os.path.isfile', os.path.isfile, self._isfile)
271 self._createMock('os.path.islink', os.path.islink, self._islink)
272 self._createMock('os.path.getmtime', os.path.getmtime, self._getmtime)
273 self._createMock('shutil.copyfile', shutil.copyfile, self._copyfile)
274 self._createMock('shutil.rmtree', shutil.rmtree, self._rmtree)
275 for p in self._patchers:
276 p.start()
277
278 def _endMock(self):
279 for p in self._patchers:
280 p.stop()
281
282 def _createMock(self, name, orig, func, **kwargs):
283 self._originals[name] = orig
284 self._patchers.append(mock.patch(name, func, **kwargs))
285
286 def _doOpen(self, orig_name, path, mode, *args, **kwargs):
287 path = os.path.normpath(path)
288 if path.startswith(resources_path):
289 return self._originals[orig_name](path, mode, *args, **kwargs)
290
291 if 'r' in mode:
292 e = self._getFsEntry(path)
293 elif 'w' in mode or 'x' in mode or 'a' in mode:
294 e = self._getFsEntry(path)
295 if e is None:
296 contents = ''
297 if 'b' in mode:
298 contents = bytes()
299 self._fs._createFile(path, contents)
300 e = self._getFsEntry(path)
301 assert e is not None
302 elif 'x' in mode:
303 err = IOError("File '%s' already exists" % path)
304 err.errno = errno.EEXIST
305 raise err
306 else:
307 err = IOError("Unsupported open mode: %s" % mode)
308 err.errno = errno.EINVAL
309 raise err
310
311 if e is None:
312 err = IOError("No such file: %s" % path)
313 err.errno = errno.ENOENT
314 raise err
315 if not isinstance(e, _MockFsEntry):
316 err = IOError("'%s' is not a file %s" % (path, e))
317 err.errno = errno.EISDIR
318 raise err
319
320 return _MockFsEntryWriter(e, mode)
321
322 def _open(self, path, mode, *args, **kwargs):
323 return self._doOpen('__main__.open', path, mode, *args, **kwargs)
324
325 def _codecsOpen(self, path, mode, *args, **kwargs):
326 return self._doOpen('codecs.open', path, mode, *args, **kwargs)
327
328 def _listdir(self, path):
329 path = os.path.normpath(path)
330 if path.startswith(resources_path):
331 return self._originals['os.listdir'](path)
332
333 e = self._getFsEntry(path)
334 if e is None:
335 raise OSError("No such directory: %s" % path)
336 if not isinstance(e, dict):
337 raise OSError("'%s' is not a directory." % path)
338 return list(e.keys())
339
340 def _makedirs(self, path, mode=0o777):
341 if not path.replace('\\', '/').startswith('/' + self.root):
342 raise Exception("Shouldn't create directory: %s" % path)
343 self._fs._createDir(path)
344
345 def _remove(self, path):
346 path = os.path.normpath(path)
347 self._fs._deleteEntry(path)
348
349 def _exists(self, path):
350 path = os.path.normpath(path)
351 if path.startswith(resources_path):
352 return self._originals['os.path.isdir'](path)
353 e = self._getFsEntry(path)
354 return e is not None
355
356 def _isdir(self, path):
357 path = os.path.normpath(path)
358 if path.startswith(resources_path):
359 return self._originals['os.path.isdir'](path)
360 e = self._getFsEntry(path)
361 return e is not None and isinstance(e, dict)
362
363 def _isfile(self, path):
364 path = os.path.normpath(path)
365 if path.startswith(resources_path):
366 return self._originals['os.path.isfile'](path)
367 e = self._getFsEntry(path)
368 return e is not None and isinstance(e, _MockFsEntry)
369
370 def _islink(self, path):
371 path = os.path.normpath(path)
372 if path.startswith(resources_path):
373 return self._originals['os.path.islink'](path)
374 return False
375
376 def _getmtime(self, path):
377 path = os.path.normpath(path)
378 if path.startswith(resources_path):
379 return self._originals['os.path.getmtime'](path)
380 e = self._getFsEntry(path)
381 if e is None:
382 raise OSError("No such file: %s" % path)
383 return e.metadata['mtime']
384
385 def _copyfile(self, src, dst):
386 src = os.path.normpath(src)
387 if src.startswith(resources_path):
388 with self._originals['__main__.open'](src, 'r') as fp:
389 src_text = fp.read()
390 else:
391 e = self._getFsEntry(src)
392 src_text = e.contents
393 if not dst.replace('\\', '/').startswith('/' + self.root):
394 raise Exception("Shouldn't copy to: %s" % dst)
395 self._fs._createFile(dst, src_text)
396
397 def _rename(self, src, dst):
398 src = os.path.normpath(src)
399 if src.startswith(resources_path) or dst.startswith(resources_path):
400 raise Exception("Shouldn't rename files in the resources path.")
401 self._copyfile(src, dst)
402 self._remove(src)
403
404 def _rmtree(self, path):
405 if not path.replace('\\', '/').startswith('/' + self.root):
406 raise Exception("Shouldn't delete trees from: %s" % path)
407 e = self._fs._getEntry(os.path.dirname(path))
408 del e[os.path.basename(path)]
409
410 def _getFsEntry(self, path):
411 return self._fs._getEntry(path)
412