Skip to content

Commit

Permalink
Merge pull request #10 from MattTimms/dev
Browse files Browse the repository at this point in the history
release: v1.3.0
  • Loading branch information
MattTimms authored Jun 4, 2023
2 parents 2f0dd32 + 1d88a6f commit 576654f
Show file tree
Hide file tree
Showing 18 changed files with 120 additions and 147 deletions.
Binary file modified .github/imgs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions .github/workflows/run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
MAILERSEND_API_KEY: ${{ secrets.MAILERSEND_API_KEY }}
FROM_ADDRESS: no-reply@${{ secrets.DOMAIN }}
run: |
python coles_vs_woolies send shopping-list.json \
python coles_vs_woolies shopping-list.json \
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--dry_run' || '' }}
local:
needs: github
Expand All @@ -62,5 +62,5 @@ jobs:
MAILERSEND_API_KEY: ${{ secrets.MAILERSEND_API_KEY }}
FROM_ADDRESS: no-reply@${{ secrets.DOMAIN }}
run: |
python coles_vs_woolies send shopping-list.json \
python coles_vs_woolies shopping-list.json \
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--dry_run' || '' }}
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
.idea
.github/actions-runner
shopping-list.json
shopping-list.bkup.json

Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
# CHANGELOG.md

## v1.3.0

* Added support for merchant-exclusive products
* Added Jaccard similarity sorting for better accuracy results
* Dropped Python3.9 support
* Refactored CLI entry
* Fixed missing merchant in email
* Fixed IGA no-results if query >50 chars
* Fixed email formatting for no-offer-merchants

## v1.2.0

* Added 'iga' merchant
* Added 'iga' merchant
* Added CLI import shopping list from txt file
* Added CLI import of multiple shopping lists from json file

Expand Down
25 changes: 9 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 🍎 coles_vs_woolies 🍏

![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11-blue)
![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11-blue)
[![pass](https://github.com/MattTimms/coles_vs_woolies/actions/workflows/test.yml/badge.svg)](https://github.com/MattTimms/coles_vs_woolies/actions/workflows/test.yml)
[![working just fine for me](https://github.com/MattTimms/coles_vs_woolies/actions/workflows/run.yml/badge.svg)](https://github.com/MattTimms/coles_vs_woolies/actions/workflows/run.yml)

Expand Down Expand Up @@ -54,32 +54,25 @@ pip install -r requirements.txt

```shell
$ python coles_vs_woolies --help
# usage: coles_vs_woolies [-h] {display,send} ...
# usage: coles_vs_woolies [-h] [-o OUT_DIR] [-d] file_path
#
# Compare prices between Aussie grocers
#
# positional arguments:
# {display,send}
# display Display product price comparisons
# send Email product price comparisons
# file_path File path to a JSON config shopping list; see `shopping-list.example.json`
#
# options:
# -h, --help show this help message and exit
#
# example:
# python coles_vs_woolies display
# "Cadbury Dairy Milk Chocolate Block 180g"
# "Connoisseur Ice Cream Vanilla Caramel Brownie 1L"
#
# python coles_vs_woolies send
# "Cadbury Dairy Milk Chocolate Block 180g"
# "Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"
# --to_addrs <me@gmail.com> <you@gmail.com>
# -h, --help show this help message and exit
# -o OUT_DIR, --out_dir OUT_DIR
# Directory for saving copy of the email HTML template.
# -d, --dry_run Disable email delivery
```

```shell
cp .env.example .env
# populate .env to simplify calls
cp shopping-list.example.json shopping-list.json
# populate the shopping list with your email & desired items
```

## Install w/ GitHub Actions
Expand Down
96 changes: 22 additions & 74 deletions coles_vs_woolies/__main__.py
Original file line number Diff line number Diff line change
@@ -1,93 +1,41 @@
import argparse
import json
import os
import textwrap
from argparse import ArgumentParser
from typing import List

from pydantic import BaseModel
from pydantic import BaseModel, Extra

from coles_vs_woolies.main import display, send
from coles_vs_woolies.main import send


def cli():
example_usage = '''example:
python coles_vs_woolies display
"Cadbury Dairy Milk Chocolate Block 180g"
"Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"
python coles_vs_woolies send
"Cadbury Dairy Milk Chocolate Block 180g"
"Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"
--to_addrs <me@gmail.com> <you@gmail.com>
'''
class ShoppingList(BaseModel, extra=Extra.allow):
""" Model for the `shopping-list` json config file. """
to_addrs: list[str]
products: list[str]


def cli():
parser = ArgumentParser(
prog='coles_vs_woolies',
description='Compare prices between Aussie grocers',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent(example_usage)
)

subparsers = parser.add_subparsers(dest='action')

help_product = 'List of descriptive product search terms. Brand, package weight or size should be included. ' \
'Can be file path. E.g. "Cadbury Dairy Milk Chocolate Block 180g"' \
'"Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"'

# Display parser
display_parser = subparsers.add_parser('display', help='Display product price comparisons')
display_parser.add_argument('products', nargs='+', help=help_product)

# Send parser
send_parser = subparsers.add_parser('send', help='Email product price comparisons')
send_parser.add_argument('products', nargs='+', help=help_product)
send_parser.add_argument('-t', '--to_addrs', nargs='+', help="Recipients' email address.", required=False)
send_parser.add_argument('-o', '--out_dir', type=str, help='Directory for saving copy of the email HTML template.',
required=False)
send_parser.add_argument('-d', '--dry_run', action='store_true', help='Disable email delivery',
default=False, required=False)
parser.add_argument('file_path', type=str,
help='File path to a JSON config shopping list; see `shopping-list.example.json`')
parser.add_argument('-o', '--out_dir', type=str, required=False,
help='Directory for saving copy of the email HTML template.')
parser.add_argument('-d', '--dry_run', action='store_true', default=False, required=False,
help='Disable email delivery')

# Parse inputs
kwargs = vars(parser.parse_args())
action = kwargs.pop('action')

_product_inputs = kwargs.pop('products')
if os.path.isfile(fp := _product_inputs[0]) and fp.endswith('.json'):
with open(fp, 'r') as f:
jobs = [_JsonInput.parse_obj(x) for x in json.load(f)]
_ = kwargs.pop('to_addrs', None)
for job in jobs:
_run(action, job.products, to_addrs=job.to_addrs, **kwargs)
else:
if action == 'send' and kwargs.get('to_addrs', None) is None:
parser.error('the following arguments are required: -t/--to_addrs')
products = _parse_product_inputs(_product_inputs)
_run(action, products, **kwargs)


class _JsonInput(BaseModel):
to_addrs: List[str]
products: List[str]


def _run(action: str, products: List[str], **kwargs):
if action == 'send':
send(products=products, **kwargs)
else:
display(products=products)


def _parse_product_inputs(args: List[str]) -> List[str]:
""" Return product list from input list of products/file-paths """
products = []
for input_ in args:
if os.path.isfile(input_):
with open(input_, 'r') as f:
products.extend(f.read().splitlines())
else:
products.append(input_)
return sorted(list(set(products)))
with open(kwargs.pop('file_path'), 'r') as fp:
shopping_lists: list[ShoppingList] = [ShoppingList.parse_obj(list_) for list_ in json.load(fp)]

# Run for each shopping list
for shopping_list in shopping_lists:
send(products=shopping_list.products,
to_addrs=shopping_list.to_addrs,
**kwargs)


if __name__ == '__main__':
Expand Down
35 changes: 24 additions & 11 deletions coles_vs_woolies/emailing/generate.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import pathlib
import datetime
import pathlib

from rich.console import Console
from rich.table import Table

from coles_vs_woolies.search.types import ProductOffers
from coles_vs_woolies.search import available_merchants_names
from coles_vs_woolies.search.types import Merchant, ProductOffers

_SCRIPT_DIR = pathlib.Path(__file__).parent.absolute()
_TEMPLATE_DIR = _SCRIPT_DIR / 'templates'
Expand All @@ -25,37 +26,49 @@ def generate_weekly_email(product_offers: ProductOffers, out_path: str = None) -
with open(_TEMPLATE_DIR / 'snippets/table_row.html', 'r', encoding="utf-8") as f:
html_template_table_row: str = f.read()

# Replace template variables
# Build merchant offer HTML rows from template
rows = []
green = '#008000'
light_grey = '#afafaf'
green, light_grey = '#008000', '#afafaf'
html_padding = '<span style="opacity:0;">0</span>'
for product_name, offers in product_offers.items():
row_template = html_template_table_row
row_template = row_template.replace('{{ product }}', product_name)

# Replace merchant offers
lowest_price = min(offers).price
is_sales = any((offer.is_on_special for offer in offers))
merchants_with_offers: set[Merchant] = set()
for offer in offers:
merchant = offer.merchant
price = offer.price if offer.price is not None else 'n/a'
colour = green if is_sales and price == lowest_price else light_grey
zero_padding = '<span style="opacity:0;">0</span>' if len(str(price).split('.')[-1]) == 1 else ''
merchants_with_offers.add(merchant)

html_replacement = f'<a href="{offer.link}" style="color:{colour};text-decoration:inherit;">${price}{zero_padding}</a>'
# Determine text replacement details
price = f'${offer.price}' if offer.price is not None else '-'
colour = green if is_sales and offer.price == lowest_price else light_grey
zero_padding = html_padding if len(price.split('.')[-1]) == 1 else ''

# Insert merchant offer into HTML template
html_replacement = f'<a href="{offer.link}" style="color:{colour};text-decoration:inherit;">{price}{zero_padding}</a>'
row_template = row_template.replace('{{ %(merchant)s_price }}' % {"merchant": merchant}, html_replacement)

# Format email for merchants without offers
for missing_merchant in available_merchants_names.difference(merchants_with_offers):
html_replacement = f'<span style="color:{light_grey};">-<span style="opacity:0;">00</span></span>'
row_template = row_template.replace('{{ %(merchant)s_price }}' % {"merchant": missing_merchant},
html_replacement)

rows.append(row_template)

# Build HTML table of merchant offers
html_template_table = html_template_table.replace('{{ rows }}', ''.join(rows))
html_template = html_template.replace('{{ table }}', html_template_table)

# Add time
# Add timespan to template
year, week, weekday = datetime.datetime.now().isocalendar()
week_start, week_fin = (week - 1, week) if weekday < 3 else (week, week + 1)
start = datetime.datetime.fromisocalendar(year, week_start, 3)
fin = datetime.datetime.fromisocalendar(year, week_fin, 2)
html_template = html_template.replace('{{ intro }}',
html_template = html_template.replace('{{ timespan }}',
f"Deals from {start.strftime('%a %d/%m')} till {fin.strftime('%a %d/%m')}")

# Output formatted template
Expand Down
5 changes: 2 additions & 3 deletions coles_vs_woolies/emailing/mailer_send.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import datetime
import os
from typing import List

from dotenv import load_dotenv, find_dotenv
from dotenv import find_dotenv, load_dotenv
from mailersend import emails

load_dotenv(dotenv_path=find_dotenv())
Expand All @@ -11,7 +10,7 @@


def send(email_html: str,
to_addrs: List[str],
to_addrs: list[str],
from_addr: str = None,
mailersend_api_key: str = None):
"""
Expand Down
4 changes: 2 additions & 2 deletions coles_vs_woolies/emailing/templates/weekly.html
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@
<!--<h1 style="margin-top:0;color:#111111;font-size:24px;line-height:36px;font-weight:600;margin-bottom:24px;" >Welcome, {$name}!</h1>-->

<p
style="color:#4a5566;margin-top:20px;margin-bottom:20px;margin-right:0;margin-left:0;font-size:15px;line-height:28px;">
{{ intro }}
style="color:#4a5566;margin-top:20px;margin-bottom:20px;margin-right:0;margin-left:0;font-size:13px;line-height:28px;">
{{ timespan }}
</p>


Expand Down
10 changes: 6 additions & 4 deletions coles_vs_woolies/examples.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
""" A collection of display examples for product comparisons """

from collections import defaultdict
from typing import Dict, List, Literal
from typing import Literal

from rich import box
from rich.console import Console
from rich.table import Table

from coles_vs_woolies.search.types import ProductOffers, Merchant, Product
from coles_vs_woolies.search.similarity import jaccard_similarity
from coles_vs_woolies.search.types import Merchant, Product, ProductOffers

_console = Console()

Expand All @@ -23,13 +24,14 @@ def compare_offers(product_offers: ProductOffers):
for i, _product in enumerate(products):
txt_colour = 'green' if is_sales and i in cheapest_product_idx else 'grey50'
# txt_colour = None if not i else 'grey50'
_console.print(f' {_product.merchant.upper()}: {_product}', style=txt_colour)
similarity = jaccard_similarity(name, _product.display_name)
_console.print(f' {_product.merchant.upper()}: {_product} | {similarity=:.2f}', style=txt_colour)
_console.print('\n')


def best_offers_by_merchant(product_offers: ProductOffers):
# Collect the cheapest offer
cheapest_products_by_merchant: Dict[Merchant | Literal['either'], List[Product]] = defaultdict(list)
cheapest_products_by_merchant: dict[Merchant | Literal['either'], list[Product]] = defaultdict(list)
for products in product_offers.values():
is_all_same_price = len(set(p.price for p in products)) == 1
if is_all_same_price:
Expand Down
Loading

0 comments on commit 576654f

Please sign in to comment.