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