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