Reunite UTF-8 hack comment with sys.setdefaultencoding call it labels.
[cookbook.git] / cookbook / cookbook.py
1 #!/usr/bin/python
2 # -*- encoding: utf-8 -*-
3 #
4 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
5 #
6 # This file is part of Cookbook.
7 #
8 # Cookbook is free software: you can redistribute it and/or modify it
9 # under the terms of the GNU General Public License as published by the
10 # Free Software Foundation, either version 3 of the License, or (at your
11 # option) any later version.
12 #
13 # Cookbook is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with Cookbook.  If not, see <http://www.gnu.org/licenses/>.
20
21 """Represent a cookbook and recipes with Python classes.
22 """
23
24 from __future__ import with_statement
25
26 import os
27 import os.path
28 import textwrap
29 import types
30 from urllib import quote_plus
31
32 import yaml
33
34
35 def string_for_yaml(unicode_):
36     """
37     >>> string_for_yaml(None)
38     >>> string_for_yaml('all ascii')
39     'all ascii'
40     >>> string_for_yaml(u'all ascii')
41     'all ascii'
42     >>> string_for_yaml(u'½ ascii')
43     u'\\xc2\\xbd ascii'
44     """
45     if unicode_ == None:
46         return unicode_
47     try:
48         string = unicode_.encode('ascii')
49         return string
50     except UnicodeEncodeError:
51         return unicode_
52
53 def to_yaml_object(obj):
54     """
55     >>> to_yaml_object(None)
56     >>> to_yaml_object('all ascii')
57     'all ascii'
58     >>> to_yaml_object(u'all ascii')
59     'all ascii'
60     >>> to_yaml_object('all ascii')
61     'all ascii'
62     >>> to_yaml_object(u'½ ascii')
63     u'\\xc2\\xbd ascii'
64     >>> class x (object):
65     ...     def to_yaml(self):
66     ...         return 'to_yaml return value'
67     >>> to_yaml_object(x())
68     'to_yaml return value'
69     >>> to_yaml_object([u'all ascii', u'½ ascii'])
70     ['all ascii', u'\\xc2\\xbd ascii']
71     """
72     if obj == None:
73         return obj
74     if hasattr(obj, 'to_yaml'):
75         return obj.to_yaml()
76     if type(obj) in types.StringTypes:
77         return string_for_yaml(obj)
78     if hasattr(obj, '__len__'):
79         ret = []
80         for item in obj:
81             ret.append(to_yaml_object(item))
82         return ret
83     raise NotImplementedError(
84         'cannot convert %s to YAMLable dict:\n%s' % (type(obj), unicode(obj)))
85
86
87 class Amount (object):
88     """
89     >>> import pprint
90     >>> a = Amount(value='1', units='T.')
91     >>> str(a)
92     '1 T.'
93     >>> pprint.pprint(a.to_yaml())
94     {'alternatives': [], 'range_': None, 'units': 'T.', 'value': '1'}
95     >>> x = Amount()
96     >>> x.from_yaml(a.to_yaml())
97     >>> str(x)
98     '1 T.'
99     >>> b = Amount(value='1')
100     >>> str(b)
101     '1'
102     >>> pprint.pprint(b.to_yaml())
103     {'alternatives': [], 'range_': None, 'units': None, 'value': '1'}
104     >>> x.from_yaml(b.to_yaml())
105     >>> str(x)
106     '1'
107     >>> c = Amount(value='15', units='mL', alternatives=[a])
108     >>> str(c)
109     '15 mL (1 T.)'
110     >>> pprint.pprint(c.to_yaml())
111     {'alternatives': [{'alternatives': [],
112                        'range_': None,
113                        'units': 'T.',
114                        'value': '1'}],
115      'range_': None,
116      'units': 'mL',
117      'value': '15'}
118     >>> x.from_yaml(c.to_yaml())
119     >>> str(x)
120     '15 mL (1 T.)'
121     >>> d = Amount(units='T.', range_=['1','2'])
122     >>> str(d)
123     '1-2 T.'
124     >>> pprint.pprint(d.to_yaml())
125     {'alternatives': [], 'range_': ['1', '2'], 'units': 'T.', 'value': None}
126     >>> x.from_yaml(d.to_yaml())
127     >>> str(x)
128     '1-2 T.'
129     """
130     def __init__(self, value=None, units=None, range_=None, alternatives=None):
131         self.value = value
132         self.units = units
133         self.range_ = range_
134         if alternatives == None:
135             alternatives = []
136         self.alternatives = alternatives
137
138     def __str__(self):
139         return str(self.__unicode__())
140
141     def __unicode__(self):
142         if self.range_ == None:
143             value = self.value
144         else:
145             value = '-'.join(self.range_)
146         ret = [value]
147         if self.units != None:
148             ret.append(self.units)
149         if len(self.alternatives) > 0:
150             ret.append('(%s)' % ', '.join(
151                     [unicode(a) for a in self.alternatives]))
152         return ' '.join([x for x in ret if x != None])
153
154     def to_yaml(self):
155         d = {}
156         for key in ['value', 'range_', 'units', 'alternatives']:
157             d[key] = to_yaml_object(getattr(self, key))
158         return d
159
160     def from_yaml(self, dict):
161         for key in ['value', 'range_', 'units']:
162             setattr(self, key, dict.get(key, None))
163         self.alternatives = []
164         for a in dict.get('alternatives', []):
165             amount = Amount()
166             amount.from_yaml(a)
167             self.alternatives.append(amount)
168
169
170 class Ingredient (object):
171     """
172     >>> import pprint
173     >>> i = Ingredient('eye of newt', Amount('1'))
174     >>> str(i)
175     '1 eye of newt'
176     >>> pprint.pprint(i.to_yaml())
177     {'amount': {'alternatives': [], 'range_': None, 'units': None, 'value': '1'},
178      'name': 'eye of newt',
179      'note': None}
180     >>> x = Ingredient()
181     >>> x.from_yaml(i.to_yaml())
182     >>> str(x)
183     '1 eye of newt'
184     >>> j = Ingredient('salamanders', Amount('2'), 'diced fine')
185     >>> str(j)
186     '2 salamanders, diced fine'
187     >>> pprint.pprint(j.to_yaml())
188     {'amount': {'alternatives': [], 'range_': None, 'units': None, 'value': '2'},
189      'name': 'salamanders',
190      'note': 'diced fine'}
191     >>> x.from_yaml(j.to_yaml())
192     >>> str(x)
193     '2 salamanders, diced fine'
194     """
195     def __init__(self, name=None, amount=None, note=None):
196         self.name = name
197         self.amount = amount
198         self.note = note
199
200     def __str__(self):
201         return str(self.__unicode__())
202
203     def __unicode__(self):
204         if self.note == None:
205             return '%s %s' % (unicode(self.amount or ''), self.name)
206         return '%s %s, %s' % (unicode(self.amount or ''), self.name, self.note)
207
208     def to_yaml(self):
209         d = {}
210         for key in ['name', 'amount', 'note']:
211             d[key] = to_yaml_object(getattr(self, key))
212         return d
213
214     def from_yaml(self, dict):
215         for key in ['name', 'note']:
216             setattr(self, key, dict.get(key, None))
217         self.amount = Amount()
218         self.amount.from_yaml(dict.get('amount', {}))
219
220
221 class IngredientBlock (list):
222     """
223     >>> import pprint
224     >>> ib = IngredientBlock(None, [
225     ...         Ingredient('eye of newt', Amount('1')),
226     ...         Ingredient('salamanders', Amount('2'), 'diced fine')])
227     >>> print str(ib)
228     Ingredients:
229       1 eye of newt
230       2 salamanders, diced fine
231     >>> pprint.pprint(ib.to_yaml())
232     {'ingredients': [{'amount': {'alternatives': [],
233                                  'range_': None,
234                                  'units': None,
235                                  'value': '1'},
236                       'name': 'eye of newt',
237                       'note': None},
238                      {'amount': {'alternatives': [],
239                                  'range_': None,
240                                  'units': None,
241                                  'value': '2'},
242                       'name': 'salamanders',
243                       'note': 'diced fine'}],
244      'name': None}
245     >>> x = IngredientBlock()
246     >>> x.from_yaml(ib.to_yaml())
247     >>> print str(x)
248     Ingredients:
249       1 eye of newt
250       2 salamanders, diced fine
251     >>> ib.name = 'Dressing'
252     >>> print str(ib)
253     Dressing:
254       1 eye of newt
255       2 salamanders, diced fine
256     >>> pprint.pprint(ib.to_yaml())
257     {'ingredients': [{'amount': {'alternatives': [],
258                                  'range_': None,
259                                  'units': None,
260                                  'value': '1'},
261                       'name': 'eye of newt',
262                       'note': None},
263                      {'amount': {'alternatives': [],
264                                  'range_': None,
265                                  'units': None,
266                                  'value': '2'},
267                       'name': 'salamanders',
268                       'note': 'diced fine'}],
269      'name': 'Dressing'}
270     >>> x = IngredientBlock()
271     >>> x.from_yaml(ib.to_yaml())
272     >>> print str(x)
273     Dressing:
274       1 eye of newt
275       2 salamanders, diced fine
276     """
277     def __init__(self, name=None, *args, **kwargs):
278         self.name = name
279         super(IngredientBlock, self).__init__(*args, **kwargs)
280
281     def heading(self):
282         if self.name == None:
283             return 'Ingredients'
284         return self.name
285
286     def __str__(self):
287         return str(self.__unicode__())
288
289     def __unicode__(self):
290         ret = [unicode(i) for i in self]
291         ret.insert(0, '%s:' % self.heading())
292         return '\n  '.join(ret)
293
294     def to_yaml(self):
295         d = {}
296         for key in ['name']:
297             d[key] = to_yaml_object(getattr(self, key))
298         d['ingredients'] = to_yaml_object(list(self))
299         return d
300
301     def from_yaml(self, dict):
302         self.name = dict.get('name', None)
303         while len(self) > 0:
304             self.pop()
305         for i in dict.get('ingredients', []):
306             ingredient = Ingredient()
307             ingredient.from_yaml(i)
308             self.append(ingredient)
309
310
311 class Directions (list):
312     """
313     >>> import pprint
314     >>> d = Directions(['paragraph 1', 'paragraph 2', 'paragraph 3'])
315     >>> print str(d)
316     paragraph 1
317     <BLANKLINE>
318     paragraph 2
319     <BLANKLINE>
320     paragraph 3
321     >>> pprint.pprint(d.to_yaml())
322     'paragraph 1\\n\\nparagraph 2\\n\\nparagraph 3'
323     >>> x = Directions()
324     >>> x.from_yaml(d.to_yaml())
325     >>> print str(x)
326     paragraph 1
327     <BLANKLINE>
328     paragraph 2
329     <BLANKLINE>
330     paragraph 3
331     >>> pprint.pprint(x.to_yaml())
332     'paragraph 1\\n\\nparagraph 2\\n\\nparagraph 3'
333     """
334     def __str__(self):
335         return str(self.__unicode__())
336
337     def __unicode__(self):
338         return '\n\n'.join(self)
339
340     def wrapped_paragraphs(self, *args, **kwargs):
341         return ['\n'.join(
342                 textwrap.wrap(
343                     paragraph,
344                     *args,
345                     **kwargs))
346                 for paragraph in self]
347
348     def wrap(self, *args, **kwargs):
349         return '\n\n'.join(self.wrapped_paragraphs(*args, **kwargs))
350
351     def to_yaml(self):
352         return string_for_yaml('\n\n'.join([paragraph.rstrip('\n')
353                                             for paragraph in self]))
354
355     def from_yaml(self, string):
356         if string == None:
357             return
358         while len(self) > 0:
359             self.pop()
360         for paragraph in string.split('\n\n'):
361             self.append(paragraph)
362
363
364 class Recipe (object):
365     """
366     >>> import pprint
367     >>> r = Recipe('Hot Newt Dressing',
368     ...     [IngredientBlock(None, [
369     ...         Ingredient('eye of newt', Amount('1')),
370     ...         Ingredient('salamanders', Amount('2'), 'diced fine')])],
371     ...     Directions(['Mix ingredients until salamander starts to smoke',
372     ...                 'Serve warm over rice']),
373     ...     yield_='enough for one apprentice',
374     ...     author='Merlin',
375     ...     source='CENSORED',
376     ...     url='http://merlin.uk/recipes/1234/',
377     ...     tags=['dinner', 'easy', 'apprentice', 'eye', 'newt', 'salamander'])
378     >>> print str(r)
379     Recipe: Hot Newt Dressing
380     Yield: enough for one apprentice
381     From: Merlin
382     Source: CENSORED
383     URL: http://merlin.uk/recipes/1234/
384     Ingredients:
385       1 eye of newt
386       2 salamanders, diced fine
387     Mix ingredients until salamander starts to smoke
388     <BLANKLINE>
389     Serve warm over rice
390     >>> r.matches_tags(None)
391     True
392     >>> r.matches_tags([])
393     True
394     >>> r.matches_tags(['dinner', 'apprentice'])
395     True
396     >>> r.matches_tags(['dinner', 'dragon'])
397     False
398     >>> pprint.pprint(r.to_yaml())
399     {'author': 'Merlin',
400      'directions': 'Mix ingredients until salamander starts to smoke\\n\\nServe warm over rice',
401      'ingredient_blocks': [{'ingredients': [{'amount': {'alternatives': [],
402                                                         'range_': None,
403                                                         'units': None,
404                                                         'value': '1'},
405                                              'name': 'eye of newt',
406                                              'note': None},
407                                             {'amount': {'alternatives': [],
408                                                         'range_': None,
409                                                         'units': None,
410                                                         'value': '2'},
411                                              'name': 'salamanders',
412                                              'note': 'diced fine'}],
413                             'name': None}],
414      'name': 'Hot Newt Dressing',
415      'source': 'CENSORED',
416      'tags': ['dinner', 'easy', 'apprentice', 'eye', 'newt', 'salamander'],
417      'url': 'http://merlin.uk/recipes/1234/',
418      'yield_': 'enough for one apprentice'}
419     >>> x = Recipe()
420     >>> x.from_yaml(r.to_yaml())
421     >>> print str(x)
422     Recipe: Hot Newt Dressing
423     Yield: enough for one apprentice
424     From: Merlin
425     Source: CENSORED
426     URL: http://merlin.uk/recipes/1234/
427     Ingredients:
428       1 eye of newt
429       2 salamanders, diced fine
430     Mix ingredients until salamander starts to smoke
431     <BLANKLINE>
432     Serve warm over rice
433     """
434     def __init__(self, name=None, ingredient_blocks=None, directions=None,
435                  yield_=None, author=None, source=None, url=None, tags=None):
436         self.name = name
437         self.ingredient_blocks = ingredient_blocks
438         self.directions = directions
439         self.yield_ = yield_
440         self.author = author
441         self.source = source
442         self.url = url
443         self.tags = tags
444         self.path = None
445
446     def clean_name(self, ascii=False):
447         name = self.name
448         for from_,to in [(' ','_'), ('/', '_'), (',', ''), ('&', 'and')]:
449             name = name.replace(from_, to)
450         if ascii == True:
451             return quote_plus(name.encode('utf-8'))
452         return name
453
454     def matches_tags(self, tags):
455         """Return True if this recipe is tagges with each of the tags in tags.
456         """
457         if tags in [None, []]:
458             return True
459         if self.tags == None:
460             return False
461         for t in tags:
462             if t not in self.tags:
463                 return False
464         return True
465
466     def __str__(self):
467         return str(self.__unicode__())
468
469     def __unicode__(self):
470         return '\n'.join([
471                 'Recipe: %s' % self.name,
472                 'Yield: %s' % self.yield_,
473                 'From: %s' % self.author,
474                 'Source: %s' % self.source,
475                 'URL: %s' % self.url,
476                 '\n'.join([unicode(ib) for ib in self.ingredient_blocks]),
477                 unicode(self.directions),
478                 ])
479
480     def to_yaml(self):
481         d = {}
482         for key in ['name', 'ingredient_blocks', 'directions', 'yield_',
483                     'author', 'source', 'url', 'tags']:
484             d[key] = to_yaml_object(getattr(self, key))
485         return d
486
487     def from_yaml(self, dict):
488         for key in ['name', 'yield_', 'author', 'source', 'url', 'tags']:
489             setattr(self, key, dict.get(key, None))
490         self.ingredient_blocks = []
491         for ib in dict.get('ingredient_blocks', []):
492             ingredient_block = IngredientBlock()
493             ingredient_block.from_yaml(ib)
494             self.ingredient_blocks.append(ingredient_block)
495         self.directions = Directions()
496         self.directions.from_yaml(dict.get('directions', None))
497
498     def save(self, stream, path=None):
499         yaml.dump(self.to_yaml(), stream,
500                   default_flow_style=False, allow_unicode=True, width=78)
501         if path != None:
502             self.path = path
503
504     def load(self, stream, path=None):
505         dict = yaml.load(stream)
506         self.from_yaml(dict)
507         if path != None:
508             self.path = path
509
510
511 class Cookbook (list):
512     """
513     TODO: doctests
514     """
515     def __init__(self, name="Mom's cookbook", *args, **kwargs):
516         self.name = name
517         self.save_dir = u'recipe'
518         super(Cookbook, self).__init__(*args, **kwargs)
519
520     def save(self):
521         if not os.path.isdir(self.save_dir):
522             os.mkdir(self.save_dir)
523         paths = []
524         for recipe in self:
525             p = self._free_path(recipe, paths)
526             paths.append(p)
527             with open(p, 'w') as f:
528                 recipe.save(f, p)
529
530     def load(self):
531         for path in sorted(os.listdir(self.save_dir)):
532             r = Recipe()
533             p = os.path.join(self.save_dir, path)
534             with open(p, 'r') as f:
535                 r.load(f, p)
536             self.append(r)
537
538     def make_index(self):
539         self.index = {}
540         paths = [recipe.path for recipe in self]
541         for recipe in self:
542             self.index[recipe.name] = recipe
543             self.index[recipe.clean_name()] = recipe
544             self.index[recipe.clean_name(ascii=True)] = recipe
545             if recipe.path == None:
546                 p = self._free_path(recipe, paths)
547                 paths.append(p)
548                 recipe.path = p
549
550     def _free_path(self, recipe, paths):
551         base_path = os.path.join(self.save_dir, recipe.clean_name())
552         path = base_path
553         i = 2
554         while path in paths:
555             path = '%s_%d' % (base_path, i)
556             i += 1
557         return path
558
559     def tags(self):
560         """List all tags used in this cookbook.
561         """
562         tags = set()
563         for recipe in self:
564             if recipe.tags != None:
565                 tags = tags.union(set(recipe.tags))
566         return sorted(tags)
567
568     def tagged(self, tags=None):
569         """Iterate through all recipes matching the given list of tags.
570         """
571         for recipe in self:
572             if recipe.matches_tags(tags):
573                 yield recipe
574
575
576 def test():
577     import doctest
578     doctest.testmod()