From 7c243662bbcbcf16eca58feeeb67a995ed26f9a4 Mon Sep 17 00:00:00 2001 From: Dorthe Luebbert Date: Fri, 10 Feb 2023 08:43:39 +0100 Subject: [PATCH 01/10] export a single recipe from local repository to Paprika app file format --- .gitignore | 3 + README.md | 17 +++--- kptncook/__init__.py | 41 ++++++++++++- kptncook/paprika.py | 105 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 + templates/paprika.jinja2.json | 24 ++++++++ tests/paprika_test.py | 91 +++++++++++++++++++++++++++++ 7 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 kptncook/paprika.py create mode 100644 templates/paprika.jinja2.json create mode 100644 tests/paprika_test.py diff --git a/.gitignore b/.gitignore index 5a2f885..2a7de09 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ __pycache__ # environment .env + +# export files for Paprika app accidently create in source code directory +*.paprikarecipes \ No newline at end of file diff --git a/README.md b/README.md index c76ef18..b42d911 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,15 @@ Options: --help Show this message and exit. Commands: - backup-favorites Store kptncook favorites in local repository. - kptncook-access-token Get access token for kptncook. - kptncook-today List all recipes for today from the kptncook... - list-recipes List all locally saved recipes. - save-todays-recipes Save recipes for today from kptncook site. - search-by-id Search for a recipe by id in kptncook api, id... - sync Fetch recipes for today from api, save them to... - sync-with-mealie Sync locally saced recipes with mealie. + backup-favorites Store kptncook favorites in local repository. + kptncook-access-token Get access token for kptncook. + kptncook-today List all recipes for today from the kptncook... + list-recipes List all locally saved recipes. + save-todays-recipes Save recipes for today from kptncook site. + search-by-id Search for a recipe by id in kptncook api, id... + sync Fetch recipes for today from api, save them to... + sync-with-mealie Sync locally saced recipes with mealie. + export-recipe-to-paprika Export a recipe to Paprika app ``` ## Environment diff --git a/kptncook/__init__.py b/kptncook/__init__.py index 7d390fe..980bfdf 100644 --- a/kptncook/__init__.py +++ b/kptncook/__init__.py @@ -17,6 +17,7 @@ from .mealie import MealieApiClient, kptncook_to_mealie from .models import Recipe from .repositories import RecipeRepository +from .paprika import PaprikaExporter __all__ = [ "list_kptncook_today", @@ -27,9 +28,10 @@ "get_kptncook_access_token", "list_recipes", "search_kptncook_recipe_by_id", + "export_recipe_to_paprika", ] -__version__ = "0.0.9" +__version__ = "0.0.10" cli = typer.Typer() @@ -80,6 +82,16 @@ def get_kptncook_recipes_from_repository() -> list[Recipe]: return recipes +def get_recipe_from_repository_by_oid(oid: str) -> list[Recipe]: + """ + get one single recipe from local repository + :param oid: oid of recipe + :return: list + """ + recipes = get_kptncook_recipes_from_repository() + return [(recipe) for num, recipe in enumerate(recipes) if recipe.id.oid == oid] + + @cli.command(name="sync-with-mealie") def sync_with_mealie(): """ @@ -191,5 +203,32 @@ def search_kptncook_recipe_by_id(_id: str): rprint(f"Added recipe {id_type} {id_value} to local repository") +@cli.command(name="export-recipe-to-paprika") +def export_recipe_to_paprika(_id: str): + """ + Export a recipe to Paprika app + + Example usage: kptncook export-recipe-to-paprika 635a68635100007500061cd7 + """ + parsed = parse_id(_id) + if parsed is None: + rprint("Could not parse id") + sys.exit(1) + # we can expect always an oid here - correct? + id_type, id_value = parsed + found_recipes = get_recipe_from_repository_by_oid(oid=id_value) + if len(found_recipes) == 0: + rprint("Recipe not found.") + sys.exit(1) + if len(found_recipes) > 1: + rprint("More than one recipe found with that ID.") + sys.exit(1) + + exporter = PaprikaExporter() + recipe = found_recipes[0] + filename = exporter.export(recipe=recipe) + rprint("\n The recipe was exported to '%s'. Open this file with the Paprika App.\n" % filename) + + if __name__ == "__main__": cli() diff --git a/kptncook/paprika.py b/kptncook/paprika.py new file mode 100644 index 0000000..61080df --- /dev/null +++ b/kptncook/paprika.py @@ -0,0 +1,105 @@ +""" +Export a single recipe to Paprika App + +(file format: + 1. export recipe to json + 2. compress file as gz: naming convention: a_recipe_name.paprikarecipe (Singular) + 3. zip this file as some_recipes.paprikarecipes (Plural!) +""" +import os +import re +import shutil +import uuid +import requests +import base64 +import zipfile +import gzip +import secrets +import tempfile +from pathlib import Path +from datetime import datetime + +from jinja2 import Environment, FileSystemLoader +from unidecode import unidecode + +from kptncook.models import Recipe +from kptncook.config import settings + + +class PaprikaExporter(): + + def export(self, recipe: Recipe): + renderer = ExportRenderer() + cover_filename, cover_img = self.get_cover_img_as_base64_string(recipe=recipe) + + recipe_as_json = renderer.render(template_name="paprika.jinja2.json", + recipe=recipe, + uid=str(uuid.uuid4()).upper(), + dtnow=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + cover_filename=cover_filename, + hash=secrets.token_hex(32), + cover_img=cover_img) + filename = self.asciify_string(s=recipe.localized_title.de) + ".paprikarecipes" + tmp_dir = tempfile.mkdtemp() + filename_full_path = self.save_recipe(recipe_as_json=recipe_as_json, + filename=filename, dir=tmp_dir) + self.move_to_target_dir(source=filename_full_path, target=os.path.join(str(Path.cwd()), filename)) + return filename + + def move_to_target_dir(self, source: str, target: str): + shutil.move(source, target) + + def asciify_string(self, s): + s = unidecode(s) + s = re.sub(r'[^\w\s]', '_', s) + s = re.sub(r'\s+', '_', s) + return s + + def save_recipe(self, + recipe_as_json: str, + filename: str, + dir: str): + recipe_as_gz = os.path.join(dir, "arecipe.paprikarecipe") + with gzip.open(recipe_as_gz, "wb") as f: + f.write(recipe_as_json.encode("utf-8")) + filename_full_path = os.path.join(dir, filename) + with zipfile.ZipFile(filename_full_path, "w", + compression=zipfile.ZIP_DEFLATED, + allowZip64=True) as zip_file: + zip_file.write(recipe_as_gz) + return filename_full_path + + def get_cover_img_as_base64_string(self, recipe: Recipe): + cover = self.get_cover(image_list=recipe.image_list) + cover_url = recipe.get_image_url(api_key=settings.kptncook_api_key) + if not isinstance(cover_url, str): + return None + + response = requests.get(cover_url) + if response.status_code == 200: + return cover.name, base64.b64encode(response.content).decode("utf-8") + + def get_cover(self, image_list: list): + if not isinstance(image_list, list): + raise ValueError("Parameter image_list must be a list") + try: + [cover] = [i for i in image_list if i.type == "cover"] + except ValueError: + return None + return cover + + +class ExportRenderer(): + def render(self, template_name: str, recipe: Recipe, **kwargs) -> str: + environment = self.get_environement() + template = environment.get_template(template_name) + return template.render(recipe=recipe, **kwargs) + + def get_template_dir(self): + module_path = os.path.abspath(__file__) + real_path = os.path.realpath(module_path) + root_dir = os.path.dirname(os.path.dirname(real_path)) + return os.path.join(root_dir, "templates") + + def get_environement(self): + return Environment(loader=FileSystemLoader(self.get_template_dir()), trim_blocks=True) diff --git a/pyproject.toml b/pyproject.toml index a33e213..0e51be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ "pydantic[dotenv] >= 1.9", "typer >= 0.4", "click", + "unidecode", + "jinja2" ] [project.optional-dependencies] diff --git a/templates/paprika.jinja2.json b/templates/paprika.jinja2.json new file mode 100644 index 0000000..caf02e7 --- /dev/null +++ b/templates/paprika.jinja2.json @@ -0,0 +1,24 @@ +{ + "uid":"{{uid}}", + "name":"{{recipe.localized_title.de}}", + "directions": "{% for step in recipe.steps %}{{step.title.de}}\n{% endfor %}", + "servings":"2", + "rating":0, + "difficulty":"", + "ingredients":"{% for ingredient in recipe.ingredients %}{% if ingredient.quantity %}{{'{0:g}'.format(ingredient.quantity) }}{% endif %} {{ingredient.measure|default('',true)}} {{ingredient.ingredient.uncountableTitle.de|default('',true)}}\n{% endfor %}", + "notes":"", + "created":"{{dtnow}}", + "image_url":null, + "cook_time":"{{recipe.cooking_time|default('',true)}}", + "prep_time":"{{recipe.preparation_time|default('',true)}}", + "source":"Kptncook", + "source_url":"", + "hash" : "{{hash}}", + "photo_hash":null, + "photos":[], + "photo": "{{cover_filename}}", + "nutritional_info":"{% for nutrient, amount in recipe.recipe_nutrition %}{{nutrient}}: {{amount}}\n{% endfor %}", + "photo_data":"{{cover_img}}", + "photo_large":null, + "categories":["Kptncook"] +} \ No newline at end of file diff --git a/tests/paprika_test.py b/tests/paprika_test.py new file mode 100644 index 0000000..ba9e02b --- /dev/null +++ b/tests/paprika_test.py @@ -0,0 +1,91 @@ +import os +import pytest + +from jinja2.exceptions import TemplateNotFound + +from kptncook.paprika import PaprikaExporter, ExportRenderer +from kptncook.models import Recipe + + +def test_asciify_string(): + p = PaprikaExporter() + assert p.asciify_string("Süßkartoffeln mit Taboulé & Dip") == "Susskartoffeln_mit_Taboule___Dip" + assert p.asciify_string("Ölige_Ähren") == "Olige_Ahren" + + +def test_get_cover_img_as_base64_string(full_recipe): + p = PaprikaExporter() + recipe = Recipe.parse_obj(full_recipe) + cover_info = p.get_cover_img_as_base64_string(recipe=recipe) + assert isinstance(cover_info, tuple) is True + assert len(cover_info) == 2 + + # no images availlable for some reason + recipe.image_list = list() + cover_info = p.get_cover_img_as_base64_string(recipe=recipe) + assert cover_info is None + + +def test_export(full_recipe): + p = PaprikaExporter() + recipe = Recipe.parse_obj(full_recipe) + p.export(recipe=recipe) + expected_file = "Uberbackene_Muschelnudeln_mit_Lachs___Senf_Dill_Sauce.paprikarecipes" + assert os.path.isfile(expected_file) is True + if os.path.isfile(expected_file): + os.unlink(expected_file) + + +def test_get_cover(minimal): + p = PaprikaExporter() + recipe = Recipe.parse_obj(minimal) + assert p.get_cover(image_list=list()) is None + + cover = p.get_cover(image_list=recipe.image_list) + assert cover.name == 'REZ_1837_Cover.jpg' + + with pytest.raises(ValueError): + cover = p.get_cover(image_list=None) + + with pytest.raises(ValueError): + cover = p.get_cover(image_list=dict()) + + +def test_get_template_dir(): + r = ExportRenderer() + assert os.path.isdir(r.get_template_dir()) is True + + +def test_render(minimal): + # happy path + recipe = Recipe.parse_obj(minimal) + r = ExportRenderer() + json = r.render(template_name="paprika.jinja2.json", recipe=recipe) + assert json == ('{\n' + ' "uid":"",\n' + ' "name":"Minimal Recipe",\n' + ' "directions": "Alles parat?\\n",\n' + ' "servings":"2",\n' + ' "rating":0,\n' + ' "difficulty":"",\n' + ' "ingredients":"",\n' + ' "notes":"",\n' + ' "created":"",\n' + ' "image_url":null,\n' + ' "cook_time":"",\n' + ' "prep_time":"20",\n' + ' "source":"Kptncook",\n' + ' "source_url":"",\n' + ' "hash" : "",\n' + ' "photo_hash":null,\n' + ' "photos":[],\n' + ' "photo": "",\n' + ' "nutritional_info":"calories: 100\\nprotein: 30\\nfat: 10\\ncarbohydrate: ' + '20\\n",\n' + ' "photo_data":"",\n' + ' "photo_large":null,\n' + ' "categories":["Kptncook"]\n' + '}') + # invalid + with pytest.raises(TemplateNotFound): + json = r.render(template_name="invalid_template.jinja2.json", recipe=recipe) From 895ea09daf39dbfe930da63f0b6a0d63a4a0186a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Wersd=C3=B6rfer?= Date: Tue, 14 Feb 2023 07:28:43 +0100 Subject: [PATCH 02/10] use httpx instead of requests, to avoid having to install the requests type stubs for mypy and also because httpx is used everywhere else some typos + staticmethod decorators.. replaced dir with directory to avoid shadowing builtin dir --- kptncook/paprika.py | 88 ++++++++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/kptncook/paprika.py b/kptncook/paprika.py index 61080df..e4991c0 100644 --- a/kptncook/paprika.py +++ b/kptncook/paprika.py @@ -1,71 +1,75 @@ """ Export a single recipe to Paprika App -(file format: +file format: 1. export recipe to json 2. compress file as gz: naming convention: a_recipe_name.paprikarecipe (Singular) 3. zip this file as some_recipes.paprikarecipes (Plural!) """ +import base64 +import gzip import os import re +import secrets import shutil +import tempfile import uuid -import requests -import base64 import zipfile -import gzip -import secrets -import tempfile -from pathlib import Path from datetime import datetime +from pathlib import Path +import httpx from jinja2 import Environment, FileSystemLoader from unidecode import unidecode -from kptncook.models import Recipe from kptncook.config import settings +from kptncook.models import Recipe -class PaprikaExporter(): - +class PaprikaExporter: def export(self, recipe: Recipe): renderer = ExportRenderer() cover_filename, cover_img = self.get_cover_img_as_base64_string(recipe=recipe) - recipe_as_json = renderer.render(template_name="paprika.jinja2.json", - recipe=recipe, - uid=str(uuid.uuid4()).upper(), - dtnow=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - cover_filename=cover_filename, - hash=secrets.token_hex(32), - cover_img=cover_img) + recipe_as_json = renderer.render( + template_name="paprika.jinja2.json", + recipe=recipe, + uid=str(uuid.uuid4()).upper(), + dtnow=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + cover_filename=cover_filename, + hash=secrets.token_hex(32), + cover_img=cover_img, + ) filename = self.asciify_string(s=recipe.localized_title.de) + ".paprikarecipes" tmp_dir = tempfile.mkdtemp() - filename_full_path = self.save_recipe(recipe_as_json=recipe_as_json, - filename=filename, dir=tmp_dir) - self.move_to_target_dir(source=filename_full_path, target=os.path.join(str(Path.cwd()), filename)) + filename_full_path = self.save_recipe( + recipe_as_json=recipe_as_json, filename=filename, directory=tmp_dir + ) + self.move_to_target_dir( + source=filename_full_path, target=os.path.join(str(Path.cwd()), filename) + ) return filename - def move_to_target_dir(self, source: str, target: str): + @staticmethod + def move_to_target_dir(source: str, target: str): shutil.move(source, target) - def asciify_string(self, s): + @staticmethod + def asciify_string(s): s = unidecode(s) - s = re.sub(r'[^\w\s]', '_', s) - s = re.sub(r'\s+', '_', s) + s = re.sub(r"[^\w\s]", "_", s) + s = re.sub(r"\s+", "_", s) return s - def save_recipe(self, - recipe_as_json: str, - filename: str, - dir: str): - recipe_as_gz = os.path.join(dir, "arecipe.paprikarecipe") + @staticmethod + def save_recipe(recipe_as_json: str, filename: str, directory: str): + recipe_as_gz = os.path.join(directory, "arecipe.paprikarecipe") with gzip.open(recipe_as_gz, "wb") as f: f.write(recipe_as_json.encode("utf-8")) - filename_full_path = os.path.join(dir, filename) - with zipfile.ZipFile(filename_full_path, "w", - compression=zipfile.ZIP_DEFLATED, - allowZip64=True) as zip_file: + filename_full_path = os.path.join(directory, filename) + with zipfile.ZipFile( + filename_full_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True + ) as zip_file: zip_file.write(recipe_as_gz) return filename_full_path @@ -75,11 +79,12 @@ def get_cover_img_as_base64_string(self, recipe: Recipe): if not isinstance(cover_url, str): return None - response = requests.get(cover_url) + response = httpx.get(cover_url) if response.status_code == 200: return cover.name, base64.b64encode(response.content).decode("utf-8") - def get_cover(self, image_list: list): + @staticmethod + def get_cover(image_list: list): if not isinstance(image_list, list): raise ValueError("Parameter image_list must be a list") try: @@ -89,17 +94,20 @@ def get_cover(self, image_list: list): return cover -class ExportRenderer(): +class ExportRenderer: def render(self, template_name: str, recipe: Recipe, **kwargs) -> str: - environment = self.get_environement() + environment = self.get_environment() template = environment.get_template(template_name) return template.render(recipe=recipe, **kwargs) - def get_template_dir(self): + @staticmethod + def get_template_dir(): module_path = os.path.abspath(__file__) real_path = os.path.realpath(module_path) root_dir = os.path.dirname(os.path.dirname(real_path)) return os.path.join(root_dir, "templates") - def get_environement(self): - return Environment(loader=FileSystemLoader(self.get_template_dir()), trim_blocks=True) + def get_environment(self): + return Environment( + loader=FileSystemLoader(self.get_template_dir()), trim_blocks=True + ) From 50afa2e923b0d10c5bf259f7708ca7db0258a378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Wersd=C3=B6rfer?= Date: Wed, 15 Feb 2023 00:47:28 +0100 Subject: [PATCH 03/10] mock httpx.get to avoid hitting the network during tests + some minor fixes --- pyproject.toml | 1 + tests/paprika_test.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0e51be8..c80ba49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ test = [ "pytest >= 6", "pytest-cov >= 3", + "pytest-mock", ] doc = [ "mkdocs >= 1.2", diff --git a/tests/paprika_test.py b/tests/paprika_test.py index ba9e02b..bfbb06c 100644 --- a/tests/paprika_test.py +++ b/tests/paprika_test.py @@ -13,22 +13,24 @@ def test_asciify_string(): assert p.asciify_string("Ölige_Ähren") == "Olige_Ahren" -def test_get_cover_img_as_base64_string(full_recipe): +def test_get_cover_img_as_base64_string(full_recipe, mocker): p = PaprikaExporter() recipe = Recipe.parse_obj(full_recipe) + mocker.patch("kptncook.paprika.httpx.get", return_value=mocker.Mock(content=b"foobar", status_code=200)) cover_info = p.get_cover_img_as_base64_string(recipe=recipe) assert isinstance(cover_info, tuple) is True assert len(cover_info) == 2 - # no images availlable for some reason + # no images available for some reason recipe.image_list = list() cover_info = p.get_cover_img_as_base64_string(recipe=recipe) assert cover_info is None -def test_export(full_recipe): +def test_export(full_recipe, mocker): p = PaprikaExporter() recipe = Recipe.parse_obj(full_recipe) + mocker.patch("kptncook.paprika.httpx.get", return_value=mocker.Mock(content=b"foobar", status_code=200)) p.export(recipe=recipe) expected_file = "Uberbackene_Muschelnudeln_mit_Lachs___Senf_Dill_Sauce.paprikarecipes" assert os.path.isfile(expected_file) is True @@ -45,10 +47,10 @@ def test_get_cover(minimal): assert cover.name == 'REZ_1837_Cover.jpg' with pytest.raises(ValueError): - cover = p.get_cover(image_list=None) + p.get_cover(image_list=None) with pytest.raises(ValueError): - cover = p.get_cover(image_list=dict()) + p.get_cover(image_list=dict()) def test_get_template_dir(): @@ -88,4 +90,4 @@ def test_render(minimal): '}') # invalid with pytest.raises(TemplateNotFound): - json = r.render(template_name="invalid_template.jinja2.json", recipe=recipe) + r.render(template_name="invalid_template.jinja2.json", recipe=recipe) From e90ee9391d8f16564da86ec03164d35db8ddeebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Wersd=C3=B6rfer?= Date: Wed, 15 Feb 2023 00:48:58 +0100 Subject: [PATCH 04/10] autoupdate --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index feb60e0..bdcdcda 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,19 +7,19 @@ repos: - id: check-yaml - id: check-added-large-files - repo: https://github.com/asottile/pyupgrade - rev: v3.3.0 + rev: v3.3.1 hooks: - id: pyupgrade args: [--py310-plus] - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 23.1.0 hooks: - id: black language_version: python3.11 - repo: https://github.com/timothycrosley/isort # isort config is in setup.cfg - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort language_version: python3 From d5c05cbe107146706db65e0f1991a637bf65c6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Wersd=C3=B6rfer?= Date: Wed, 15 Feb 2023 00:49:18 +0100 Subject: [PATCH 05/10] ignore swap files --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2a7de09..0890d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,8 @@ __pycache__ .env # export files for Paprika app accidently create in source code directory -*.paprikarecipes \ No newline at end of file +*.paprikarecipes + +# vim +.*swp +.*swo From cf5a6dad17a3a537a0318a0f20151c59d639d54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Wersd=C3=B6rfer?= Date: Wed, 15 Feb 2023 00:51:07 +0100 Subject: [PATCH 06/10] pre-commit hooks working again --- kptncook/__init__.py | 7 +++- templates/paprika.jinja2.json | 2 +- tests/paprika_test.py | 77 ++++++++++++++++++++--------------- 3 files changed, 51 insertions(+), 35 deletions(-) diff --git a/kptncook/__init__.py b/kptncook/__init__.py index 980bfdf..56b531c 100644 --- a/kptncook/__init__.py +++ b/kptncook/__init__.py @@ -16,8 +16,8 @@ from .config import settings from .mealie import MealieApiClient, kptncook_to_mealie from .models import Recipe -from .repositories import RecipeRepository from .paprika import PaprikaExporter +from .repositories import RecipeRepository __all__ = [ "list_kptncook_today", @@ -227,7 +227,10 @@ def export_recipe_to_paprika(_id: str): exporter = PaprikaExporter() recipe = found_recipes[0] filename = exporter.export(recipe=recipe) - rprint("\n The recipe was exported to '%s'. Open this file with the Paprika App.\n" % filename) + rprint( + "\n The recipe was exported to '%s'. Open this file with the Paprika App.\n" + % filename + ) if __name__ == "__main__": diff --git a/templates/paprika.jinja2.json b/templates/paprika.jinja2.json index caf02e7..9720dab 100644 --- a/templates/paprika.jinja2.json +++ b/templates/paprika.jinja2.json @@ -21,4 +21,4 @@ "photo_data":"{{cover_img}}", "photo_large":null, "categories":["Kptncook"] -} \ No newline at end of file +} diff --git a/tests/paprika_test.py b/tests/paprika_test.py index bfbb06c..17c067f 100644 --- a/tests/paprika_test.py +++ b/tests/paprika_test.py @@ -1,22 +1,28 @@ import os -import pytest +import pytest from jinja2.exceptions import TemplateNotFound -from kptncook.paprika import PaprikaExporter, ExportRenderer from kptncook.models import Recipe +from kptncook.paprika import ExportRenderer, PaprikaExporter def test_asciify_string(): p = PaprikaExporter() - assert p.asciify_string("Süßkartoffeln mit Taboulé & Dip") == "Susskartoffeln_mit_Taboule___Dip" + assert ( + p.asciify_string("Süßkartoffeln mit Taboulé & Dip") + == "Susskartoffeln_mit_Taboule___Dip" + ) assert p.asciify_string("Ölige_Ähren") == "Olige_Ahren" def test_get_cover_img_as_base64_string(full_recipe, mocker): p = PaprikaExporter() recipe = Recipe.parse_obj(full_recipe) - mocker.patch("kptncook.paprika.httpx.get", return_value=mocker.Mock(content=b"foobar", status_code=200)) + mocker.patch( + "kptncook.paprika.httpx.get", + return_value=mocker.Mock(content=b"foobar", status_code=200), + ) cover_info = p.get_cover_img_as_base64_string(recipe=recipe) assert isinstance(cover_info, tuple) is True assert len(cover_info) == 2 @@ -30,9 +36,14 @@ def test_get_cover_img_as_base64_string(full_recipe, mocker): def test_export(full_recipe, mocker): p = PaprikaExporter() recipe = Recipe.parse_obj(full_recipe) - mocker.patch("kptncook.paprika.httpx.get", return_value=mocker.Mock(content=b"foobar", status_code=200)) + mocker.patch( + "kptncook.paprika.httpx.get", + return_value=mocker.Mock(content=b"foobar", status_code=200), + ) p.export(recipe=recipe) - expected_file = "Uberbackene_Muschelnudeln_mit_Lachs___Senf_Dill_Sauce.paprikarecipes" + expected_file = ( + "Uberbackene_Muschelnudeln_mit_Lachs___Senf_Dill_Sauce.paprikarecipes" + ) assert os.path.isfile(expected_file) is True if os.path.isfile(expected_file): os.unlink(expected_file) @@ -44,7 +55,7 @@ def test_get_cover(minimal): assert p.get_cover(image_list=list()) is None cover = p.get_cover(image_list=recipe.image_list) - assert cover.name == 'REZ_1837_Cover.jpg' + assert cover.name == "REZ_1837_Cover.jpg" with pytest.raises(ValueError): p.get_cover(image_list=None) @@ -63,31 +74,33 @@ def test_render(minimal): recipe = Recipe.parse_obj(minimal) r = ExportRenderer() json = r.render(template_name="paprika.jinja2.json", recipe=recipe) - assert json == ('{\n' - ' "uid":"",\n' - ' "name":"Minimal Recipe",\n' - ' "directions": "Alles parat?\\n",\n' - ' "servings":"2",\n' - ' "rating":0,\n' - ' "difficulty":"",\n' - ' "ingredients":"",\n' - ' "notes":"",\n' - ' "created":"",\n' - ' "image_url":null,\n' - ' "cook_time":"",\n' - ' "prep_time":"20",\n' - ' "source":"Kptncook",\n' - ' "source_url":"",\n' - ' "hash" : "",\n' - ' "photo_hash":null,\n' - ' "photos":[],\n' - ' "photo": "",\n' - ' "nutritional_info":"calories: 100\\nprotein: 30\\nfat: 10\\ncarbohydrate: ' - '20\\n",\n' - ' "photo_data":"",\n' - ' "photo_large":null,\n' - ' "categories":["Kptncook"]\n' - '}') + assert json == ( + "{\n" + ' "uid":"",\n' + ' "name":"Minimal Recipe",\n' + ' "directions": "Alles parat?\\n",\n' + ' "servings":"2",\n' + ' "rating":0,\n' + ' "difficulty":"",\n' + ' "ingredients":"",\n' + ' "notes":"",\n' + ' "created":"",\n' + ' "image_url":null,\n' + ' "cook_time":"",\n' + ' "prep_time":"20",\n' + ' "source":"Kptncook",\n' + ' "source_url":"",\n' + ' "hash" : "",\n' + ' "photo_hash":null,\n' + ' "photos":[],\n' + ' "photo": "",\n' + ' "nutritional_info":"calories: 100\\nprotein: 30\\nfat: 10\\ncarbohydrate: ' + '20\\n",\n' + ' "photo_data":"",\n' + ' "photo_large":null,\n' + ' "categories":["Kptncook"]\n' + "}" + ) # invalid with pytest.raises(TemplateNotFound): r.render(template_name="invalid_template.jinja2.json", recipe=recipe) From 92e1f7a2addfcd7135b34c59393431a7b878aa82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Wersd=C3=B6rfer?= Date: Wed, 15 Feb 2023 01:07:57 +0100 Subject: [PATCH 07/10] added type annotations (found some issues where cover.name could be missing if cover is None and None could not be unpacked in a tuple..) --- kptncook/paprika.py | 28 +++++++++++++++------------- tests/paprika_test.py | 4 ++-- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/kptncook/paprika.py b/kptncook/paprika.py index e4991c0..a495b72 100644 --- a/kptncook/paprika.py +++ b/kptncook/paprika.py @@ -23,11 +23,11 @@ from unidecode import unidecode from kptncook.config import settings -from kptncook.models import Recipe +from kptncook.models import Image, Recipe class PaprikaExporter: - def export(self, recipe: Recipe): + def export(self, recipe: Recipe) -> str: renderer = ExportRenderer() cover_filename, cover_img = self.get_cover_img_as_base64_string(recipe=recipe) @@ -51,18 +51,18 @@ def export(self, recipe: Recipe): return filename @staticmethod - def move_to_target_dir(source: str, target: str): - shutil.move(source, target) + def move_to_target_dir(source: str, target: str) -> str: + return shutil.move(source, target) @staticmethod - def asciify_string(s): + def asciify_string(s) -> str: s = unidecode(s) s = re.sub(r"[^\w\s]", "_", s) s = re.sub(r"\s+", "_", s) return s @staticmethod - def save_recipe(recipe_as_json: str, filename: str, directory: str): + def save_recipe(recipe_as_json: str, filename: str, directory: str) -> str: recipe_as_gz = os.path.join(directory, "arecipe.paprikarecipe") with gzip.open(recipe_as_gz, "wb") as f: f.write(recipe_as_json.encode("utf-8")) @@ -73,18 +73,20 @@ def save_recipe(recipe_as_json: str, filename: str, directory: str): zip_file.write(recipe_as_gz) return filename_full_path - def get_cover_img_as_base64_string(self, recipe: Recipe): + def get_cover_img_as_base64_string(self, recipe: Recipe) -> tuple[str, str]: cover = self.get_cover(image_list=recipe.image_list) + if cover is None: + raise ValueError("No cover image found") cover_url = recipe.get_image_url(api_key=settings.kptncook_api_key) if not isinstance(cover_url, str): - return None + raise ValueError("Cover URL must be a string") response = httpx.get(cover_url) - if response.status_code == 200: - return cover.name, base64.b64encode(response.content).decode("utf-8") + response.raise_for_status() + return cover.name, base64.b64encode(response.content).decode("utf-8") @staticmethod - def get_cover(image_list: list): + def get_cover(image_list: list[Image]) -> Image | None: if not isinstance(image_list, list): raise ValueError("Parameter image_list must be a list") try: @@ -101,13 +103,13 @@ def render(self, template_name: str, recipe: Recipe, **kwargs) -> str: return template.render(recipe=recipe, **kwargs) @staticmethod - def get_template_dir(): + def get_template_dir() -> str: module_path = os.path.abspath(__file__) real_path = os.path.realpath(module_path) root_dir = os.path.dirname(os.path.dirname(real_path)) return os.path.join(root_dir, "templates") - def get_environment(self): + def get_environment(self) -> Environment: return Environment( loader=FileSystemLoader(self.get_template_dir()), trim_blocks=True ) diff --git a/tests/paprika_test.py b/tests/paprika_test.py index 17c067f..6c4bbf2 100644 --- a/tests/paprika_test.py +++ b/tests/paprika_test.py @@ -29,8 +29,8 @@ def test_get_cover_img_as_base64_string(full_recipe, mocker): # no images available for some reason recipe.image_list = list() - cover_info = p.get_cover_img_as_base64_string(recipe=recipe) - assert cover_info is None + with pytest.raises(ValueError): + p.get_cover_img_as_base64_string(recipe=recipe) def test_export(full_recipe, mocker): From 18f5177712646f8f92055235f630f3233ca2a7eb Mon Sep 17 00:00:00 2001 From: Dorthe Luebbert Date: Sun, 26 Feb 2023 08:13:54 +0100 Subject: [PATCH 08/10] with kptncook export-recipes-to-paprika user export without further parameters all recipes to Paprika, when a recipe id is added as parameter one recipe is exported --- README.md | 2 +- kptncook/__init__.py | 33 ++++++++----- kptncook/paprika.py | 93 +++++++++++++++++++++++------------ templates/paprika.jinja2.json | 2 +- tests/paprika_test.py | 41 +++++++++++++-- 5 files changed, 121 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index b42d911..cc32940 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Commands: search-by-id Search for a recipe by id in kptncook api, id... sync Fetch recipes for today from api, save them to... sync-with-mealie Sync locally saced recipes with mealie. - export-recipe-to-paprika Export a recipe to Paprika app + export-recipes-to-paprika Export a recipe by id or all recipes to Paprika app ``` ## Environment diff --git a/kptncook/__init__.py b/kptncook/__init__.py index 56b531c..9480497 100644 --- a/kptncook/__init__.py +++ b/kptncook/__init__.py @@ -28,7 +28,7 @@ "get_kptncook_access_token", "list_recipes", "search_kptncook_recipe_by_id", - "export_recipe_to_paprika", + "export_recipes_to_paprika", ] __version__ = "0.0.10" @@ -203,13 +203,27 @@ def search_kptncook_recipe_by_id(_id: str): rprint(f"Added recipe {id_type} {id_value} to local repository") -@cli.command(name="export-recipe-to-paprika") -def export_recipe_to_paprika(_id: str): +@cli.command(name="export-recipes-to-paprika") +def export_recipes_to_paprika(_id: str | None = typer.Argument(None)): """ - Export a recipe to Paprika app + Export one recipe or all recipes to Paprika app - Example usage: kptncook export-recipe-to-paprika 635a68635100007500061cd7 + Example usage 1: kptncook export-recipes-to-paprika 635a68635100007500061cd7 + Example usage 2: kptncook export-recipes-to-paprika """ + if _id: + recipes = get_recipe_by_id(_id) + else: + recipes = get_kptncook_recipes_from_repository() + exporter = PaprikaExporter() + filename = exporter.export(recipes=recipes) + rprint( + "\n The data was exported to '%s'. Open the export file with the Paprika App.\n" + % filename + ) + + +def get_recipe_by_id(_id: str): parsed = parse_id(_id) if parsed is None: rprint("Could not parse id") @@ -223,14 +237,7 @@ def export_recipe_to_paprika(_id: str): if len(found_recipes) > 1: rprint("More than one recipe found with that ID.") sys.exit(1) - - exporter = PaprikaExporter() - recipe = found_recipes[0] - filename = exporter.export(recipe=recipe) - rprint( - "\n The recipe was exported to '%s'. Open this file with the Paprika App.\n" - % filename - ) + return found_recipes if __name__ == "__main__": diff --git a/kptncook/paprika.py b/kptncook/paprika.py index a495b72..9f23607 100644 --- a/kptncook/paprika.py +++ b/kptncook/paprika.py @@ -7,13 +7,13 @@ 3. zip this file as some_recipes.paprikarecipes (Plural!) """ import base64 +import glob import gzip import os import re import secrets import shutil import tempfile -import uuid import zipfile from datetime import datetime from pathlib import Path @@ -27,50 +27,70 @@ class PaprikaExporter: - def export(self, recipe: Recipe) -> str: - renderer = ExportRenderer() - cover_filename, cover_img = self.get_cover_img_as_base64_string(recipe=recipe) - - recipe_as_json = renderer.render( - template_name="paprika.jinja2.json", - recipe=recipe, - uid=str(uuid.uuid4()).upper(), - dtnow=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - cover_filename=cover_filename, - hash=secrets.token_hex(32), - cover_img=cover_img, - ) - filename = self.asciify_string(s=recipe.localized_title.de) + ".paprikarecipes" + def export(self, recipes: list[Recipe]) -> str: + export_data = self.get_export_data(recipes=recipes) + filename = self.get_export_filename(export_data=export_data, recipes=recipes) tmp_dir = tempfile.mkdtemp() - filename_full_path = self.save_recipe( - recipe_as_json=recipe_as_json, filename=filename, directory=tmp_dir + filename_full_path = self.save_recipes( + export_data=export_data, directory=tmp_dir, filename=filename ) self.move_to_target_dir( source=filename_full_path, target=os.path.join(str(Path.cwd()), filename) ) return filename - @staticmethod - def move_to_target_dir(source: str, target: str) -> str: + def get_export_filename( + self, export_data: dict[str, str], recipes: list[Recipe] + ) -> str: + if len(export_data) == 1: + return ( + self.asciify_string(s=recipes[0].localized_title.de) + ".paprikarecipes" + ) + else: + return "allrecipes.paprikarecipes" + + def get_export_data(self, recipes: list[Recipe]) -> dict[str, str]: + renderer = ExportRenderer() + export_data = dict() + for recipe in recipes: + cover_filename, cover_img = self.get_cover_img_as_base64_string( + recipe=recipe + ) + recipe_as_json = renderer.render( + template_name="paprika.jinja2.json", + recipe=recipe, + dtnow=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + cover_filename=cover_filename, + hash=secrets.token_hex(32), + cover_img=cover_img, + ) + export_data[str(recipe.id.oid)] = recipe_as_json + return export_data + + def move_to_target_dir(self, source: str, target: str) -> str: return shutil.move(source, target) - @staticmethod - def asciify_string(s) -> str: + def asciify_string(self, s) -> str: s = unidecode(s) s = re.sub(r"[^\w\s]", "_", s) s = re.sub(r"\s+", "_", s) return s - @staticmethod - def save_recipe(recipe_as_json: str, filename: str, directory: str) -> str: - recipe_as_gz = os.path.join(directory, "arecipe.paprikarecipe") - with gzip.open(recipe_as_gz, "wb") as f: - f.write(recipe_as_json.encode("utf-8")) + def save_recipes( + self, export_data: dict[str], filename: str, directory: str + ) -> str: + for id, recipe_as_json in export_data.items(): + recipe_as_gz = os.path.join(directory, "recipe_" + id + ".paprikarecipe") + with gzip.open(recipe_as_gz, "wb") as f: + f.write(recipe_as_json.encode("utf-8")) filename_full_path = os.path.join(directory, filename) with zipfile.ZipFile( filename_full_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True ) as zip_file: - zip_file.write(recipe_as_gz) + gz_files = glob.glob(os.path.join(directory, "*.paprikarecipe")) + print(gz_files) + for gz_file in gz_files: + zip_file.write(gz_file, arcname=os.path.basename(gz_file)) return filename_full_path def get_cover_img_as_base64_string(self, recipe: Recipe) -> tuple[str, str]: @@ -80,13 +100,22 @@ def get_cover_img_as_base64_string(self, recipe: Recipe) -> tuple[str, str]: cover_url = recipe.get_image_url(api_key=settings.kptncook_api_key) if not isinstance(cover_url, str): raise ValueError("Cover URL must be a string") - - response = httpx.get(cover_url) - response.raise_for_status() + try: + response = httpx.get(cover_url) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + print( + f'Cover image for "{recipe.localized_title.de}" not found online any more.' + ) + else: + print( + f"While trying to fetch the cover img a HTTP error occured: {exc.response.status_code}: {exc}" + ) + return None, None return cover.name, base64.b64encode(response.content).decode("utf-8") - @staticmethod - def get_cover(image_list: list[Image]) -> Image | None: + def get_cover(self, image_list: list[Image]) -> Image | None: if not isinstance(image_list, list): raise ValueError("Parameter image_list must be a list") try: diff --git a/templates/paprika.jinja2.json b/templates/paprika.jinja2.json index 9720dab..8f2b431 100644 --- a/templates/paprika.jinja2.json +++ b/templates/paprika.jinja2.json @@ -1,5 +1,5 @@ { - "uid":"{{uid}}", + "uid":"{{recipe.id.oid}}", "name":"{{recipe.localized_title.de}}", "directions": "{% for step in recipe.steps %}{{step.title.de}}\n{% endfor %}", "servings":"2", diff --git a/tests/paprika_test.py b/tests/paprika_test.py index 6c4bbf2..45b8809 100644 --- a/tests/paprika_test.py +++ b/tests/paprika_test.py @@ -1,5 +1,6 @@ import os +import httpx import pytest from jinja2.exceptions import TemplateNotFound @@ -33,14 +34,33 @@ def test_get_cover_img_as_base64_string(full_recipe, mocker): p.get_cover_img_as_base64_string(recipe=recipe) -def test_export(full_recipe, mocker): +def test_get_cover_img_as_base64_string_can_handle_404(full_recipe, mocker): + p = PaprikaExporter() + recipe = Recipe.parse_obj(full_recipe) + mocker.patch( + "kptncook.paprika.httpx.get", + return_value=mocker.Mock(content=b"foobar", status_code=404), + ) + # hm, looks weird, but works. + m = mocker.patch("kptncook.paprika.httpx.get") + mock_response = mocker.Mock() + mock_response.raise_for_status = mocker.Mock( + side_effect=httpx.HTTPStatusError( + message="404 File not found", response=mock_response, request=mocker.Mock() + ) + ) + m.return_value = mock_response + assert p.get_cover_img_as_base64_string(recipe=recipe) == (None, None) + + +def test_export_single_recipe(full_recipe, mocker): p = PaprikaExporter() recipe = Recipe.parse_obj(full_recipe) mocker.patch( "kptncook.paprika.httpx.get", return_value=mocker.Mock(content=b"foobar", status_code=200), ) - p.export(recipe=recipe) + p.export(recipes=[recipe]) expected_file = ( "Uberbackene_Muschelnudeln_mit_Lachs___Senf_Dill_Sauce.paprikarecipes" ) @@ -49,6 +69,21 @@ def test_export(full_recipe, mocker): os.unlink(expected_file) +def test_export_all_recipes(full_recipe, minimal, mocker): + p = PaprikaExporter() + recipe1 = Recipe.parse_obj(full_recipe) + recipe2 = Recipe.parse_obj(minimal) + mocker.patch( + "kptncook.paprika.httpx.get", + return_value=mocker.Mock(content=b"foobar", status_code=200), + ) + p.export(recipes=[recipe1, recipe2]) + expected_file = "allrecipes.paprikarecipes" + assert os.path.isfile(expected_file) is True + if os.path.isfile(expected_file): + os.unlink(expected_file) + + def test_get_cover(minimal): p = PaprikaExporter() recipe = Recipe.parse_obj(minimal) @@ -76,7 +111,7 @@ def test_render(minimal): json = r.render(template_name="paprika.jinja2.json", recipe=recipe) assert json == ( "{\n" - ' "uid":"",\n' + ' "uid":"5e5390e2740000cdf1381c64",\n' ' "name":"Minimal Recipe",\n' ' "directions": "Alles parat?\\n",\n' ' "servings":"2",\n' From cb9f79bc955441eafa5999ce61cff154e57e2945 Mon Sep 17 00:00:00 2001 From: Dorthe Luebbert Date: Sun, 26 Feb 2023 09:11:54 +0100 Subject: [PATCH 09/10] hint on git pre-commit hooks added --- README.md | 6 ++++++ pyproject.toml | 1 + 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index cc32940..8320194 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ Install a symlinked development version of the package: ``` $ flit install -s ``` + +Install the git pre-commit hooks: +``` +$ pre-commit install +``` + ## Run Tests Flit should have already installed pytest: diff --git a/pyproject.toml b/pyproject.toml index c80ba49..5f1bab9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ doc = [ dev = [ "jupyterlab >= 3.2.9", "mypy", + "pre-commit" ] [project.urls] From c030a03ee7c330276b08446d01c902683daec69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20Wersd=C3=B6rfer?= Date: Sun, 26 Feb 2023 09:42:15 +0100 Subject: [PATCH 10/10] typer only supports optional arguments using Optional from typing - trying to exclude from pyupgrade by adding it as a standalone type --- kptncook/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kptncook/__init__.py b/kptncook/__init__.py index 9480497..6d13023 100644 --- a/kptncook/__init__.py +++ b/kptncook/__init__.py @@ -5,6 +5,7 @@ import sys from datetime import date +from typing import Optional import httpx import typer @@ -203,8 +204,12 @@ def search_kptncook_recipe_by_id(_id: str): rprint(f"Added recipe {id_type} {id_value} to local repository") +# Optional needed by typer, standalone to trick pyupgrade to not change it +OptionalId = Optional[str] + + @cli.command(name="export-recipes-to-paprika") -def export_recipes_to_paprika(_id: str | None = typer.Argument(None)): +def export_recipes_to_paprika(_id: OptionalId = typer.Argument(None)): """ Export one recipe or all recipes to Paprika app