Skip to content

Commit 1e42d9f

Browse files
authored
Migrate to new bring library (#17)
* Migrate bring library and refactor ingredient parsing * Bump python version * Bump pre-commit hooks * Moved the `parse_ignored_ingredients` function from `BringHandler` to `main.py` * Remove UUID field from Ingredient class initialization The UUID field is now generated dynamically in the `to_dict` method instead of being set during initialization. * Fix and refactor ignored ingredient parsing * Fix erroneous space in specification if unit and quantity are None * Add support for default values in env variable getter Updated the `EnvironmentVariableGetter.get` method to accept an optional `default_value` parameter. If the specified environment variable is not set, the method now returns the provided default value instead of raising an error. * Add support for handling ingredients without amounts. Introduced `IngredientWithAmountsDisabled` to process ingredients when amounts are not enabled. Updated the ingredient parsing logic to conditionally handle ingredients based on the `enable_amount` flag. * Remove erroneous space in specification * Add fix for unparsed recipes * Removed unnecessary brackets around log messages * Update README to note Mealie v2+ requirement Added an important notice in the README to clarify that the integration only supports Mealie version 2 or higher, released in October 2024. Deprecated support for earlier versions is also mentioned with a reference to the relevant pull request.
1 parent 28a2c05 commit 1e42d9f

10 files changed

Lines changed: 187 additions & 245 deletions

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ repos:
1212
- id: black
1313
args: [ "--line-length", "119" ]
1414
- repo: https://github.com/PyCQA/bandit
15-
rev: 1.8.0
15+
rev: 1.8.2
1616
hooks:
1717
- id: bandit
1818
args: [ '-c', '.bandit.yml', '-r' ]
@@ -22,6 +22,6 @@ repos:
2222
- id: flake8
2323
additional_dependencies: [ flake8-annotations ]
2424
- repo: https://github.com/gitleaks/gitleaks
25-
rev: v8.21.2
25+
rev: v8.23.2
2626
hooks:
2727
- id: gitleaks

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.10-alpine
1+
FROM python:3.12-alpine
22

33
LABEL org.opencontainers.image.source="https://github.com/felixschndr/mealie-bring-api"
44
LABEL org.opencontainers.image.description="The container image of the mealie bring api integration (https://github.com/felixschndr/mealie-bring-api)"

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Mealie instance to be publicly available (from the internet). Since many users w
88
This project provides the source code and a container image for a simple webserver which listens for requests by the
99
Mealie instance and adds the ingredients of a recipe to a specified Bring shopping list.
1010

11+
> [!IMPORTANT]
12+
> This integration does only support Mealie version >= `2` which was [released in October 2024](https://github.com/mealie-recipes/mealie/releases/tag/v2.0.0). The support for Mealie version < `2` was deprecated in https://github.com/felixschndr/mealie-bring-api/pull/17.
13+
1114
## Architecture
1215

1316
### Without this project

requirements-dev.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
flake8==7.0.0
22
isort==5.13.2
3-
black==24.4.2
3+
black==24.4.2
4+
bandit=1.8.2

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
Flask==3.0.3
2-
python-bring-api==3.0.0
32
python-dotenv==1.0.1
3+
bring-api==0.9.1
4+
aiohttp==3.11.10

source/bring_handler.py

Lines changed: 28 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,49 @@
1-
import os
1+
import asyncio
22
import sys
33

4-
from dotenv import load_dotenv
4+
import aiohttp
5+
from bring_api import Bring, BringItemOperation, BringNotificationType
6+
from environment_variable_getter import EnvironmentVariableGetter
57
from ingredient import Ingredient
68
from logger_mixin import LoggerMixin
7-
from python_bring_api.bring import Bring
8-
from python_bring_api.types import BringNotificationType
99

1010

1111
class BringHandler(LoggerMixin):
12-
def __init__(self):
12+
def __init__(self, loop: asyncio.AbstractEventLoop):
1313
super().__init__()
1414

15-
load_dotenv()
16-
self.username = os.getenv("BRING_USERNAME")
17-
self.password = os.getenv("BRING_PASSWORD")
18-
self.list_name = os.getenv("BRING_LIST_NAME")
19-
self.ignored_ingredients_input = os.getenv("IGNORED_INGREDIENTS")
20-
self.def_check_if_environment_variables_are_set()
15+
self.bring = loop.run_until_complete(self.initialize())
16+
self.list_uuid = loop.run_until_complete(self.determine_list_uuid())
2117

22-
self.bring = self.login_into_bring()
18+
async def initialize(self) -> Bring:
19+
username = EnvironmentVariableGetter.get("BRING_USERNAME")
20+
password = EnvironmentVariableGetter.get("BRING_PASSWORD")
2321

24-
self.list_uuid = self.determine_list_uuid()
25-
self.ignored_ingredients = self.parse_ignored_ingredients()
26-
27-
def def_check_if_environment_variables_are_set(self) -> None:
28-
if not self.username or not self.password or not self.list_name:
29-
self.log.critical(
30-
"Ensure that the environment variables BRING_USERNAME, BRING_PASSWORD and BRING_LIST_NAME are set"
31-
)
32-
sys.exit(1)
33-
self.log.debug("Bring credentials and list name are set")
34-
35-
if not self.ignored_ingredients_input:
36-
self.log.info(
37-
'The variable IGNORED_INGREDIENTS is not set. All ingredients will be added. Consider adding something like "Salt,Pepper"'
38-
)
39-
40-
def login_into_bring(self) -> Bring:
41-
bring = Bring(self.username, self.password)
22+
session = aiohttp.ClientSession()
23+
bring = Bring(session, username, password)
4224
self.log.info("Attempting the login into Bring")
43-
bring.login()
25+
await bring.login()
4426
self.log.info("Login successful")
4527

4628
return bring
4729

48-
def determine_list_uuid(self) -> str:
49-
bring_list_uuid = None
50-
bring_list_name_lower = self.list_name.lower()
51-
for bring_list in self.bring.loadLists()["lists"]:
52-
if bring_list["name"].lower() == bring_list_name_lower:
53-
bring_list_uuid = bring_list["listUuid"]
54-
break
55-
56-
if not bring_list_uuid:
57-
self.log.critical(f'Can not find a list with the name "{self.list_name}"')
58-
sys.exit(1)
59-
60-
self.log.info(f'Found the list with the name "{self.list_name}" (UUID: {bring_list_uuid})')
61-
62-
return bring_list_uuid
30+
async def determine_list_uuid(self) -> str:
31+
list_name = EnvironmentVariableGetter.get("BRING_LIST_NAME")
6332

64-
def parse_ignored_ingredients(self) -> list[str]:
65-
ignored_ingredients = []
66-
67-
if not self.ignored_ingredients_input:
68-
return ignored_ingredients
69-
70-
ignored_ingredients_input = self.ignored_ingredients_input.lower()
71-
72-
for ingredient in ignored_ingredients_input.replace(", ", ",").split(","):
73-
ignored_ingredients.append(ingredient)
74-
75-
if ignored_ingredients:
76-
self.log.info(f"Ignoring ingredients {ignored_ingredients}")
77-
78-
return ignored_ingredients
79-
80-
def add_item_to_list(self, ingredient: Ingredient) -> None:
81-
self.log.debug(f"Adding ingredient to Bring: {ingredient}")
33+
list_name_lower = list_name.lower()
34+
for bring_list in (await self.bring.load_lists())["lists"]:
35+
if bring_list["name"].lower() == list_name_lower:
36+
bring_list_uuid = bring_list["listUuid"]
37+
self.log.info(f'Found the list with the name "{list_name}" (UUID: {bring_list_uuid})')
38+
return bring_list_uuid
8239

83-
if ingredient.specification:
84-
if self.check_if_food_is_already_in_list(ingredient.food):
85-
self.log.info(
86-
f"The food {ingredient.food} is already in the list. Adding its specification in the title"
87-
)
88-
self.bring.saveItem(self.list_uuid, f"{ingredient.food} ({ingredient.specification})")
89-
else:
90-
self.bring.saveItem(self.list_uuid, ingredient.food, ingredient.specification)
91-
else:
92-
self.bring.saveItem(self.list_uuid, ingredient.food)
40+
self.log.critical(f'Can not find a list with the name "{list_name}"')
41+
sys.exit(1)
9342

94-
def check_if_food_is_already_in_list(self, food: str) -> bool:
95-
all_saved_foods = self.bring.getItems(self.list_uuid)["purchase"]
96-
for saved_food in all_saved_foods:
97-
if saved_food["name"].lower() == food.lower():
98-
return True
99-
return False
43+
async def add_items(self, ingredients: list[Ingredient]) -> None:
44+
items = [ingredient.to_dict() for ingredient in ingredients]
45+
await self.bring.batch_update_list(self.list_uuid, items, BringItemOperation.ADD)
10046

101-
def notify_users_about_changes_in_list(self) -> None:
47+
async def notify_users_about_changes_in_list(self) -> None:
10248
self.log.debug("Notifying users about changes in shopping list")
103-
self.bring.notify(self.list_uuid, BringNotificationType.CHANGED_LIST)
49+
await self.bring.notify(self.list_uuid, BringNotificationType.CHANGED_LIST)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import os
2+
from typing import Any
3+
4+
from dotenv import find_dotenv, load_dotenv
5+
6+
load_dotenv(override=True)
7+
8+
# Load variables from .env.override with higher priority
9+
load_dotenv(dotenv_path=find_dotenv(".env.override"), override=True)
10+
11+
12+
class EnvironmentVariableGetter:
13+
@staticmethod
14+
def get(name_of_variable: str, default_value: Any = None) -> str:
15+
try:
16+
value = os.environ[name_of_variable]
17+
if value == "":
18+
raise KeyError()
19+
return value
20+
except KeyError:
21+
if default_value is not None:
22+
return default_value
23+
24+
raise RuntimeError(f'The environment variable "{name_of_variable}" is not set!') from None

source/ingredient.py

Lines changed: 78 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,78 @@
1-
from typing import Optional
2-
3-
from errors import IgnoredIngredient
4-
from logger_mixin import LoggerMixin
5-
6-
NO_INGREDIENT_NAME_ERROR = "There is an ingredient with no name, it will be ignored!"
7-
8-
9-
class Ingredient(LoggerMixin):
10-
def __init__(
11-
self,
12-
ingredient_input: dict,
13-
ignored_ingredients: list[str],
14-
enable_amount: bool,
15-
mealie_version_after_2: bool,
16-
):
17-
super().__init__()
18-
19-
self.ingredient_input = ingredient_input
20-
self.ignored_ingredients = ignored_ingredients
21-
22-
self.enable_amount = enable_amount
23-
24-
self.food = None
25-
self.specification = ""
26-
27-
self.parse_input(mealie_version_after_2)
28-
29-
def __repr__(self):
30-
if self.specification:
31-
return f"{self.food} ({self.specification})"
32-
return self.food
33-
34-
def parse_input(self, mealie_version_after_2: bool) -> None:
35-
self.log.debug(f"Parsing {self.ingredient_input}")
36-
37-
if self.enable_amount:
38-
self._parse_input_with_ingredient_amounts(mealie_version_after_2)
39-
else:
40-
self._parse_input_with_no_ingredient_amounts()
41-
42-
self.log.debug(f"Parsed ingredient: {self}")
43-
44-
def _parse_input_with_no_ingredient_amounts(self) -> None:
45-
note = self.ingredient_input["note"]
46-
if not note:
47-
raise ValueError(NO_INGREDIENT_NAME_ERROR)
48-
self.food = note
49-
50-
def _parse_input_with_ingredient_amounts(self, mealie_version_after_2: bool) -> None:
51-
if not self.ingredient_input["food"]:
52-
# Happens if there is an empty ingredient (i.e., added one ingredient but did not fill it out)
53-
raise ValueError(NO_INGREDIENT_NAME_ERROR)
54-
55-
food_name = self.ingredient_input["food"]["name"]
56-
if mealie_version_after_2:
57-
food_plural_name = self.ingredient_input["food"]["plural_name"]
58-
else:
59-
food_plural_name = self.ingredient_input["food"]["pluralName"]
60-
quantity_raw = self.ingredient_input["quantity"] or 0
61-
if int(quantity_raw) == quantity_raw:
62-
quantity = int(quantity_raw)
63-
else:
64-
quantity = quantity_raw
65-
unit = self.ingredient_input["unit"]
66-
note = self.ingredient_input["note"]
67-
68-
# Ignored check #
69-
if food_name.lower() in self.ignored_ingredients:
70-
raise IgnoredIngredient(f"Found ignored ingredient {food_name}")
71-
72-
self._set_food(food_name, food_plural_name, quantity)
73-
self._set_quantity(quantity)
74-
self._set_seperator(quantity, unit)
75-
self._set_unit(quantity, unit, mealie_version_after_2)
76-
self._set_note(note)
77-
78-
def _set_food(self, food_name: str, food_plural_name: str, quantity: int) -> None:
79-
if quantity and quantity > 1 and food_plural_name:
80-
self.food = food_plural_name
81-
return
82-
83-
self.food = food_name
84-
85-
def _set_quantity(self, quantity: int) -> None:
86-
if quantity:
87-
self.specification += str(quantity)
88-
89-
def _set_seperator(self, quantity: int, unit: Optional[dict]) -> None:
90-
if quantity and unit:
91-
self.specification += " "
92-
93-
def _set_unit(self, quantity: int, unit: Optional[dict], mealie_version_after_2: bool) -> None:
94-
if not unit:
95-
return
96-
97-
unit_name = unit["name"]
98-
unit_abbreviation = unit["abbreviation"]
99-
if mealie_version_after_2:
100-
unit_plural_name = unit["plural_name"]
101-
else:
102-
unit_plural_name = unit["pluralName"]
103-
if unit_abbreviation:
104-
self.specification += unit_abbreviation
105-
elif unit_plural_name and quantity and quantity > 1:
106-
self.specification += unit_plural_name
107-
elif unit_name:
108-
self.specification += unit_name
109-
110-
def _set_note(self, note: str) -> None:
111-
if note:
112-
self.specification += f" ({note})"
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import uuid
5+
6+
7+
@dataclasses.dataclass
8+
class Ingredient:
9+
name: str
10+
specification: str = None
11+
12+
@staticmethod
13+
def from_raw_data(raw_data: dict) -> Ingredient:
14+
return Ingredient(name=Ingredient._get_name(raw_data), specification=Ingredient._get_specification(raw_data))
15+
16+
@staticmethod
17+
def _get_name(raw_data: dict) -> str:
18+
return raw_data["food"]["name"]
19+
20+
@staticmethod
21+
def _get_specification(raw_data: dict) -> str:
22+
specification = f"{Ingredient._get_quantity(raw_data)}{Ingredient._get_unit(raw_data)}"
23+
note = Ingredient._get_note(raw_data)
24+
if specification == "" and note == "":
25+
return ""
26+
if specification == "":
27+
return note
28+
if note == "":
29+
return specification
30+
return f"{specification} {note}"
31+
32+
@staticmethod
33+
def _get_quantity(raw_data: dict) -> str:
34+
quantity_raw = raw_data["quantity"]
35+
if quantity_raw is None:
36+
return ""
37+
quantity = int(quantity_raw) if quantity_raw.is_integer() else quantity_raw
38+
return str(quantity)
39+
40+
@staticmethod
41+
def _get_unit(raw_data: dict) -> str:
42+
unit_raw = raw_data["unit"]
43+
if unit_raw is None:
44+
return ""
45+
quantity_is_not_one = raw_data["quantity"] != 1
46+
if quantity_is_not_one:
47+
if unit_raw["plural_name"]:
48+
return f" {unit_raw["plural_name"]}"
49+
if unit_raw["plural_abbreviation"]:
50+
return unit_raw["plural_abbreviation"]
51+
52+
if unit_raw["name"]:
53+
return f" {unit_raw["name"]}"
54+
if unit_raw["abbreviation"]:
55+
return unit_raw["abbreviation"]
56+
57+
return ""
58+
59+
@staticmethod
60+
def _get_note(raw_data: dict) -> str:
61+
if not raw_data["note"]:
62+
return ""
63+
64+
return f"({raw_data['note']})"
65+
66+
@staticmethod
67+
def is_ignored(name_of_ingredient: str, ignored_ingredients: list[Ingredient]) -> bool:
68+
return name_of_ingredient.lower() in [ingredient.name for ingredient in ignored_ingredients]
69+
70+
def to_dict(self) -> dict:
71+
return {"itemId": self.name, "spec": self.specification, "uuid": str(uuid.uuid4())}
72+
73+
74+
@dataclasses.dataclass
75+
class IngredientWithAmountsDisabled(Ingredient):
76+
@staticmethod
77+
def from_raw_data(raw_data: dict) -> Ingredient:
78+
return IngredientWithAmountsDisabled(name=raw_data["display"])

0 commit comments

Comments
 (0)