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.
@@ -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,4 @@
1
+ from .base_objects import Cookware, Ingredient, Quantity, Timing
2
+ from .recipe import Metadata, Recipe, Step
3
+
4
+ __all__ = ['Recipe', 'Step', 'Ingredient', 'Cookware', 'Timing', 'Quantity', 'Metadata']
@@ -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)