From 9790ad7e82f304cfdb7ac84e9f5b6b98282d6a98 Mon Sep 17 00:00:00 2001 From: Michael Duffett Date: Sun, 28 Apr 2024 00:20:06 +0930 Subject: [PATCH 1/3] Ploetzblog and test configurability --- README.rst | 1 + recipe_scrapers/__init__.py | 2 + recipe_scrapers/ploetzblog.py | 142 + tests/__init__.py | 45 +- tests/test_data/ploetzblog.de/ploetzblog.json | 45 + .../ploetzblog.de/ploetzblog.testhtml | 2999 +++++++++++++++++ 6 files changed, 3227 insertions(+), 7 deletions(-) create mode 100644 recipe_scrapers/ploetzblog.py create mode 100644 tests/test_data/ploetzblog.de/ploetzblog.json create mode 100644 tests/test_data/ploetzblog.de/ploetzblog.testhtml diff --git a/README.rst b/README.rst index aab825ebe..862a1420e 100644 --- a/README.rst +++ b/README.rst @@ -300,6 +300,7 @@ Scrapers available for: - `https://www.pingodoce.pt/ `_ - `https://pinkowlkitchen.com/ `_ - `https://www.platingpixels.com/ `_ +- `https://www.ploetzblog.de/ `_ - `https://plowingthroughlife.com/ `_ - `https://popsugar.com/ `_ - `https://practicalselfreliance.com/ `_ diff --git a/recipe_scrapers/__init__.py b/recipe_scrapers/__init__.py index e0f0a0c31..e078c1674 100644 --- a/recipe_scrapers/__init__.py +++ b/recipe_scrapers/__init__.py @@ -209,6 +209,7 @@ from .pingodoce import PingoDoce from .pinkowlkitchen import PinkOwlKitchen from .platingpixels import PlatingPixels +from .ploetzblog import Ploetzblog from .plowingthroughlife import PlowingThroughLife from .popsugar import PopSugar from .practicalselfreliance import PracticalSelfReliance @@ -388,6 +389,7 @@ MyJewishLearning.host(): MyJewishLearning, NutritionFacts.host(): NutritionFacts, PinchOfYum.host(): PinchOfYum, + Ploetzblog.host(): Ploetzblog, Recept.host(): Recept, RicettePerBimby.host(): RicettePerBimby, StrongrFastr.host(): StrongrFastr, diff --git a/recipe_scrapers/ploetzblog.py b/recipe_scrapers/ploetzblog.py new file mode 100644 index 000000000..7d744ed15 --- /dev/null +++ b/recipe_scrapers/ploetzblog.py @@ -0,0 +1,142 @@ +# mypy: allow-untyped-defs + +import re +from typing import List + +from ._abstract import AbstractScraper +from ._grouping_utils import IngredientGroup +from ._utils import normalize_string + + +class Ploetzblog(AbstractScraper): + @classmethod + def host(cls): + return "ploetzblog.de" + + def author(self): + return self._get_script_string_field("authorName") + + def title(self): + return self.soup.find("h1").text + + def category(self): + return self.schema.category() + + def total_time(self): + # Could also be scraped manually from the page text + # Issue is that the time units are in German, which get_minutes does not work for + return self._get_script_number_field("preparationTime") + + def yields(self): + count_input = self.soup.find("input", {"id": "recipePieceCount"}) + count = count_input.get("value") + + unit_td = count_input.parent.find_next_sibling("td") + unit = normalize_string(unit_td.text) + + return f"{count} {unit}" + + def image(self): + return self.schema.image() + + def ingredients(self): + ingredients_div = self.soup.find( + "div", {"class": "we2p-pb-recipe__ingredients"} + ) + ingredients_table = ingredients_div.find_all("table")[1] + return self._get_ingredients_from_table(ingredients_table) + + def ingredient_groups(self) -> List[IngredientGroup]: + ingredient_groups = [] + + group_divs = self.soup.find_all( + "div", {"class": "module-mb-4 vg-wort-text module-break-inside-avoid"} + ) + for group_div in group_divs: + h4 = group_div.find("h4") + purpose = normalize_string(h4.text) + + ingredients_table = group_div.find("table") + ingredients = self._get_ingredients_from_table(ingredients_table) + + ingredient_groups.append(IngredientGroup(ingredients, purpose=purpose)) + + return ingredient_groups + + def instructions(self): + instruction_ps = self.soup.find_all( + "p", {"class": "module-float-left module-my-auto we2p-autolinker"} + ) + instructions = [ + normalize_string(instruction.text) for instruction in instruction_ps + ] + return "\n".join(instructions[:2]) + + def ratings(self): + return self.schema.ratings() + + def cuisine(self): + return self.schema.cuisine() + + def description(self): + description_div = self.soup.find( + "div", {"class": "we2p-pb-recipe__description"} + ) + + lines = [] + for p in description_div.find_all("p"): + lines.append(normalize_string(p.text)) + + return "\n".join(lines) + + def site_name(self): + return "Plötzblog" + + def _get_ingredients_from_table(self, ingredients_table): + ingredients = [] + + tr_list = ingredients_table.find_all("tr") + for tr in tr_list: + line = [] + td_list = tr.find_all("td", limit=2) + for td in td_list: + span_list = td.find_all("span") + for span in span_list: + text = normalize_string(span.text) + if text: + line.append(text) + ingredients.append(" ".join(line)) + + return ingredients + + def _get_script(self): + main = self.soup.find("main", {"id": "main-content"}) + script = main.find( + "script", string=re.compile(r'"types":\["ForumPost","Recipe"\]') + ) + return script + + def _get_field_name_pattern(self, field_name): + return f'\\"{field_name}\\"\\s*:\\s*' + + def _get_script_string_field(self, field_name): + script = self._get_script() + + result = re.search( + self._get_field_name_pattern(field_name) + '\\"([^"]+)', script.string + ) + if not result: + return None + + return result.group(1) + + def _get_script_number_field(self, field_name): + script = self._get_script() + + result = re.search( + self._get_field_name_pattern(field_name) + "([^,]+)", script.string + ) + if not result: + return None + + return int(result.group(1)) diff --git a/tests/__init__.py b/tests/__init__.py index 1893c30c0..af7aed625 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,7 @@ import json import pathlib import unittest +from enum import Enum from typing import Callable from recipe_scrapers import scrape_html @@ -35,6 +36,33 @@ "equipment", ] +OPTIONS_KEY = "_options" + + +class TestOptions(Enum): + CONSISTENT_INGREDIENT_GROUPS = ("consistent_ingredient_groups", True) + """ + Controls if the consistent ingredient groups test is run. + Disable if ingredient groups contain sub-quantities of the same ingredient (as the test will fail). + """ + + def __new__(cls, value: str, default): + obj = object.__new__(cls) + obj._value_ = value + return obj + + def __init__(self, value: str, default: str) -> None: + self.default = default + + +def get_options(expect): + options = {} + for option in TestOptions: + # Checks if the option has been set in the test + # Tolerates both the options node and the specific option not being defined + options[option] = expect.get(OPTIONS_KEY, {}).get(option.value, option.default) + return options + class RecipeTestCase(unittest.TestCase): maxDiff = None @@ -80,6 +108,8 @@ def test_func(self): ] actual = scrape_html(testhtml.read_text(encoding="utf-8"), host) + options = get_options(expect) + # Mandatory tests # If the key isn't present, check an assertion is raised for key in MANDATORY_TESTS: @@ -110,14 +140,15 @@ def test_func(self): msg=f"The actual value for .{key}() did not match the expected value.", ) - # Assert that the ingredients returned by the ingredient_groups() function - # are the same as the ingredients return by the ingredients() function. - grouped = [] - for group in actual.ingredient_groups(): - grouped.extend(group.ingredients) + if options.get(TestOptions.CONSISTENT_INGREDIENT_GROUPS): + # Assert that the ingredients returned by the ingredient_groups() function + # are the same as the ingredients return by the ingredients() function. + grouped = [] + for group in actual.ingredient_groups(): + grouped.extend(group.ingredients) - with self.subTest("ingredient_groups"): - self.assertEqual(sorted(actual.ingredients()), sorted(grouped)) + with self.subTest("ingredient_groups"): + self.assertEqual(sorted(actual.ingredients()), sorted(grouped)) return test_func diff --git a/tests/test_data/ploetzblog.de/ploetzblog.json b/tests/test_data/ploetzblog.de/ploetzblog.json new file mode 100644 index 000000000..d11c9abfd --- /dev/null +++ b/tests/test_data/ploetzblog.de/ploetzblog.json @@ -0,0 +1,45 @@ +{ + "author": "Lutz Gei\\u00dfler", + "canonical_url": "ploetzblog.de", + "host": "ploetzblog.de", + "description": "Mein bislang bestes Weizensauerteigbrot, ganz ohne Backhefe.\nGrobe bis mittlere, unregelmäßige Porung, wattige Krume und kaum spürbare, milde Säure. Der Teigling bekommt eine lange kalte Stückgare und entwickelt auch deshalb seinen wilden Trieb im Gusseisentopf.\nFür etwas mehr Charakter kann der Sauerteig mit Vollkornmehl angesetzt werden.\nWichtig ist das triebstarke und aktive Anstellgut, das 2 – 3 Mal vor dem Ansetzen des Sauerteiges bei 27 – 28 °C aufgefrischt werden sollte.\nHinweis: Wahlweise kann das Brot auch im auf 250 °C aufgeheizten Gusseisentopf 50 Minuten fallend auf 220 °C gebacken werden. Dann den Deckel nach 40 Minuten abnehmen.", + "image": "https://webimages.we2p.de/2/ploetzblog/entity/gallery/619f68b528ae7154616ab768/Mildes_Weizensauerteigbrot_20160506.jpg", + "ingredients": [ + "558 g Weizenmehl 550", + "389 g Wasser", + "90 g Weizenanstellgut TA 200 (weich)", + "13 g Salz" + ], + "ingredient_groups": [ + { + "ingredients": [ + "90 g Wasser", + "90 g Weizenmehl 550", + "90 g Weizenanstellgut TA 200 (weich)" + ], + "purpose": "Weizensauerteig" + }, + { + "ingredients": [ + "13 g Salz", + "298 g Wasser", + "467 g Weizenmehl 550", + "gesamter Weizensauerteig" + ], + "purpose": "Hauptteig" + } + ], + "instructions": "Die Zutaten in der genannten Reihenfolge in eine Schüssel wiegen.\nMischen, bis sich die Zutaten zu einem weichen Teig verbunden haben (gewünschte Teigtemperatur: ca. 28 °C).", + "instructions_list": [ + "Die Zutaten in der genannten Reihenfolge in eine Schüssel wiegen.", + "Mischen, bis sich die Zutaten zu einem weichen Teig verbunden haben (gewünschte Teigtemperatur: ca. 28 °C)." + ], + "language": "de", + "site_name": "Plötzblog", + "title": "Mildes Weizensauerteigbrot", + "total_time": 982, + "yields": "1 Stück zu (je) ca. 1050 g", + "_options": { + "consistent_ingredient_groups": false + } +} diff --git a/tests/test_data/ploetzblog.de/ploetzblog.testhtml b/tests/test_data/ploetzblog.de/ploetzblog.testhtml new file mode 100644 index 000000000..345e17495 --- /dev/null +++ b/tests/test_data/ploetzblog.de/ploetzblog.testhtml @@ -0,0 +1,2999 @@ + + + + + + + + + +Mildes Weizensauerteigbrot: Plötzblog + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + +
+ + + + + + + +
+ +
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+ + + + +
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+ + +
+ + +
+

Mildes Weizensauerteigbrot

+

Weizensauerteigbrot

+
+
+ + +
+ + +
+ Ein kräftig ausgebackenes Weizensauerteigbrot mit eingeschnittenem „A" auf der Oberfläche liegt auf einem Holztisch. +
+
+ + + +
+ + +
+ + +

Mein bislang bestes Weizensauerteigbrot, ganz ohne Backhefe. 

+ + +

Grobe bis mittlere, unregelmäßige Porung, wattige Krume und kaum spürbare, milde Säure. Der Teigling bekommt eine lange kalte Stückgare und entwickelt auch deshalb seinen wilden Trieb im Gusseisentopf. 

Für etwas mehr Charakter kann der Sauerteig mit Vollkornmehl angesetzt werden. 

Wichtig ist das triebstarke und aktive Anstellgut, das 2 – 3 Mal vor dem Ansetzen des Sauerteiges bei 27 – 28 °C aufgefrischt werden sollte.

Hinweis: Wahlweise kann das Brot auch im auf 250 °C aufgeheizten Gusseisentopf 50 Minuten fallend auf 220 °C gebacken werden. Dann den Deckel nach 40 Minuten abnehmen. 

+ + + +
+ + +
+
+ + + 575 + Kommentare + + + + +
+ +
+
+
+ 15. April 2017 +
+
+ + +
+ +
+ + + +
+ +
+ + + + +
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+ +
+ +

Dieses Zubehör habe ich für das Rezept verwendet:

+ +

+ + Zubehör +

+ + + + + + + + + + +
BezeichnungMengeEinheit
+
+
+ +
+ +
+ +
+ +
+
+ + +
+
+ + + + +
+ +
+ +
+
+ + + +
+ + +
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+ +
+ +
+ + + + +
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+ + +
+ + +
+ +

+ + Zutatenübersicht +

+ + + + + + + + +
für + + Stück zu (je) ca. 1050 g174 %
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 558 g + + + Weizenmehl 550 + + + + + 92,5 % +
+ 389 g + + + Wasser + + + + + 64,5 % +
+ 90 g + + + Weizenanstellgut TA 200 (weich) + + + + + 15 % +
+ 13 g + + + Salz + + + + + 2,2 % +
+ +
+ + +
+ +

+ + Planungsbeispiel +

+

+ Gesamtzubereitungszeit:  + 16 Stunden 22 Minuten +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Tag 1 + 16:38 UhrWeizensauerteig herstellen
+ 18:43 UhrHauptteig herstellen
+ 19:32 UhrDehnen und Falten
+ 20:02 UhrDehnen und Falten
+ 20:32 UhrDehnen und Falten
+ 21:02 UhrDehnen und Falten
+ 22:03 UhrFormen
+ Tag 2 + 07:10 UhrOfen vorheizen auf 250 °C
+ 08:09 UhrSchneiden
+ 08:10 UhrBacken
09:00 Uhrca. fertig gebacken
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + + + + +
+ +

+ + Weizensauerteig +

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 90 g + + + Wasser + + + + + + + 40 °C + + 15 % +
+ 90 g + + + Weizenmehl 550 + + + + + + + 20 °C + + 15 % +
+ 90 g + + + Weizenanstellgut TA 200 (weich) + + + + + + + 28 °C + + 15 % +
+
+ + +
+
+
+
0
+
+

+ Die Zutaten in der genannten Reihenfolge in eine Schüssel wiegen. +

+
+
+
+
+
1
+
+

+ Mischen, bis sich die Zutaten zu einem weichen Teig verbunden haben (gewünschte Teigtemperatur: ca. 28 °C). +

+
+
+
+
+
1
+
+

+ Mit einer Abdeckhaube, einem Deckel oder etwas ähnlichem zudecken. +

+
+
+
+
+
2
+
+

+ 2 Stunden bei 27-28 °C reifen lassen. +

+
+
+
+ +
+ +
+ + +
+ +

+ + Hauptteig +

+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 13 g + + + Salz + + + + + + + 20 °C + + 2,2 % +
+ 298 g + + + Wasser + + + + + + + 25 °C + + 49,5 % +
+ 467 g + + + Weizenmehl 550 + + + + + + + 20 °C + + 77,5 % +
+ + gesamter Weizensauerteig + + + + + + + 28 °C +
+
+ + +
+
+
+
1
+
+

+ Die Zutaten in der genannten Reihenfolge in die Schüssel wiegen. +

+
+
+
+
+
2
+
+

+ Zu einem Teig vermischen. +

+
+
+
+
+
3
+
+

+ Kneten, bis sich eine dünne Teighaut ausziehen lässt (Fenstertest) (gewünschte Teigtemperatur: ca. 27 °C). +

+
+
+
+
+
3
+
+

+ Mit einer Abdeckhaube, einem Deckel oder etwas ähnlichem zudecken. +

+
+
+
+
+
4
+
+

+ 3 Stunden bei 27-28 °C reifen lassen. +Dabei nach 30, 60, 90 und 120 Minuten dehnen und falten. +

+
+
+
+
+
4
+
+

+ 30 Minuten bei 27-28 °C reifen lassen. +

+
+
+
+
+
4
+
+

+ Den Teig dehnen und falten.  +

+
+
+
+
+
4
+
+

+ 30 Minuten bei 27-28 °C reifen lassen. +

+
+
+
+
+
4
+
+

+ Den Teig dehnen und falten.  +

+
+
+
+
+
4
+
+

+ 30 Minuten bei 27-28 °C reifen lassen. +

+
+
+
+
+
4
+
+

+ Den Teig dehnen und falten.  +

+
+
+
+
+
4
+
+

+ 30 Minuten bei 27-28 °C reifen lassen. +

+
+
+
+
+
4
+
+

+ Den Teig dehnen und falten.  +

+
+
+
+
+
4
+
+

+ 1 Stunde bei 27-28 °C auf knapp das doppelte Volumen reifen lassen. +

+
+
+
+
+
4
+
+

+ Den Teig aus der Schüssel oder Wanne auf die leicht bemehlte Arbeitsfläche geben. +

+
+
+
+
+
5
+
+

+ Den Teigling schonend, aber straff rund einschlagen. +

+
+
+
+
+
6
+
+

+ 8-12 Stunden bei 5 °C mit Schluss nach oben im leicht bemehlten Gärkorb zugedeckt reifen lassen. +

+
+
+
+
+
7
+
+

+ Den Teigling aus dem Gärkorb auf Backpapier stürzen (Schluss nach unten). +

+
+
+
+
+
8
+
+

+ Die Rasierklinge im 45°-Winkel zur Teiglingsoberfläche halten und diese nach Belieben einschneiden. +

+
+
+
+
+
9
+
+

+ Das Backpapier mit dem Teigling mithilfe eines flachen Bleches oder Brettes in den auf 250 °C vorgeheizten Ofen auf den Backstein befördern. +Sofort kräftig bedampfen. +Den Ofen sofort auf 220 °C herunterdrehen. +Den Dampf nach 20 Minuten ablassen. +Insgesamt 50 Minuten ausbacken. +

+
+
+
+ +
+ +
+ + + +
+ + + + + + + + +
+ + +
+ + + 575 + Kommentare + + + +
+ + + +
+ +
+ +
+ +
+ + + + + +
+ +
+
+
+

+ © 2009-2024 · ploetzblog.de von Lutz Geißler. Nutzung nur für private, nichtkommerzielle und nichtöffentliche Zwecke. Jede öffentliche und jede kommerzielle Nutzung (z. B. Bücher, Medienbeiträge, Social Media inkl. YouTube, TikTok & Co.), auch in Auszügen, muss zwingend mit dem Rechteinhaber abgestimmt werden. +

+

Dieses Dokument ist ein Druck der folgenden Internetseite: https://www.ploetzblog.de/rezepte/mildes-weizensauerteigbrot/id=619f68b528ae7154616ab768

+

Abgerufen am: 24. April 2024, 7:17 Uhr · © 2023, Lutz Geißler

+
+
+
+ + +
+ + + +
+ + + +
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + \ No newline at end of file From 7241ddff85407d31aa18183ec466cc8da2238bae Mon Sep 17 00:00:00 2001 From: Michael Duffett Date: Sat, 4 May 2024 22:46:35 +0930 Subject: [PATCH 2/3] Refactor tests to allow for custom data driven tests --- tests/__init__.py | 126 +++--------------- tests/data_driven/__init__.py | 0 .../test_data/ploetzblog.de/ploetzblog.json | 0 .../ploetzblog.de/ploetzblog.testhtml | 0 tests/data_driven/test_ploetzblog.py | 18 +++ tests/data_utils.py | 78 +++++++++++ 6 files changed, 113 insertions(+), 109 deletions(-) create mode 100644 tests/data_driven/__init__.py rename tests/{ => data_driven}/test_data/ploetzblog.de/ploetzblog.json (100%) rename tests/{ => data_driven}/test_data/ploetzblog.de/ploetzblog.testhtml (100%) create mode 100644 tests/data_driven/test_ploetzblog.py create mode 100644 tests/data_utils.py diff --git a/tests/__init__.py b/tests/__init__.py index af7aed625..bc158ee70 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,67 +1,8 @@ -import json import pathlib import unittest -from enum import Enum from typing import Callable -from recipe_scrapers import scrape_html -from recipe_scrapers._grouping_utils import IngredientGroup - -MANDATORY_TESTS = [ - "author", - "canonical_url", - "host", - "description", - "image", - "ingredients", - "ingredient_groups", - "instructions", - "instructions_list", - "language", - "site_name", - "title", - "total_time", - "yields", -] - -OPTIONAL_TESTS = [ - "category", - "cook_time", - "cuisine", - "nutrients", - "prep_time", - "cooking_method", - "ratings", - "reviews", - "equipment", -] - -OPTIONS_KEY = "_options" - - -class TestOptions(Enum): - CONSISTENT_INGREDIENT_GROUPS = ("consistent_ingredient_groups", True) - """ - Controls if the consistent ingredient groups test is run. - Disable if ingredient groups contain sub-quantities of the same ingredient (as the test will fail). - """ - - def __new__(cls, value: str, default): - obj = object.__new__(cls) - obj._value_ = value - return obj - - def __init__(self, value: str, default: str) -> None: - self.default = default - - -def get_options(expect): - options = {} - for option in TestOptions: - # Checks if the option has been set in the test - # Tolerates both the options node and the specific option not being defined - options[option] = expect.get(OPTIONS_KEY, {}).get(option.value, option.default) - return options +from .data_utils import load_test, run_mandatory_tests, run_optional_test class RecipeTestCase(unittest.TestCase): @@ -100,55 +41,19 @@ def test_func_factory( """ def test_func(self): - with open(testjson, encoding="utf-8") as f: - expect = json.load(f) - expect["ingredient_groups"] = [ - IngredientGroup(**group) - for group in expect.get("ingredient_groups", []) - ] - actual = scrape_html(testhtml.read_text(encoding="utf-8"), host) - - options = get_options(expect) - - # Mandatory tests - # If the key isn't present, check an assertion is raised - for key in MANDATORY_TESTS: - with self.subTest(key): - scraper_func = getattr(actual, key) - if key in expect.keys(): - self.assertEqual( - expect[key], - scraper_func(), - msg=f"The actual value for .{key}() did not match the expected value.", - ) - else: - with self.assertRaises( - Exception, - msg=f".{key}() was expected to raise an exception but it did not.", - ): - scraper_func() - - # Optional tests - # If the key isn't present, skip - for key in OPTIONAL_TESTS: - with self.subTest(key): - scraper_func = getattr(actual, key) - if key in expect.keys(): - self.assertEqual( - expect[key], - scraper_func(), - msg=f"The actual value for .{key}() did not match the expected value.", - ) - - if options.get(TestOptions.CONSISTENT_INGREDIENT_GROUPS): - # Assert that the ingredients returned by the ingredient_groups() function - # are the same as the ingredients return by the ingredients() function. - grouped = [] - for group in actual.ingredient_groups(): - grouped.extend(group.ingredients) - - with self.subTest("ingredient_groups"): - self.assertEqual(sorted(actual.ingredients()), sorted(grouped)) + expect, actual = load_test(host, testhtml, testjson) + + run_mandatory_tests(self, expect, actual) + run_optional_test(self, expect, actual) + + # Assert that the ingredients returned by the ingredient_groups() function + # are the same as the ingredients return by the ingredients() function. + grouped = [] + for group in actual.ingredient_groups(): + grouped.extend(group.ingredients) + + with self.subTest("ingredient_groups"): + self.assertEqual(sorted(actual.ingredients()), sorted(grouped)) return test_func @@ -207,6 +112,9 @@ def load_tests( tests = loader.loadTestsFromTestCase(RecipeTestCase) suite.addTest(tests) + data_driven_tests = loader.discover("tests/data_driven") + suite.addTests(data_driven_tests) + # Add library tests to test suite library_tests = loader.discover("tests/library") suite.addTests(library_tests) diff --git a/tests/data_driven/__init__.py b/tests/data_driven/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_data/ploetzblog.de/ploetzblog.json b/tests/data_driven/test_data/ploetzblog.de/ploetzblog.json similarity index 100% rename from tests/test_data/ploetzblog.de/ploetzblog.json rename to tests/data_driven/test_data/ploetzblog.de/ploetzblog.json diff --git a/tests/test_data/ploetzblog.de/ploetzblog.testhtml b/tests/data_driven/test_data/ploetzblog.de/ploetzblog.testhtml similarity index 100% rename from tests/test_data/ploetzblog.de/ploetzblog.testhtml rename to tests/data_driven/test_data/ploetzblog.de/ploetzblog.testhtml diff --git a/tests/data_driven/test_ploetzblog.py b/tests/data_driven/test_ploetzblog.py new file mode 100644 index 000000000..e488d6b81 --- /dev/null +++ b/tests/data_driven/test_ploetzblog.py @@ -0,0 +1,18 @@ +import pathlib +import unittest + +from ..data_utils import load_test, run_mandatory_tests, run_optional_test + + +class PloetzblogTest(unittest.TestCase): + + def test_ploetzblog(self): + testhtml = pathlib.Path( + "tests/data_driven/test_data/ploetzblog.de/ploetzblog.testhtml" + ) + testjson = testhtml.with_suffix(".json") + + expect, actual = load_test("ploetzblog.de", testhtml, testjson) + + run_mandatory_tests(self, expect, actual) + run_optional_test(self, expect, actual) diff --git a/tests/data_utils.py b/tests/data_utils.py new file mode 100644 index 000000000..f011bcf78 --- /dev/null +++ b/tests/data_utils.py @@ -0,0 +1,78 @@ +import json +import pathlib + +from recipe_scrapers import scrape_html +from recipe_scrapers._grouping_utils import IngredientGroup + +MANDATORY_TESTS = [ + "author", + "canonical_url", + "host", + "description", + "image", + "ingredients", + "ingredient_groups", + "instructions", + "instructions_list", + "language", + "site_name", + "title", + "total_time", + "yields", +] + +OPTIONAL_TESTS = [ + "category", + "cook_time", + "cuisine", + "nutrients", + "prep_time", + "cooking_method", + "ratings", + "reviews", + "equipment", +] + + +def load_test(host: str, testhtml: pathlib.Path, testjson: pathlib.Path): + with open(testjson, encoding="utf-8") as f: + expect = json.load(f) + expect["ingredient_groups"] = [ + IngredientGroup(**group) for group in expect.get("ingredient_groups", []) + ] + actual = scrape_html(testhtml.read_text(encoding="utf-8"), host) + return expect, actual + + +def run_mandatory_tests(self, expect, actual, tests=MANDATORY_TESTS): + # Mandatory tests + # If the key isn't present, check an assertion is raised + for key in tests: + with self.subTest(key): + scraper_func = getattr(actual, key) + if key in expect.keys(): + self.assertEqual( + expect[key], + scraper_func(), + msg=f"The actual value for .{key}() did not match the expected value.", + ) + else: + with self.assertRaises( + Exception, + msg=f".{key}() was expected to raise an exception but it did not.", + ): + scraper_func() + + +def run_optional_test(self, expect, actual, tests=OPTIONAL_TESTS): + # Optional tests + # If the key isn't present, skip + for key in tests: + with self.subTest(key): + scraper_func = getattr(actual, key) + if key in expect.keys(): + self.assertEqual( + expect[key], + scraper_func(), + msg=f"The actual value for .{key}() did not match the expected value.", + ) From 148f37a6c824833eec0dec97480fd98973123f31 Mon Sep 17 00:00:00 2001 From: Michael Duffett Date: Sun, 5 May 2024 10:53:11 +0930 Subject: [PATCH 3/3] Remove options --- tests/data_driven/test_data/ploetzblog.de/ploetzblog.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/data_driven/test_data/ploetzblog.de/ploetzblog.json b/tests/data_driven/test_data/ploetzblog.de/ploetzblog.json index d11c9abfd..aec38f453 100644 --- a/tests/data_driven/test_data/ploetzblog.de/ploetzblog.json +++ b/tests/data_driven/test_data/ploetzblog.de/ploetzblog.json @@ -38,8 +38,5 @@ "site_name": "Plötzblog", "title": "Mildes Weizensauerteigbrot", "total_time": 982, - "yields": "1 Stück zu (je) ca. 1050 g", - "_options": { - "consistent_ingredient_groups": false - } + "yields": "1 Stück zu (je) ca. 1050 g" }