cooklang-py 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cooklang_py-0.1.0/LICENSE.md +21 -0
- cooklang_py-0.1.0/PKG-INFO +84 -0
- cooklang_py-0.1.0/README.md +69 -0
- cooklang_py-0.1.0/pyproject.toml +58 -0
- cooklang_py-0.1.0/src/cooklang_py/__init__.py +4 -0
- cooklang_py-0.1.0/src/cooklang_py/base_objects.py +174 -0
- cooklang_py-0.1.0/src/cooklang_py/const.py +42 -0
- cooklang_py-0.1.0/src/cooklang_py/recipe.py +138 -0
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Dan Shernicoff
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1,84 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: cooklang-py
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Python Parser for Cooklang
|
5
|
+
Author: Dan Shernicoff
|
6
|
+
Author-email: Dan Shernicoff <dan@brassnet.biz>
|
7
|
+
License-Expression: MIT
|
8
|
+
License-File: LICENSE.md
|
9
|
+
Requires-Dist: frontmatter>=3.0.8
|
10
|
+
Requires-Python: >=3.10
|
11
|
+
Project-URL: Repository, https://github.com/brass75/cooklang-py
|
12
|
+
Project-URL: cooklang, https://cooklang.org
|
13
|
+
Project-URL: homepage, https://github.com/brass75/cooklang-py
|
14
|
+
Description-Content-Type: text/markdown
|
15
|
+
|
16
|
+
# cooklang-py
|
17
|
+
|
18
|
+
A parser for the [Cooklang recipe markup language](https://cooklang.org)
|
19
|
+
|
20
|
+
## Installation
|
21
|
+
|
22
|
+
To install just run:
|
23
|
+
|
24
|
+
```shell
|
25
|
+
pip install cooklang-py
|
26
|
+
```
|
27
|
+
|
28
|
+
## `Recipe`
|
29
|
+
|
30
|
+
The `Recipe` is the primary unit. There are 2 ways to create a `Recipe` object:
|
31
|
+
|
32
|
+
```python
|
33
|
+
from cooklang_py import Recipe
|
34
|
+
|
35
|
+
# Create from a string in memory
|
36
|
+
recipe_string = '<your recipe here>'
|
37
|
+
recipe = Recipe(recipe_string)
|
38
|
+
|
39
|
+
# Create from a file
|
40
|
+
recipe_file = '/path/to/recipe/file'
|
41
|
+
recipe = Recipe.from_file(recipe_file)
|
42
|
+
```
|
43
|
+
|
44
|
+
Just like a recipe in a book a `Recipe` contains:
|
45
|
+
|
46
|
+
- Ingredients
|
47
|
+
- Cookware / equipment
|
48
|
+
- Timings
|
49
|
+
- Metadata (servings, cook time, etc.)
|
50
|
+
|
51
|
+
To see how to define these in your input please refer to the
|
52
|
+
[Cooklang language specification](https://cooklang.org/docs/spec/#comments)
|
53
|
+
|
54
|
+
When the recipe is parsed there will be a list of `Step` objects. Each step object can contain:
|
55
|
+
|
56
|
+
- Ingredients
|
57
|
+
- Cookware
|
58
|
+
- Timings
|
59
|
+
- Instructions (text)
|
60
|
+
|
61
|
+
At both the `Recipe` and `Step` level you can access the list of `Ingredients` and `Cookware`
|
62
|
+
for the recipe or step.
|
63
|
+
|
64
|
+
### Cookware, Timings, and Ingredients
|
65
|
+
|
66
|
+
Cookware, timings, and ingredients are the backbone of the recipe. In this package all three
|
67
|
+
inherit from the `BaseObj`. They all have the following 3 attributes:
|
68
|
+
|
69
|
+
- `name` - the name of the item (i.e. pot, carrot, etc.)
|
70
|
+
- `quantity` - how much is needed
|
71
|
+
- For `Ingredient` quantity defaults to "some" if it is not set.
|
72
|
+
- For `Cookware` quantity defaults to 1 if it is not set.
|
73
|
+
- `notes` - any notes for the item (i.e. "greased", "peeled and diced", etc.)
|
74
|
+
- `Timings` do not have notes per the Cooklang specification.
|
75
|
+
|
76
|
+
## Compatibility
|
77
|
+
|
78
|
+
`cooklang-py` passes all canonical tests defined at
|
79
|
+
[https://github.com/cooklang/cooklang-rs/blob/main/tests/canonical.yaml](https://github.com/cooklang/cooklang-rs/blob/main/tests/canonical.yaml)
|
80
|
+
for the following platforms:
|
81
|
+
|
82
|
+
- Linux
|
83
|
+
- MacOS
|
84
|
+
- Windows (with the exception of test cases with Unicode characters.)
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# cooklang-py
|
2
|
+
|
3
|
+
A parser for the [Cooklang recipe markup language](https://cooklang.org)
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
To install just run:
|
8
|
+
|
9
|
+
```shell
|
10
|
+
pip install cooklang-py
|
11
|
+
```
|
12
|
+
|
13
|
+
## `Recipe`
|
14
|
+
|
15
|
+
The `Recipe` is the primary unit. There are 2 ways to create a `Recipe` object:
|
16
|
+
|
17
|
+
```python
|
18
|
+
from cooklang_py import Recipe
|
19
|
+
|
20
|
+
# Create from a string in memory
|
21
|
+
recipe_string = '<your recipe here>'
|
22
|
+
recipe = Recipe(recipe_string)
|
23
|
+
|
24
|
+
# Create from a file
|
25
|
+
recipe_file = '/path/to/recipe/file'
|
26
|
+
recipe = Recipe.from_file(recipe_file)
|
27
|
+
```
|
28
|
+
|
29
|
+
Just like a recipe in a book a `Recipe` contains:
|
30
|
+
|
31
|
+
- Ingredients
|
32
|
+
- Cookware / equipment
|
33
|
+
- Timings
|
34
|
+
- Metadata (servings, cook time, etc.)
|
35
|
+
|
36
|
+
To see how to define these in your input please refer to the
|
37
|
+
[Cooklang language specification](https://cooklang.org/docs/spec/#comments)
|
38
|
+
|
39
|
+
When the recipe is parsed there will be a list of `Step` objects. Each step object can contain:
|
40
|
+
|
41
|
+
- Ingredients
|
42
|
+
- Cookware
|
43
|
+
- Timings
|
44
|
+
- Instructions (text)
|
45
|
+
|
46
|
+
At both the `Recipe` and `Step` level you can access the list of `Ingredients` and `Cookware`
|
47
|
+
for the recipe or step.
|
48
|
+
|
49
|
+
### Cookware, Timings, and Ingredients
|
50
|
+
|
51
|
+
Cookware, timings, and ingredients are the backbone of the recipe. In this package all three
|
52
|
+
inherit from the `BaseObj`. They all have the following 3 attributes:
|
53
|
+
|
54
|
+
- `name` - the name of the item (i.e. pot, carrot, etc.)
|
55
|
+
- `quantity` - how much is needed
|
56
|
+
- For `Ingredient` quantity defaults to "some" if it is not set.
|
57
|
+
- For `Cookware` quantity defaults to 1 if it is not set.
|
58
|
+
- `notes` - any notes for the item (i.e. "greased", "peeled and diced", etc.)
|
59
|
+
- `Timings` do not have notes per the Cooklang specification.
|
60
|
+
|
61
|
+
## Compatibility
|
62
|
+
|
63
|
+
`cooklang-py` passes all canonical tests defined at
|
64
|
+
[https://github.com/cooklang/cooklang-rs/blob/main/tests/canonical.yaml](https://github.com/cooklang/cooklang-rs/blob/main/tests/canonical.yaml)
|
65
|
+
for the following platforms:
|
66
|
+
|
67
|
+
- Linux
|
68
|
+
- MacOS
|
69
|
+
- Windows (with the exception of test cases with Unicode characters.)
|
@@ -0,0 +1,58 @@
|
|
1
|
+
[project]
|
2
|
+
name = "cooklang_py"
|
3
|
+
version = "0.1.0"
|
4
|
+
description = "Python Parser for Cooklang"
|
5
|
+
readme = "README.md"
|
6
|
+
authors = [{name = "Dan Shernicoff", email = "dan@brassnet.biz"}]
|
7
|
+
license = "MIT"
|
8
|
+
license-files = ["LICENSE.md"]
|
9
|
+
requires-python = ">=3.10"
|
10
|
+
dependencies = [
|
11
|
+
"frontmatter>=3.0.8",
|
12
|
+
]
|
13
|
+
|
14
|
+
[dependency-groups]
|
15
|
+
dev = [
|
16
|
+
"coverage>=7.9.1",
|
17
|
+
"pre-commit>=4.2.0",
|
18
|
+
"pytest>=8.4.1",
|
19
|
+
"pytest-cov>=6.2.1",
|
20
|
+
"ruff>=0.12.1",
|
21
|
+
"uv-build>=0.7.12,<0.8.0",
|
22
|
+
]
|
23
|
+
|
24
|
+
[project.urls]
|
25
|
+
homepage = "https://github.com/brass75/cooklang-py"
|
26
|
+
Repository = "https://github.com/brass75/cooklang-py"
|
27
|
+
cooklang = "https://cooklang.org"
|
28
|
+
|
29
|
+
[tool.ruff]
|
30
|
+
target-version = "py312"
|
31
|
+
# Allow lines to be as long as 120 characters.
|
32
|
+
line-length = 120
|
33
|
+
lint.select = [
|
34
|
+
"E", # pycodestyle
|
35
|
+
"F", # pyflakes
|
36
|
+
"UP", # pyupgrade
|
37
|
+
"I", # imports
|
38
|
+
]
|
39
|
+
|
40
|
+
|
41
|
+
lint.ignore = [
|
42
|
+
]
|
43
|
+
|
44
|
+
# Allow fix for all enabled rules (when `--fix`) is provided.
|
45
|
+
lint.fixable = ["ALL"]
|
46
|
+
lint.unfixable = []
|
47
|
+
|
48
|
+
exclude = [
|
49
|
+
]
|
50
|
+
|
51
|
+
[tool.ruff.format]
|
52
|
+
quote-style = "single"
|
53
|
+
indent-style = "space"
|
54
|
+
docstring-code-format = true
|
55
|
+
|
56
|
+
[build-system]
|
57
|
+
requires = ["uv_build>=0.7.12,<0.8.0"]
|
58
|
+
build-backend = "uv_build"
|
@@ -0,0 +1,174 @@
|
|
1
|
+
"""Base Object for Ingredient, Cookware, and Timing"""
|
2
|
+
|
3
|
+
import re
|
4
|
+
from decimal import Decimal, InvalidOperation
|
5
|
+
from fractions import Fraction
|
6
|
+
|
7
|
+
from .const import NOTE_PATTERN, QUANTITY_PATTERN, UNIT_MAPPINGS
|
8
|
+
|
9
|
+
|
10
|
+
class Quantity:
|
11
|
+
"""Quantity Class"""
|
12
|
+
|
13
|
+
def __init__(self, qstr: str):
|
14
|
+
self._raw = qstr
|
15
|
+
self.unit = ''
|
16
|
+
if '%' in qstr:
|
17
|
+
self.amount, self.unit = qstr.split('%')
|
18
|
+
self.unit = self.unit.strip()
|
19
|
+
else:
|
20
|
+
self.amount = qstr
|
21
|
+
self.amount = self.amount.strip()
|
22
|
+
|
23
|
+
# Try storing the quantity as a numeric value
|
24
|
+
try:
|
25
|
+
if '/' in self.amount:
|
26
|
+
self.amount = Fraction(re.sub(r'\s+', '', self.amount))
|
27
|
+
elif '.' in self.amount:
|
28
|
+
self.amount = Decimal(self.amount)
|
29
|
+
else:
|
30
|
+
self.amount = int(self.amount)
|
31
|
+
except (ValueError, InvalidOperation):
|
32
|
+
pass
|
33
|
+
|
34
|
+
def __eq__(self, other):
|
35
|
+
if not isinstance(other, Quantity):
|
36
|
+
return False
|
37
|
+
return self.amount == other.amount and self.unit == other.unit
|
38
|
+
|
39
|
+
def __str__(self):
|
40
|
+
return f'{self.amount} {UNIT_MAPPINGS.get(self.unit, self.unit)}'.strip()
|
41
|
+
|
42
|
+
def __repr__(self):
|
43
|
+
return f'{self.__class__.__name__}(qstr={repr(self._raw)})'
|
44
|
+
|
45
|
+
|
46
|
+
class BaseObj:
|
47
|
+
"""Base Object for Ingredient, Cookware, and Timing"""
|
48
|
+
|
49
|
+
prefix = None
|
50
|
+
supports_notes = True
|
51
|
+
|
52
|
+
def __init__(
|
53
|
+
self,
|
54
|
+
raw: str,
|
55
|
+
name: str,
|
56
|
+
*,
|
57
|
+
quantity: str = None,
|
58
|
+
notes: str = None,
|
59
|
+
):
|
60
|
+
"""
|
61
|
+
Constructor for the BaseObj class
|
62
|
+
|
63
|
+
:param raw: The raw string the ingredient came from
|
64
|
+
:param name: The name of the ingredient
|
65
|
+
:param quantity: The quantity as described in the raw string
|
66
|
+
:param notes: Notes from the raw string
|
67
|
+
"""
|
68
|
+
self.raw = raw
|
69
|
+
self.name = name.strip()
|
70
|
+
self._quantity = quantity.strip() if quantity else None
|
71
|
+
self.notes = notes
|
72
|
+
self._parsed_quantity = Quantity(quantity) if quantity else ''
|
73
|
+
|
74
|
+
def __eq__(self, other):
|
75
|
+
if not (isinstance(other, BaseObj)):
|
76
|
+
return False
|
77
|
+
return all(getattr(self, attr) == getattr(other, attr) for attr in ('name', '_parsed_quantity', 'notes'))
|
78
|
+
|
79
|
+
@property
|
80
|
+
def long_str(self) -> str:
|
81
|
+
"""Formatted string"""
|
82
|
+
s = str(self.quantity) + ' ' if self.quantity else ''
|
83
|
+
s = f'{s}{self.name}'
|
84
|
+
if self.notes:
|
85
|
+
s += f' ({self.notes})'
|
86
|
+
return s.strip()
|
87
|
+
|
88
|
+
def __repr__(self):
|
89
|
+
s = f'{self.__class__.__name__}(raw={self.raw!r}, name={self.name!r}, quantity={self._quantity!r}'
|
90
|
+
if self.__class__.supports_notes:
|
91
|
+
s += f', notes={repr(self.notes)}'
|
92
|
+
return s + ')'
|
93
|
+
|
94
|
+
@property
|
95
|
+
def quantity(self) -> str:
|
96
|
+
return self._parsed_quantity
|
97
|
+
|
98
|
+
def __str__(self):
|
99
|
+
"""Short version of the formatted string"""
|
100
|
+
if self.quantity:
|
101
|
+
return f'{self.name} ({self.quantity})'.strip()
|
102
|
+
return self.name
|
103
|
+
|
104
|
+
def __hash__(self):
|
105
|
+
return hash(self.raw)
|
106
|
+
|
107
|
+
@classmethod
|
108
|
+
def factory(cls, raw: str):
|
109
|
+
"""
|
110
|
+
Factory to create an object
|
111
|
+
|
112
|
+
:param raw: raw string to create from
|
113
|
+
:return: An object of cls
|
114
|
+
"""
|
115
|
+
if not cls.prefix:
|
116
|
+
raise NotImplementedError(f'{cls.__name__} does not have a prefix set!')
|
117
|
+
if not raw.startswith(cls.prefix):
|
118
|
+
raise ValueError(f'Raw string does not start with {repr(cls.prefix)}: [{repr(raw[0])}]')
|
119
|
+
raw = raw[1:]
|
120
|
+
if next_object_starts := [raw.index(prefix) for prefix in PREFIXES if prefix in raw]:
|
121
|
+
next_start = min(next_object_starts)
|
122
|
+
raw = raw[:next_start]
|
123
|
+
note_pattern = NOTE_PATTERN if cls.supports_notes else ''
|
124
|
+
if match := re.search(rf'(?P<name>.*?){QUANTITY_PATTERN}{note_pattern}', raw):
|
125
|
+
return cls(f'{cls.prefix}{raw[: match.end(match.lastgroup) + 1]}', **match.groupdict())
|
126
|
+
if note_pattern and (match := re.search(rf'^(P<name>[\S]+){note_pattern}', raw)):
|
127
|
+
return cls(f'{cls.prefix}{raw[: match.end(match.lastgroup) + 1]}', **match.groupdict())
|
128
|
+
name = raw.split()[0]
|
129
|
+
name = re.sub(r'\W+$', '', name) or name
|
130
|
+
return cls(f'{cls.prefix}{name}', name=name)
|
131
|
+
|
132
|
+
|
133
|
+
class Ingredient(BaseObj):
|
134
|
+
"""Ingredient"""
|
135
|
+
|
136
|
+
prefix = '@'
|
137
|
+
supports_notes = True
|
138
|
+
|
139
|
+
def __init__(self, *args, **kwargs):
|
140
|
+
if not kwargs.get('quantity', '').strip():
|
141
|
+
kwargs['quantity'] = 'some'
|
142
|
+
super().__init__(*args, **kwargs)
|
143
|
+
|
144
|
+
|
145
|
+
class Cookware(BaseObj):
|
146
|
+
"""Ingredient"""
|
147
|
+
|
148
|
+
prefix = '#'
|
149
|
+
supports_notes = True
|
150
|
+
|
151
|
+
def __init__(self, *args, **kwargs):
|
152
|
+
if not kwargs.get('quantity', '').strip():
|
153
|
+
kwargs['quantity'] = '1'
|
154
|
+
super().__init__(*args, **kwargs)
|
155
|
+
|
156
|
+
|
157
|
+
class Timing(BaseObj):
|
158
|
+
"""Ingredient"""
|
159
|
+
|
160
|
+
prefix = '~'
|
161
|
+
supports_notes = False
|
162
|
+
|
163
|
+
def __str__(self):
|
164
|
+
return str(self.quantity).strip()
|
165
|
+
|
166
|
+
def long_str(self) -> str:
|
167
|
+
return str(self)
|
168
|
+
|
169
|
+
|
170
|
+
PREFIXES = {
|
171
|
+
'@': Ingredient,
|
172
|
+
'#': Cookware,
|
173
|
+
'~': Timing,
|
174
|
+
}
|
@@ -0,0 +1,42 @@
|
|
1
|
+
"""Constants"""
|
2
|
+
|
3
|
+
QUANTITY_PATTERN = r'(?<!\\){(?P<quantity>.*?)}'
|
4
|
+
NOTE_PATTERN = r'(?:\((?P<notes>.*)\))?'
|
5
|
+
|
6
|
+
UNIT_MAPPINGS = {
|
7
|
+
'teaspoon': 'tsp',
|
8
|
+
'tablespoon': 'tbsp',
|
9
|
+
'quart': 'qt',
|
10
|
+
'gallon': 'gal',
|
11
|
+
'kilo': 'kg',
|
12
|
+
'gram': 'g',
|
13
|
+
'ounce': 'oz',
|
14
|
+
'pound': 'lb',
|
15
|
+
'liter': 'l',
|
16
|
+
'milliliter': 'ml',
|
17
|
+
}
|
18
|
+
|
19
|
+
METADATA_MAPPINGS = {
|
20
|
+
'source': 'source.name',
|
21
|
+
'author': 'source.author',
|
22
|
+
'serves': 'servings',
|
23
|
+
'yield': 'servings',
|
24
|
+
'course': 'category',
|
25
|
+
'time required': 'duration',
|
26
|
+
'time': 'duration',
|
27
|
+
'prep time': 'time.prep',
|
28
|
+
'cook time': 'time.cook',
|
29
|
+
'image': 'images',
|
30
|
+
'picture': 'images',
|
31
|
+
'pictures': 'images',
|
32
|
+
'introduction': 'description',
|
33
|
+
}
|
34
|
+
|
35
|
+
METADATA_DISPLAY_MAP = {
|
36
|
+
'source.name': 'Recipe from',
|
37
|
+
'source.author': 'Recipe author',
|
38
|
+
'source.url': 'Recipe URL',
|
39
|
+
'duration': 'Total cook time',
|
40
|
+
'time.prep': 'Prep time',
|
41
|
+
'time.cook': 'Cook time',
|
42
|
+
}
|
@@ -0,0 +1,138 @@
|
|
1
|
+
import re
|
2
|
+
from os import PathLike
|
3
|
+
|
4
|
+
import frontmatter
|
5
|
+
|
6
|
+
from .base_objects import PREFIXES, Cookware, Ingredient
|
7
|
+
from .const import METADATA_DISPLAY_MAP, METADATA_MAPPINGS
|
8
|
+
|
9
|
+
|
10
|
+
class Metadata:
|
11
|
+
"""Recipe Metadata Class"""
|
12
|
+
|
13
|
+
def __init__(self, metadata: str):
|
14
|
+
self._parsed = frontmatter.Frontmatter().read(metadata)
|
15
|
+
attributes = self._parsed.get('attributes')
|
16
|
+
if isinstance(attributes, str):
|
17
|
+
attrs = dict()
|
18
|
+
for line in attributes.splitlines():
|
19
|
+
if ':' not in line:
|
20
|
+
continue
|
21
|
+
key, value = line.split(':', 1)
|
22
|
+
attrs[key.strip()] = value
|
23
|
+
attributes = attrs
|
24
|
+
self._parsed = {k.strip(): v for k, v in attributes.items()}
|
25
|
+
self._mapped = {METADATA_MAPPINGS.get(k.lower(), k.lower()): v for k, v in self._parsed.items()}
|
26
|
+
for attr, value in self._mapped.items():
|
27
|
+
setattr(self, attr, value)
|
28
|
+
|
29
|
+
for attr, value in self._parsed.items():
|
30
|
+
setattr(self, attr, value)
|
31
|
+
|
32
|
+
def __str__(self):
|
33
|
+
s = ''
|
34
|
+
for k, v in self._mapped.items():
|
35
|
+
s += f'{METADATA_DISPLAY_MAP.get(k, k).capitalize()}: {v}\n'
|
36
|
+
if s:
|
37
|
+
return s + ('-' * 50) + '\n'
|
38
|
+
return s
|
39
|
+
|
40
|
+
|
41
|
+
class Recipe:
|
42
|
+
def __init__(self, recipe: str):
|
43
|
+
self._raw = recipe
|
44
|
+
if match := re.search(r'(---.*---\s*)(.*)', recipe, re.DOTALL):
|
45
|
+
self.metadata = Metadata(match.group(1))
|
46
|
+
body = match.group(2)
|
47
|
+
else:
|
48
|
+
body = recipe
|
49
|
+
if not body:
|
50
|
+
raise ValueError('No body found in recipe!')
|
51
|
+
self.steps = list()
|
52
|
+
self.ingredients = list()
|
53
|
+
self.cookware = list()
|
54
|
+
for line in re.split(r'\n{2,}', body):
|
55
|
+
line = re.sub(r'\s+', ' ', line)
|
56
|
+
if step := Step(line):
|
57
|
+
self.steps.append(step)
|
58
|
+
self.ingredients.extend(step.ingredients)
|
59
|
+
self.cookware.extend(step.cookware)
|
60
|
+
|
61
|
+
def __iter__(self):
|
62
|
+
yield from self.steps
|
63
|
+
|
64
|
+
def __len__(self):
|
65
|
+
return len(self.steps)
|
66
|
+
|
67
|
+
def __str__(self):
|
68
|
+
s = str(self.metadata)
|
69
|
+
s += 'Ingredients:\n\n'
|
70
|
+
s += '\n'.join(ing.long_str for ing in self.ingredients)
|
71
|
+
s += '\n' + ('-' * 50) + '\n'
|
72
|
+
if self.cookware:
|
73
|
+
s += '\nCookware:\n\n'
|
74
|
+
s += '\n'.join(ing.long_str for ing in self.cookware)
|
75
|
+
s += '\n' + ('-' * 50) + '\n'
|
76
|
+
s += '\n'
|
77
|
+
s += '\n'.join(map(str, self))
|
78
|
+
return s.replace('\\', '') + '\n'
|
79
|
+
|
80
|
+
@staticmethod
|
81
|
+
def from_file(filename: PathLike):
|
82
|
+
"""
|
83
|
+
Load a recipe from a file
|
84
|
+
|
85
|
+
:param filename: Path like object indicating the location of the file.
|
86
|
+
:return: Recipe object
|
87
|
+
"""
|
88
|
+
with open(filename) as f:
|
89
|
+
return Recipe(f.read())
|
90
|
+
|
91
|
+
|
92
|
+
class Step:
|
93
|
+
def __init__(self, line: str):
|
94
|
+
self._raw = line
|
95
|
+
self.ingredients = list()
|
96
|
+
self.cookware = list()
|
97
|
+
self._sections = list()
|
98
|
+
self.parse(line)
|
99
|
+
|
100
|
+
def __iter__(self):
|
101
|
+
yield from self._sections
|
102
|
+
|
103
|
+
def __len__(self):
|
104
|
+
return len(self._sections)
|
105
|
+
|
106
|
+
def __repr__(self):
|
107
|
+
return repr(self._sections)
|
108
|
+
|
109
|
+
def parse(self, line: str):
|
110
|
+
"""
|
111
|
+
Parse a line into its component parts
|
112
|
+
:param line:
|
113
|
+
:return:
|
114
|
+
"""
|
115
|
+
if not (section := self._remove_comments(line)):
|
116
|
+
return
|
117
|
+
self._sections.clear()
|
118
|
+
while match := re.search(r'(?<!\\)[@#~][\S]', section):
|
119
|
+
if section[: match.start()].strip():
|
120
|
+
self._sections.append(section[: match.start()])
|
121
|
+
section = section[match.start() :]
|
122
|
+
obj = PREFIXES[section[0]].factory(section)
|
123
|
+
self._sections.append(obj)
|
124
|
+
section = section.removeprefix(obj.raw)
|
125
|
+
match obj:
|
126
|
+
case Ingredient():
|
127
|
+
self.ingredients.append(obj)
|
128
|
+
case Cookware():
|
129
|
+
self.cookware.append(obj)
|
130
|
+
if section.strip():
|
131
|
+
self._sections.append(section)
|
132
|
+
|
133
|
+
def __str__(self):
|
134
|
+
return ''.join(map(str, self)).rstrip()
|
135
|
+
|
136
|
+
@staticmethod
|
137
|
+
def _remove_comments(line: str) -> str:
|
138
|
+
return re.sub(r'--.*(?:$|\n)|\[-.*?-]', '', line)
|