├── paprika_recipes ├── __init__.py ├── commands │ ├── __init__.py │ ├── create_recipe.py │ ├── store_password.py │ ├── create_archive.py │ ├── download_recipes.py │ ├── extract_archive.py │ ├── upload_recipes.py │ └── edit_recipe.py ├── constants.py ├── exceptions.py ├── types.py ├── cmdline.py ├── archive.py ├── recipe.py ├── cache.py ├── utils.py ├── remote.py └── command.py ├── setup.py ├── .gitignore ├── requirements.txt ├── .github └── workflows │ └── lint.yml ├── .pre-commit-config.yaml ├── ChangeLog ├── setup.cfg └── readme.md /paprika_recipes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /paprika_recipes/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /paprika_recipes/constants.py: -------------------------------------------------------------------------------- 1 | APP_NAME = "paprika-recipes" 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | setup_requires=["pbr"], 5 | pbr=True, 6 | ) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | env/ 2 | .vscode/ 3 | .mypy_cache/ 4 | *.pyc 5 | *.egg-info 6 | .eggs 7 | build/ 8 | dist/ 9 | docs/_build/ 10 | docs/_static/ 11 | docs/_templates/ 12 | AUTHORS 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyyaml>=5.0,<6.0 2 | requests>=2.0,<3.0 3 | keyring>=21.0,<22.0 4 | rich>=12.3.0,<13.0 5 | questionary>=1.10.0,<2.0 6 | appdirs>=1.4,<2.0 7 | typing_extensions>=4.4.0,<5.0 8 | -------------------------------------------------------------------------------- /paprika_recipes/exceptions.py: -------------------------------------------------------------------------------- 1 | class PaprikaException(Exception): 2 | pass 3 | 4 | 5 | class PaprikaProgrammingError(PaprikaException): 6 | pass 7 | 8 | 9 | class PaprikaError(PaprikaException): 10 | pass 11 | 12 | 13 | class AuthenticationError(PaprikaError): 14 | pass 15 | 16 | 17 | class RequestError(PaprikaError): 18 | pass 19 | 20 | 21 | class PaprikaUserError(PaprikaException): 22 | pass 23 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: push 3 | jobs: 4 | run-linters: 5 | name: Run linters 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Check out Git repository 10 | uses: actions/checkout@v2 11 | 12 | - name: Set up Python 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.8 16 | 17 | - name: Install Python dependencies 18 | run: pip install black==20.8b1 flake8==3.8.3 19 | 20 | - name: Run linters 21 | uses: samuelmeuli/lint-action@v1.5.3 22 | with: 23 | github_token: ${{ secrets.github_token }} 24 | # Enable linters 25 | black: true 26 | flake8: true 27 | -------------------------------------------------------------------------------- /paprika_recipes/commands/create_recipe.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | from ..command import RemoteCommand 5 | from ..remote import RemoteRecipe 6 | from ..types import ConfigDict 7 | from ..utils import edit_recipe_interactively 8 | 9 | 10 | class Command(RemoteCommand): 11 | @classmethod 12 | def get_help(cls) -> str: 13 | return """Opens an editor allowing you to upload a new paprika recipe.""" 14 | 15 | @classmethod 16 | def add_arguments(cls, parser: argparse.ArgumentParser, config: ConfigDict) -> None: 17 | parser.add_argument("--editor", default=os.environ.get("EDITOR", "vim")) 18 | 19 | def handle(self) -> None: 20 | remote = self.get_remote() 21 | 22 | created = edit_recipe_interactively(RemoteRecipe()) 23 | 24 | remote.upload_recipe(created) 25 | remote.notify() 26 | -------------------------------------------------------------------------------- /paprika_recipes/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABCMeta, abstractmethod, abstractproperty 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any, Iterable, Iterator 6 | 7 | from typing_extensions import TypedDict 8 | 9 | if TYPE_CHECKING: 10 | from .recipe import BaseRecipe 11 | 12 | 13 | UNKNOWN = Any # Used for documenting unknown API responses 14 | 15 | 16 | @dataclass 17 | class RemoteRecipeIdentifier: 18 | hash: str 19 | uid: str 20 | 21 | 22 | class RecipeManager(metaclass=ABCMeta): 23 | def __iter__(self) -> Iterator[BaseRecipe]: 24 | yield from self.recipes 25 | 26 | @abstractproperty 27 | def recipes(self) -> Iterable[BaseRecipe]: 28 | ... 29 | 30 | @abstractmethod 31 | def count(self) -> int: 32 | ... 33 | 34 | 35 | class ConfigDict(TypedDict, total=False): 36 | default_account: str 37 | -------------------------------------------------------------------------------- /paprika_recipes/commands/store_password.py: -------------------------------------------------------------------------------- 1 | from getpass import getpass 2 | 3 | import keyring 4 | import questionary 5 | 6 | from ..command import BaseCommand 7 | from ..constants import APP_NAME 8 | from ..remote import Remote 9 | from ..utils import save_config 10 | 11 | 12 | class Command(BaseCommand): 13 | @classmethod 14 | def get_help(cls) -> str: 15 | return """Stores a paprika account password in your system keyring.""" 16 | 17 | def handle(self) -> None: 18 | email = "" 19 | password = "" 20 | 21 | while not email: 22 | email = input("Email: ") 23 | 24 | while not password: 25 | password = getpass("Password: ") 26 | 27 | if Remote(email, password).bearer_token: 28 | keyring.set_password(APP_NAME, email, password) 29 | print(f"Password stored for {email}") 30 | 31 | if questionary.confirm(f"Use {email} as your default account?").ask(): 32 | self.config["default_account"] = email 33 | save_config(self.config) 34 | -------------------------------------------------------------------------------- /paprika_recipes/commands/create_archive.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | from yaml import safe_load 5 | 6 | from ..archive import Archive, ArchiveRecipe 7 | from ..command import BaseCommand 8 | from ..types import ConfigDict 9 | 10 | 11 | class Command(BaseCommand): 12 | @classmethod 13 | def get_help(cls) -> str: 14 | return """Creates a new .paprikarecipes file from a directory 15 | of recipes.""" 16 | 17 | @classmethod 18 | def add_arguments(cls, parser: argparse.ArgumentParser, config: ConfigDict) -> None: 19 | parser.add_argument("export_path", type=Path) 20 | parser.add_argument("archive_path", type=Path) 21 | 22 | def handle(self) -> None: 23 | archive = Archive() 24 | 25 | for recipe_file in self.options.export_path.iterdir(): 26 | with open(recipe_file, "r") as inf: 27 | archive.add_recipe(ArchiveRecipe.from_dict(safe_load(inf))) 28 | 29 | with open(self.options.archive_path, "wb") as outf: 30 | archive.as_paprikarecipes(outf) 31 | -------------------------------------------------------------------------------- /paprika_recipes/commands/download_recipes.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | from rich.progress import track 5 | 6 | from ..command import RemoteCommand 7 | from ..types import ConfigDict 8 | from ..utils import dump_recipe_yaml 9 | 10 | 11 | class Command(RemoteCommand): 12 | @classmethod 13 | def get_help(cls) -> str: 14 | return """Downloads all recipes from a paprika account to a directory.""" 15 | 16 | @classmethod 17 | def add_arguments(cls, parser: argparse.ArgumentParser, config: ConfigDict) -> None: 18 | parser.add_argument("export_path", type=Path) 19 | 20 | def handle(self) -> None: 21 | remote = self.get_remote() 22 | 23 | self.options.export_path.mkdir(parents=True, exist_ok=True) 24 | 25 | for recipe in track( 26 | remote, total=remote.count(), description="Downloading Recipes" 27 | ): 28 | with open( 29 | self.options.export_path / Path(f"{recipe.name}.paprikarecipe.yaml"), 30 | "w", 31 | ) as outf: 32 | dump_recipe_yaml(recipe, outf) 33 | -------------------------------------------------------------------------------- /paprika_recipes/commands/extract_archive.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | from ..archive import Archive 5 | from ..command import BaseCommand 6 | from ..types import ConfigDict 7 | from ..utils import dump_recipe_yaml 8 | 9 | 10 | class Command(BaseCommand): 11 | @classmethod 12 | def get_help(cls) -> str: 13 | return """Extracts a .paprikarecipes archive to a directory.""" 14 | 15 | @classmethod 16 | def add_arguments(cls, parser: argparse.ArgumentParser, config: ConfigDict) -> None: 17 | parser.add_argument("archive_path", type=Path) 18 | parser.add_argument("export_path", type=Path) 19 | 20 | def handle(self) -> None: 21 | with open(self.options.archive_path, "rb") as inf: 22 | archive = Archive.from_file(inf) 23 | 24 | self.options.export_path.mkdir(parents=True, exist_ok=True) 25 | 26 | for recipe in archive: 27 | with open( 28 | self.options.export_path 29 | / Path(f"{recipe.name}.paprikarecipe.yaml"), 30 | "w", 31 | ) as outf: 32 | dump_recipe_yaml(recipe, outf) 33 | -------------------------------------------------------------------------------- /paprika_recipes/commands/upload_recipes.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | from rich.progress import track 5 | from yaml import safe_load 6 | 7 | from ..command import RemoteCommand 8 | from ..remote import RemoteRecipe 9 | from ..types import ConfigDict 10 | from ..utils import dump_recipe_yaml 11 | 12 | 13 | class Command(RemoteCommand): 14 | @classmethod 15 | def get_help(cls) -> str: 16 | return """Uploads a directory of recipes to a paprika account.""" 17 | 18 | @classmethod 19 | def add_arguments(cls, parser: argparse.ArgumentParser, config: ConfigDict) -> None: 20 | parser.add_argument("import_path", type=Path) 21 | 22 | def handle(self) -> None: 23 | remote = self.get_remote() 24 | 25 | files = list(self.options.import_path.iterdir()) 26 | 27 | for recipe_file in track(files, description="Uploading Recipes"): 28 | with open(recipe_file, "r") as inf: 29 | uploaded = remote.upload_recipe(RemoteRecipe.from_dict(safe_load(inf))) 30 | 31 | with open(recipe_file, "w") as outf: 32 | dump_recipe_yaml(uploaded, outf) 33 | 34 | remote.notify() 35 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | # Sort imports prior to black reformatting, to 5 | # ensure black always takes prescedence 6 | - repo: https://github.com/timothycrosley/isort 7 | rev: 4.3.21 8 | hooks: 9 | - id: isort 10 | - repo: https://github.com/ambv/black 11 | rev: 20.8b1 12 | hooks: 13 | - id: black 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v2.4.0 16 | hooks: 17 | - id: end-of-file-fixer 18 | files: '.*\.py$' 19 | - id: flake8 20 | additional_dependencies: 21 | - flake8-bugbear==19.3.0 22 | - flake8-printf-formatting==1.1.0 23 | - flake8-pytest==1.3 24 | - flake8-pytest-style==0.1.3 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v1.25.1 27 | hooks: 28 | - id: pyupgrade 29 | args: ["--py37-plus"] 30 | - repo: https://github.com/pre-commit/mirrors-mypy 31 | rev: v0.782 32 | hooks: 33 | - id: mypy 34 | args: 35 | - --pretty 36 | - --show-error-codes 37 | - --show-error-context 38 | - --ignore-missing-imports 39 | -------------------------------------------------------------------------------- /paprika_recipes/cmdline.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | 3 | from rich.console import Console 4 | from rich.traceback import install as enable_rich_traceback 5 | 6 | from .command import get_installed_commands 7 | from .exceptions import PaprikaError, PaprikaUserError 8 | from .utils import get_config 9 | 10 | 11 | def main(): 12 | enable_rich_traceback() 13 | commands = get_installed_commands() 14 | config = get_config() 15 | 16 | parser = ArgumentParser() 17 | parser.add_argument("--debugger", action="store_true") 18 | subparsers = parser.add_subparsers(dest="command") 19 | subparsers.required = True 20 | 21 | for cmd_name, cmd_class in commands.items(): 22 | parser_kwargs = {} 23 | 24 | cmd_help = cmd_class.get_help() 25 | if cmd_help: 26 | parser_kwargs["help"] = cmd_help 27 | 28 | subparser = subparsers.add_parser(cmd_name, **parser_kwargs) 29 | cmd_class._add_arguments(subparser, config) 30 | 31 | args = parser.parse_args() 32 | 33 | if args.debugger: 34 | import debugpy 35 | 36 | debugpy.listen(("0.0.0.0", 5678)) 37 | debugpy.wait_for_client() 38 | 39 | console = Console() 40 | 41 | try: 42 | commands[args.command](config, args).handle() 43 | except PaprikaError as e: 44 | console.print(f"[red]{e}[/red]") 45 | except PaprikaUserError as e: 46 | console.print(f"[yellow]{e}[/yellow]") 47 | except Exception: 48 | console.print_exception() 49 | -------------------------------------------------------------------------------- /paprika_recipes/archive.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import IO, Iterable, List, Optional 5 | from zipfile import ZIP_DEFLATED, ZipFile 6 | 7 | from .recipe import BaseRecipe 8 | from .types import UNKNOWN, RecipeManager 9 | 10 | 11 | @dataclass 12 | class ArchiveRecipe(BaseRecipe): 13 | photos: List[UNKNOWN] = field(default_factory=list) 14 | photo_data: Optional[str] = None 15 | 16 | 17 | class Archive(RecipeManager): 18 | _recipes: List[ArchiveRecipe] = [] 19 | 20 | @property 21 | def recipes(self) -> Iterable[ArchiveRecipe]: 22 | return self._recipes 23 | 24 | def count(self) -> int: 25 | return len(self._recipes) 26 | 27 | @classmethod 28 | def from_file(cls, data: IO[bytes]) -> Archive: 29 | archive = cls() 30 | 31 | with ZipFile(data) as zip_file: 32 | for zip_info in zip_file.infolist(): 33 | archive.add_recipe( 34 | ArchiveRecipe.from_file(zip_file.open(zip_info.filename)) 35 | ) 36 | 37 | return archive 38 | 39 | def add_recipe(self, recipe: ArchiveRecipe) -> ArchiveRecipe: 40 | self._recipes.append(recipe) 41 | 42 | return recipe 43 | 44 | def as_paprikarecipes(self, outf: IO[bytes]): 45 | with ZipFile(outf, mode="w", compression=ZIP_DEFLATED) as zip_file: 46 | for recipe in self: 47 | with zip_file.open( 48 | f"{recipe.name}.paprikarecipe", mode="w" 49 | ) as recipe_file: 50 | recipe_file.write(recipe.as_paprikarecipe()) 51 | 52 | def __str__(self): 53 | return f"Paprika Archive ({self.count()} recipes)" 54 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 2.4.0 5 | ----- 6 | 7 | * Completely replace enquiries 8 | * Switch to 'questionary' from 'enquiries'; add handling for multiple recipes having the same name 9 | * Reformatting; adding pre-commit 10 | * Reformatting with 'black' 11 | * Do not list recipes from remote that have been deleted 12 | 13 | 2.3.0 14 | ----- 15 | 16 | * Show name, ingredients, instructions near top of recipe YAML files 17 | * When displaying list of recipes; display them in alphabetical order 18 | * Updating changelog 19 | 20 | 2.2.0 21 | ----- 22 | 23 | * Switching back to yaml.dump to fix string formatting 24 | * Show a progressbar when saving recipe during edit 25 | * Use pathlib.Path for paths 26 | * Fixing a minor typing problem 27 | * Adding command-line arguments allowing you to control how caching is used 28 | * Store a cached copy of remote recipes to improve remote performance 29 | * Updating changelog 30 | 31 | 2.1.0 32 | ----- 33 | 34 | * Print a message in the 'interactive' recipe editor indicating how you might cancel your modification 35 | 36 | 2.0.0 37 | ----- 38 | 39 | * Adding changelog (was accidentally ignored) 40 | * Adds notion of 'default account'; refactors save-password to set it, and commands to use it 41 | * Readme formatting fix 42 | * Re-organizing readme 43 | * Updating readme 44 | 45 | 1.2.0 46 | ----- 47 | 48 | * I've decided this should've been pluralized this whole time 49 | * Adding 'edit-recipe' command and updating readme to be a little more descriptive 50 | * UI improvements; adding progressbars; improving error and traceback display 51 | * Adding command allowing you to interactively create a recipe 52 | * Adding help text for each of the subcommands 53 | * Adding new 'upload-recipes' function 54 | * Use the slightly better yaml dump function when downloading recipes 55 | * When dumping to yaml, use the much better multiline syntax 56 | * Adding support for downloading recipes from your paprika account 57 | 58 | 1.1.0 59 | ----- 60 | 61 | * Make the importer much more forgiving of missing information to make adding recipes to an archive easy 62 | 63 | 1.0.0 64 | ----- 65 | 66 | * Adding readme instructions; updating a couple command names 67 | * Slightly reallocating responsibilities between the archive and recipe classes 68 | * Adding import-from-directory functionality, too 69 | * Adding basic parser & export-to-directory functionality 70 | -------------------------------------------------------------------------------- /paprika_recipes/recipe.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import gzip 5 | import hashlib 6 | import json 7 | import uuid 8 | from dataclasses import asdict, dataclass, field, fields 9 | from typing import IO, Any, Dict, List, Type, TypeVar 10 | 11 | from .types import UNKNOWN 12 | 13 | T = TypeVar("T", bound="BaseRecipe") 14 | 15 | 16 | @dataclass 17 | class BaseRecipe: 18 | categories: List[str] = field(default_factory=list) 19 | cook_time: str = "" 20 | created: str = field(default_factory=lambda: str(datetime.datetime.utcnow())[0:19]) 21 | description: str = "" 22 | difficulty: str = "" 23 | directions: str = "" 24 | hash: str = field( 25 | default_factory=lambda: hashlib.sha256( 26 | str(uuid.uuid4()).encode("utf-8") 27 | ).hexdigest() 28 | ) 29 | image_url: str = "" 30 | ingredients: str = "" 31 | name: str = "" 32 | notes: str = "" 33 | nutritional_info: str = "" 34 | photo: str = "" 35 | photo_hash: str = "" 36 | photo_large: UNKNOWN = None 37 | prep_time: str = "" 38 | rating: int = 0 39 | servings: str = "" 40 | source: str = "" 41 | source_url: str = "" 42 | total_time: str = "" 43 | uid: str = field(default_factory=lambda: str(uuid.uuid4()).upper()) 44 | 45 | @classmethod 46 | def get_all_fields(cls): 47 | return fields(cls) 48 | 49 | @classmethod 50 | def from_file(cls: Type[T], data: IO[bytes]) -> T: 51 | return cls.from_dict(json.loads(gzip.open(data).read())) 52 | 53 | @classmethod 54 | def from_dict(cls: Type[T], data: Dict[str, Any]) -> T: 55 | return cls(**data) 56 | 57 | def as_paprikarecipe(self) -> bytes: 58 | return gzip.compress(self.as_json().encode("utf-8")) 59 | 60 | def as_json(self): 61 | return json.dumps(self.as_dict()) 62 | 63 | def as_dict(self): 64 | return asdict(self) 65 | 66 | def calculate_hash(self) -> str: 67 | fields = self.as_dict() 68 | fields.pop("hash", None) 69 | 70 | return hashlib.sha256( 71 | json.dumps(fields, sort_keys=True).encode("utf-8") 72 | ).hexdigest() 73 | 74 | def update_hash(self): 75 | self.hash = self.calculate_hash() 76 | 77 | def __str__(self): 78 | return self.name 79 | 80 | def __repr__(self): 81 | return f"<{self}>" 82 | -------------------------------------------------------------------------------- /paprika_recipes/commands/edit_recipe.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | from typing import List 4 | 5 | import questionary 6 | from rich.progress import Progress, track 7 | 8 | from ..command import RemoteCommand 9 | from ..exceptions import PaprikaUserError 10 | from ..remote import RemoteRecipe 11 | from ..types import ConfigDict 12 | from ..utils import edit_recipe_interactively 13 | 14 | 15 | class Command(RemoteCommand): 16 | @classmethod 17 | def get_help(cls) -> str: 18 | return """Opens an editor allowing you to edit an existing paprika recipe.""" 19 | 20 | @classmethod 21 | def add_arguments(cls, parser: argparse.ArgumentParser, config: ConfigDict) -> None: 22 | parser.add_argument("--editor", default=os.environ.get("EDITOR", "vim")) 23 | parser.add_argument("search_terms", nargs="*", type=str) 24 | 25 | def handle(self) -> None: 26 | remote = self.get_remote() 27 | 28 | recipes: List[RemoteRecipe] = [] 29 | for recipe in track( 30 | remote, total=remote.count(), description="Loading Recipes" 31 | ): 32 | matched = True 33 | 34 | if recipe.in_trash: 35 | continue 36 | 37 | for term in self.options.search_terms: 38 | if term.lower() not in recipe.name.lower(): 39 | matched = False 40 | break 41 | 42 | if matched: 43 | recipes.append(recipe) 44 | 45 | try: 46 | if len(recipes) > 1: 47 | choice = questionary.select( 48 | "Select a recipe to edit", 49 | [ 50 | questionary.Choice(recipe.name, recipe.uid) 51 | for recipe in sorted(recipes, key=lambda row: row.name) 52 | ], 53 | ).ask() 54 | recipe = list(filter(lambda x: x.uid == choice, recipes))[0] 55 | else: 56 | recipe = recipes[0] 57 | except IndexError: 58 | raise PaprikaUserError("No matching recipes were found.") 59 | 60 | created = edit_recipe_interactively(recipe) 61 | 62 | with Progress() as pb: 63 | task_id = pb.add_task("Uploading Recipe", total=1) 64 | 65 | remote.upload_recipe(created) 66 | 67 | pb.update(task_id, completed=1) 68 | 69 | remote.notify() 70 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = paprika-recipes 3 | author = Adam Coddington 4 | author-email = me@adamcoddington.net 5 | summary = Easily view and edit the contents of exports from the Paprika app. 6 | description-file = readme.md 7 | description-content-type = text/markdown; charset=UTF-8 8 | home-page = https://github.com/coddingtonbear/paprika-recipes/ 9 | license = MIT 10 | classifier = 11 | Development Status :: 4 - Beta 12 | Environment :: Console 13 | Programming Language :: Python :: 3 14 | 15 | [files] 16 | packages = 17 | paprika_recipes 18 | 19 | [entry_points] 20 | console_scripts = 21 | paprika-recipes = paprika_recipes.cmdline:main 22 | paprika_recipes.commands = 23 | extract-archive = paprika_recipes.commands.extract_archive:Command 24 | create-archive = paprika_recipes.commands.create_archive:Command 25 | store-password = paprika_recipes.commands.store_password:Command 26 | download-recipes = paprika_recipes.commands.download_recipes:Command 27 | upload-recipes = paprika_recipes.commands.upload_recipes:Command 28 | create-recipe = paprika_recipes.commands.create_recipe:Command 29 | edit-recipe = paprika_recipes.commands.edit_recipe:Command 30 | 31 | [flake8] 32 | # https://github.com/ambv/black#line-length 33 | max-line-length = 88 34 | ignore = 35 | E203, # E203: whitespace before ':' (defer to black) 36 | E231, # E231: missing whitespace after ',' (defer to black) 37 | E501, # E501: line length (defer to black) 38 | W503, # W503: break before binary operators (defer to black) 39 | A003, # A003: [builtins] allow class attributes to be named after builtins (e.g., `id`) 40 | exclude = 41 | migrations, 42 | 43 | [pep8] 44 | max-line-length = 88 45 | ignore = 46 | E701, # E701: multiple statements on one line (flags py3 inline type hints) 47 | 48 | [pydocstyle] 49 | # Harvey's initial attempt at pydocstyle rules. Please suggest improvements! 50 | # The `(?!\d{4}_)` pattern is a hacky way to exclude migrations, since pydocstyle doesn't 51 | # have an `exclude` option. https://github.com/PyCQA/pydocstyle/issues/175 52 | match = (?!test_)(?!\d{4}_).*\.py 53 | 54 | # See http://www.pydocstyle.org/en/5.0.1/error_codes.html 55 | ignore = 56 | D100, # D100: docstring in public module (we don't have a practice around this) 57 | D104, # D104: docstring in public package (we don't have a practice around this) 58 | D105, # D105: docstring in magic method 59 | D107, # D105: docstring in __init__ method 60 | D203, # D203: Blank line required before class docstring 61 | D213, # D213: Multi-line docstring summary should start at the second line (need to choose D212 or D213; see https://stackoverflow.com/a/45990465) 62 | D302, # D302: Use u”“” for Unicode docstrings 63 | -------------------------------------------------------------------------------- /paprika_recipes/cache.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from abc import ABCMeta, abstractmethod 5 | from pathlib import Path 6 | from typing import Dict 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class NotFound(Exception): 12 | pass 13 | 14 | 15 | class Cache(metaclass=ABCMeta): 16 | @abstractmethod 17 | def is_cached(self, uid: str, hash: str) -> bool: 18 | ... 19 | 20 | @abstractmethod 21 | def store_in_cache(self, uid: str, hash: str, recipe: Dict): 22 | ... 23 | 24 | @abstractmethod 25 | def read_from_cache(self, uid: str, hash: str) -> Dict: 26 | ... 27 | 28 | @abstractmethod 29 | def save(self): 30 | ... 31 | 32 | 33 | class NullCache(Cache): 34 | def is_cached(self, uid: str, hash: str) -> bool: 35 | return False 36 | 37 | def store_in_cache(self, uid: str, hash: str, recipe: Dict): 38 | pass 39 | 40 | def read_from_cache(self, uid: str, hash: str) -> Dict: 41 | raise NotImplementedError() 42 | 43 | def save(self): 44 | pass 45 | 46 | 47 | class DirectoryCache(Cache): 48 | def __init__(self, path: str): 49 | self._root_path = Path(path) 50 | self._index = self._load_index() 51 | 52 | def is_cached(self, uid: str, hash: str) -> bool: 53 | return self.index.get(uid) == hash 54 | 55 | def store_in_cache(self, uid: str, hash: str, recipe: Dict): 56 | self.index[uid] = hash 57 | 58 | try: 59 | with open(self._root_path / f"{uid}.json", "w") as outf: 60 | json.dump(recipe, outf) 61 | except Exception as e: 62 | logger.exception("Error encountered while writing to cache: %s", e) 63 | 64 | def read_from_cache(self, uid: str, hash: str) -> Dict: 65 | if not self.is_cached(uid, hash): 66 | raise NotFound() 67 | 68 | try: 69 | with open(self._root_path / f"{uid}.json", "r") as outf: 70 | return json.load(outf) 71 | except Exception as e: 72 | logger.exception("Error encountered while loading from cache: %s", e) 73 | return {} 74 | 75 | @property 76 | def index(self) -> Dict[str, str]: 77 | return self._index 78 | 79 | def _load_index(self) -> Dict[str, str]: 80 | index_path = self._root_path / "index.json" 81 | 82 | if not os.path.isfile(index_path): 83 | return {} 84 | 85 | try: 86 | with open(index_path, "r") as inf: 87 | return json.load(inf) 88 | except Exception as e: 89 | logger.exception("Error encountered while loading cache index: %s", e) 90 | return {} 91 | 92 | def save(self): 93 | try: 94 | with open(self._root_path / "index.json", "w") as outf: 95 | json.dump(self.index, outf) 96 | except Exception as e: 97 | logger.exception("Error encountered while saving cache: %s", e) 98 | 99 | 100 | class WriteOnlyDirectoryCache(DirectoryCache): 101 | def is_cached(self, uid: str, hash: str) -> bool: 102 | return False 103 | 104 | def read_from_cache(self, uid: str, hash: str) -> Dict: 105 | raise NotImplementedError() 106 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Paprika-Recipes: Easily manage your paprika recipes 2 | 3 | [Paprika](https://www.paprikaapp.com/) is a lovely recipe app that works so much better than I ever expected a recipe app to, and although its totally possible to edit recipes without leaving the app, that can be a little inconvenient. This console app and library are built to make creating and editing your recipes easy by providing you tools for editing or creating individual recipes, downloading your whole recipe list, and even working with recipe exports generated by the app. 4 | 5 | ## Installation 6 | 7 | ``` 8 | pip install paprika-recipes 9 | ``` 10 | 11 | ## Usage 12 | 13 | ### Modifying via Paprika's API 14 | 15 | This app can interact directly with Paprika's API using the commands documented here, but before beginning, you will need to store your paprika account information in your system keyring by running: 16 | 17 | ``` 18 | paprika-recipes store-password 19 | ``` 20 | 21 | You'll be asked for your e-mail and password; after that point, the console app will fetch your password from your system keyring instead of prompting you for it. 22 | 23 | The instructions below assume that you've answered "yes" when asked whether you'd like to use this account by default, but if you didn't -- see `--help` for instructions. 24 | 25 | #### Modifying one of your existing recipes 26 | 27 | You can modify a recipe on your Paprika account by running the following 28 | 29 | ``` 30 | paprika-recipes edit-recipe 31 | ``` 32 | 33 | You'll be presented with a list of recipes on your account and after 34 | you select the recipe you'd like to edit, your editor will be opened 35 | to allow you to make the modifications you want to make. 36 | Just save and close your editor to upload your updated recipe to Paprika. 37 | 38 | If you have decided that you've made a mistake and would like to abort, 39 | just delete all of the contents of the file before saving and closing 40 | your editor. We won't update your recipe if you do that. 41 | 42 | You can also provide search parameters as command-line arguments to 43 | limit the list of recipes presented to you, and if your search terms 44 | match just one of your recipes, we'll open the editor straight away. 45 | 46 | #### Creating a new recipe on your Paprika account 47 | 48 | You can create a new recipe on your Paprika account by running the following 49 | 50 | ``` 51 | paprika-recipes create-recipe 52 | ``` 53 | 54 | Your editor will be opened to a brand new empty recipe. Just write out 55 | your recipe's instructions and whatever other fields you'd like to fill 56 | out, then save and close your editor -- we'll upload your recipe to your 57 | Paprika account as soon as your editor has closed. 58 | 59 | If you have decided that you've made a mistake and would like to abort, 60 | just delete all of the contents of the file before saving and closing 61 | your editor. We won't update your recipe if you do that. 62 | 63 | #### Downloading your whole recipe collection 64 | 65 | If you want to download your whole recipe archive instead of editing or creating a single recipe at a time, you can download your whole recipe collection into a directory on your computer. 66 | 67 | The expected workflow for changing your recipes when using this method is a three-step process: 68 | 69 | 1. Downloading your paprika recipes from your account. 70 | 2. Modifying the extracted yaml recipe files or creating new ones. 71 | 3. Uploading your changed or new recipes back to your account. 72 | 73 | ##### Downloading 74 | 75 | ``` 76 | paprika-recipes download-recipes /path/to/export/your/recipes 77 | ``` 78 | 79 | ##### Uploading 80 | 81 | ``` 82 | paprika-recipes upload-recipes /path/to/where/you/exported/your/recipes 83 | ``` 84 | 85 | ### Modifying via Exported Archives 86 | 87 | The expected workflow for changing your recipes is a three-step process: 88 | 89 | 1. Extracting your `paprikarecipes` file to a directory. 90 | 2. Modifying the extracted yaml recipe files. 91 | 3. Compress your recipes back into an archive. 92 | 93 | #### Extracting 94 | 95 | ``` 96 | paprika-recipes extract-archive /path/to/your/export.paprikarecipes /path/to/extract/recipes/to/ 97 | ``` 98 | 99 | #### Compressing 100 | 101 | ``` 102 | paprika-recipes create-archive /path/you/earlier/extracted/recipes/to/ /path/to/a/new/export.paprikarecipes 103 | ``` 104 | -------------------------------------------------------------------------------- /paprika_recipes/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import tempfile 4 | from collections import OrderedDict 5 | from pathlib import Path 6 | from textwrap import dedent 7 | from typing import TYPE_CHECKING, Any, List, TypeVar, cast 8 | 9 | import keyring 10 | import yaml 11 | from appdirs import user_config_dir 12 | 13 | from .constants import APP_NAME 14 | from .exceptions import AuthenticationError, PaprikaUserError 15 | from .types import ConfigDict 16 | 17 | if TYPE_CHECKING: 18 | from .recipe import BaseRecipe # noqa 19 | 20 | 21 | def str_representer(dumper, data): 22 | if len(data.splitlines()) > 1: 23 | return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") 24 | return dumper.represent_scalar("tag:yaml.org,2002:str", data) 25 | 26 | 27 | def ordereddict_representer(dumper, data): 28 | value = [] 29 | 30 | for item_key, item_value in data.items(): 31 | node_key = dumper.represent_data(item_key) 32 | node_value = dumper.represent_data(item_value) 33 | 34 | value.append((node_key, node_value)) 35 | 36 | return yaml.nodes.MappingNode("tag:yaml.org,2002:map", value) 37 | 38 | 39 | yaml.add_representer(OrderedDict, ordereddict_representer) 40 | 41 | yaml.add_representer(str, str_representer) 42 | 43 | 44 | def dump_recipe_yaml(recipe: "BaseRecipe", *args: Any): 45 | key_ordering: List[str] = [ 46 | "name", 47 | "description", 48 | "ingredients", 49 | "directions", 50 | "notes", 51 | "nutritional_info", 52 | ] 53 | recipe_dict = OrderedDict() 54 | recipe_dict_unsorted = recipe.as_dict() 55 | 56 | for key in key_ordering: 57 | if key in recipe_dict_unsorted: 58 | recipe_dict[key] = recipe_dict_unsorted.pop(key) 59 | 60 | for key in sorted(recipe_dict_unsorted.keys()): 61 | recipe_dict[key] = recipe_dict_unsorted.pop(key) 62 | 63 | assert not recipe_dict_unsorted 64 | 65 | dump_yaml(recipe_dict, *args) 66 | 67 | 68 | def dump_yaml(*args: Any): 69 | # We're using a custom presenter, so we have to use `yaml.dump` 70 | # instead of `yaml.safe_dump` -- that's OK, though -- we still use 71 | # `safe_load`, which is where the actual risks are. 72 | yaml.dump(*args, allow_unicode=True) 73 | 74 | 75 | def load_yaml(*args: Any) -> Any: 76 | return yaml.safe_load(*args) 77 | 78 | 79 | def get_password_for_email(email: str) -> str: 80 | if not email: 81 | raise AuthenticationError("No account was specified.") 82 | 83 | password = keyring.get_password(APP_NAME, email) 84 | 85 | if not password: 86 | raise AuthenticationError( 87 | f"No password stored for {email}; " 88 | "store a password for this user using store-password first." 89 | ) 90 | 91 | return password 92 | 93 | 94 | T = TypeVar("T", bound="BaseRecipe") 95 | 96 | 97 | def edit_recipe_interactively(recipe: T, editor="vim") -> T: 98 | with tempfile.NamedTemporaryFile(suffix=".paprikarecipe.yaml", mode="w+") as outf: 99 | outf.write( 100 | dedent( 101 | """\ 102 | # Please modify your recipe below, then save and exit. 103 | # To cancel, delete all content from this file. 104 | """ 105 | ) 106 | ) 107 | 108 | dump_recipe_yaml(recipe, outf) 109 | 110 | outf.seek(0) 111 | 112 | proc = subprocess.Popen([editor, outf.name]) 113 | proc.wait() 114 | 115 | outf.seek(0) 116 | 117 | contents = outf.read().strip() 118 | 119 | if not contents: 120 | raise PaprikaUserError("Empty recipe found; aborting") 121 | 122 | outf.seek(0) 123 | 124 | return recipe.__class__.from_dict(yaml.safe_load(outf)) 125 | 126 | 127 | def get_config_dir() -> Path: 128 | root_path = Path(user_config_dir(APP_NAME, "coddingtonbear")) 129 | os.makedirs(root_path, exist_ok=True) 130 | 131 | return root_path 132 | 133 | 134 | def get_cache_dir() -> Path: 135 | cache_path = Path(user_config_dir(APP_NAME, "coddingtonbear")) / "cache" 136 | os.makedirs(cache_path, exist_ok=True) 137 | 138 | return cache_path 139 | 140 | 141 | def get_default_config_path() -> Path: 142 | root_path = get_config_dir() 143 | return root_path / "config.yaml" 144 | 145 | 146 | def get_config(path: Path = None) -> ConfigDict: 147 | if path is None: 148 | path = get_default_config_path() 149 | 150 | if not os.path.isfile(path): 151 | return {} 152 | 153 | with open(path, "r") as inf: 154 | return cast(ConfigDict, load_yaml(inf)) 155 | 156 | 157 | def save_config(data: ConfigDict, path: Path = None) -> None: 158 | if path is None: 159 | path = get_default_config_path() 160 | 161 | with open(path, "w") as outf: 162 | dump_yaml(data, outf) 163 | -------------------------------------------------------------------------------- /paprika_recipes/remote.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Dict, Iterable, Iterator, List, Optional 3 | 4 | import requests 5 | 6 | from .cache import Cache, NullCache 7 | from .exceptions import PaprikaError, RequestError 8 | from .recipe import BaseRecipe 9 | from .types import RecipeManager, RemoteRecipeIdentifier 10 | 11 | 12 | @dataclass 13 | class RemoteRecipe(BaseRecipe): 14 | in_trash: bool = False 15 | is_pinned: bool = False 16 | on_favorites: bool = False 17 | on_grocery_list: Optional[str] = None 18 | photo_url: Optional[str] = None 19 | scale: Optional[str] = None 20 | 21 | 22 | class Remote(RecipeManager): 23 | _bearer_token: Optional[str] = None 24 | 25 | _domain: str 26 | _email: str 27 | _password: str 28 | 29 | def __init__( 30 | self, 31 | email: str, 32 | password: str, 33 | domain: str = "www.paprikaapp.com", 34 | cache: Optional[Cache] = None, 35 | ): 36 | super().__init__() 37 | self._email = email 38 | self._password = password 39 | self._domain = domain 40 | self._cache = cache if cache else NullCache() 41 | 42 | def __iter__(self) -> Iterator[RemoteRecipe]: 43 | yield from self.recipes 44 | 45 | @property 46 | def recipes(self) -> Iterable[RemoteRecipe]: 47 | for recipe in self._get_remote_recipe_identifiers(): 48 | yield self.get_recipe_by_id(recipe.uid, recipe.hash) 49 | 50 | def get_recipe_by_id(self, id: str, hash: str) -> RemoteRecipe: 51 | all_fields = RemoteRecipe.get_all_fields() 52 | 53 | data: Dict = {} 54 | 55 | if self._cache.is_cached(id, hash): 56 | data = self._cache.read_from_cache(id, hash) 57 | 58 | if not data: 59 | recipe_response = self._request("get", f"/api/v2/sync/recipe/{id}/") 60 | 61 | data = recipe_response.json().get("result", {}) 62 | 63 | self._cache.store_in_cache(id, hash, data) 64 | self._cache.save() 65 | 66 | return RemoteRecipe( 67 | **{ 68 | field.name: data[field.name] 69 | for field in all_fields 70 | if field.name in data 71 | } 72 | ) 73 | 74 | def count(self) -> int: 75 | return len(self._get_remote_recipe_identifiers()) 76 | 77 | def upload_recipe(self, recipe: RemoteRecipe) -> RemoteRecipe: 78 | recipe.update_hash() 79 | 80 | self._request( 81 | "post", 82 | f"/api/v2/sync/recipe/{recipe.uid}/", 83 | files={"data": recipe.as_paprikarecipe()}, 84 | ) 85 | 86 | return self.get_recipe_by_id(recipe.uid, recipe.hash) 87 | 88 | def add_recipe(self, recipe: RemoteRecipe) -> RemoteRecipe: 89 | return self.upload_recipe(recipe) 90 | 91 | def _get_remote_recipe_identifiers(self) -> List[RemoteRecipeIdentifier]: 92 | recipes = self._request("get", "/api/v2/sync/recipes/") 93 | 94 | return [ 95 | RemoteRecipeIdentifier(**recipe) 96 | for recipe in recipes.json().get("result", []) 97 | ] 98 | 99 | def _request(self, method, path, authenticated=True, **kwargs): 100 | if authenticated: 101 | kwargs.setdefault("headers", {})[ 102 | "Authorization" 103 | ] = f"Bearer {self.bearer_token}" 104 | result = requests.request(method, f"https://{self._domain}{path}", **kwargs) 105 | result.raise_for_status() 106 | 107 | if "error" in result.json(): 108 | raise RequestError() 109 | 110 | return result 111 | 112 | @property 113 | def bearer_token(self): 114 | if not self._bearer_token: 115 | try: 116 | result = self._request( 117 | "post", 118 | "/api/v2/account/login/", 119 | data={"email": self._email, "password": self._password}, 120 | authenticated=False, 121 | ) 122 | 123 | token = result.json().get("result", {}).get("token") 124 | if not token: 125 | raise PaprikaError( 126 | f"No bearer token found in response: {result.content}" 127 | ) 128 | 129 | self._bearer_token = token 130 | except requests.HTTPError as e: 131 | raise PaprikaError( 132 | f"Authentication URL returned unexpected status: {e}" 133 | ) 134 | 135 | return self._bearer_token 136 | 137 | def notify(self): 138 | """Asks the API to notify recipe apps that changes have occurred.""" 139 | self._request("post", "/api/v2/sync/notify/") 140 | 141 | def __str__(self): 142 | return f"Remote Paprika Recipes ({self.count()} recipes)" 143 | -------------------------------------------------------------------------------- /paprika_recipes/command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import logging 5 | from abc import ABCMeta, abstractmethod 6 | from enum import Enum 7 | from pathlib import Path 8 | from typing import Dict, Optional, Type 9 | 10 | import pkg_resources 11 | 12 | from .cache import Cache, DirectoryCache, NullCache, WriteOnlyDirectoryCache 13 | from .exceptions import PaprikaProgrammingError 14 | from .remote import Remote 15 | from .types import ConfigDict 16 | from .utils import get_cache_dir, get_password_for_email 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def get_installed_commands() -> Dict[str, Type[BaseCommand]]: 22 | possible_commands: Dict[str, Type[BaseCommand]] = {} 23 | for entry_point in pkg_resources.iter_entry_points( 24 | group="paprika_recipes.commands" 25 | ): 26 | try: 27 | loaded_class = entry_point.load() 28 | except ImportError: 29 | logger.warning( 30 | "Attempted to load entrypoint %s, but " "an ImportError occurred.", 31 | entry_point, 32 | ) 33 | continue 34 | if not issubclass(loaded_class, BaseCommand): 35 | logger.warning( 36 | "Loaded entrypoint %s, but loaded class is " 37 | "not a subclass of `paprika_recipes.command.BaseCommand`.", 38 | entry_point, 39 | ) 40 | continue 41 | possible_commands[entry_point.name] = loaded_class 42 | 43 | return possible_commands 44 | 45 | 46 | class BaseCommand(metaclass=ABCMeta): 47 | def __init__(self, config: ConfigDict, options: argparse.Namespace): 48 | self._options: argparse.Namespace = options 49 | self._config: ConfigDict = config 50 | super().__init__() 51 | 52 | @property 53 | def options(self) -> argparse.Namespace: 54 | """Provides options provided at the command-line.""" 55 | return self._options 56 | 57 | @property 58 | def config(self) -> ConfigDict: 59 | """Returns saved configuration as a dictionary.""" 60 | return self._config 61 | 62 | @classmethod 63 | def get_help(cls) -> str: 64 | """Retuurns help text for this function.""" 65 | return "" 66 | 67 | @classmethod 68 | def add_arguments(cls, parser: argparse.ArgumentParser, config: ConfigDict) -> None: 69 | """Allows adding additional command-line arguments.""" 70 | pass 71 | 72 | @classmethod 73 | def _add_arguments( 74 | cls, parser: argparse.ArgumentParser, config: ConfigDict 75 | ) -> None: 76 | cls.add_arguments(parser, config) 77 | 78 | @abstractmethod 79 | def handle(self) -> None: 80 | """This is where the work of your function starts.""" 81 | ... 82 | 83 | 84 | class RemoteCommand(BaseCommand): 85 | _cache: Optional[Cache] = None 86 | 87 | class CacheChoices(Enum): 88 | none = "none" 89 | ignore = "ignore" 90 | enabled = "enabled" 91 | 92 | def __str__(self): 93 | return self.value 94 | 95 | def get_cache(self) -> Cache: 96 | if not self._cache: 97 | if self.options.cache_mode == self.CacheChoices.enabled: 98 | self._cache = DirectoryCache(self.options.cache_path) 99 | elif self.options.cache_mode == self.CacheChoices.ignore: 100 | self._cache = WriteOnlyDirectoryCache(self.options.cache_path) 101 | elif self.options.cache_mode == self.CacheChoices.none: 102 | self._cache = NullCache() 103 | else: 104 | raise PaprikaProgrammingError( 105 | f"Unhandled cache choice: {self.options.cache_mode}" 106 | ) 107 | 108 | return self._cache 109 | 110 | @classmethod 111 | def _add_arguments( 112 | cls, parser: argparse.ArgumentParser, config: ConfigDict 113 | ) -> None: 114 | """Allows adding additional command-line arguments.""" 115 | parser.add_argument( 116 | "--account", type=str, default=config.get("default_account", "") 117 | ) 118 | parser.add_argument( 119 | "--cache-mode", 120 | type=cls.CacheChoices, 121 | choices=cls.CacheChoices, 122 | default=cls.CacheChoices.enabled, 123 | help=( 124 | "enabled (default): read and write from the cache; " 125 | "ignore: write to the cache, but do not read from it; " 126 | "none: neither read nor write to the cache." 127 | ), 128 | ) 129 | parser.add_argument( 130 | "--cache-path", 131 | type=Path, 132 | default=Path(get_cache_dir()), 133 | help=f"directory to store cache files within; default: {get_cache_dir()}", 134 | ) 135 | super()._add_arguments(parser, config) 136 | 137 | def get_remote(self) -> Remote: 138 | return Remote( 139 | self.options.account, 140 | get_password_for_email(self.options.account), 141 | cache=self.get_cache(), 142 | ) 143 | --------------------------------------------------------------------------------