Skip to content

Commit 576654f

Browse files
authored
Merge pull request #10 from MattTimms/dev
release: v1.3.0
2 parents 2f0dd32 + 1d88a6f commit 576654f

File tree

18 files changed

+120
-147
lines changed

18 files changed

+120
-147
lines changed

.github/imgs/demo.gif

1.06 MB
Loading

.github/workflows/run.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
MAILERSEND_API_KEY: ${{ secrets.MAILERSEND_API_KEY }}
3838
FROM_ADDRESS: no-reply@${{ secrets.DOMAIN }}
3939
run: |
40-
python coles_vs_woolies send shopping-list.json \
40+
python coles_vs_woolies shopping-list.json \
4141
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--dry_run' || '' }}
4242
local:
4343
needs: github
@@ -62,5 +62,5 @@ jobs:
6262
MAILERSEND_API_KEY: ${{ secrets.MAILERSEND_API_KEY }}
6363
FROM_ADDRESS: no-reply@${{ secrets.DOMAIN }}
6464
run: |
65-
python coles_vs_woolies send shopping-list.json \
65+
python coles_vs_woolies shopping-list.json \
6666
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--dry_run' || '' }}

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
.idea
2-
.github/actions-runner
32
shopping-list.json
43
shopping-list.bkup.json
54

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
# CHANGELOG.md
22

3+
## v1.3.0
4+
5+
* Added support for merchant-exclusive products
6+
* Added Jaccard similarity sorting for better accuracy results
7+
* Dropped Python3.9 support
8+
* Refactored CLI entry
9+
* Fixed missing merchant in email
10+
* Fixed IGA no-results if query >50 chars
11+
* Fixed email formatting for no-offer-merchants
12+
313
## v1.2.0
414

5-
* Added 'iga' merchant
15+
* Added 'iga' merchant
616
* Added CLI import shopping list from txt file
717
* Added CLI import of multiple shopping lists from json file
818

README.md

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 🍎 coles_vs_woolies 🍏
22

3-
![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11-blue)
3+
![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11-blue)
44
[![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)
55
[![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)
66

@@ -54,32 +54,25 @@ pip install -r requirements.txt
5454

5555
```shell
5656
$ python coles_vs_woolies --help
57-
# usage: coles_vs_woolies [-h] {display,send} ...
57+
# usage: coles_vs_woolies [-h] [-o OUT_DIR] [-d] file_path
5858
#
5959
# Compare prices between Aussie grocers
6060
#
6161
# positional arguments:
62-
# {display,send}
63-
# display Display product price comparisons
64-
# send Email product price comparisons
62+
# file_path File path to a JSON config shopping list; see `shopping-list.example.json`
6563
#
6664
# options:
67-
# -h, --help show this help message and exit
68-
#
69-
# example:
70-
# python coles_vs_woolies display
71-
# "Cadbury Dairy Milk Chocolate Block 180g"
72-
# "Connoisseur Ice Cream Vanilla Caramel Brownie 1L"
73-
#
74-
# python coles_vs_woolies send
75-
# "Cadbury Dairy Milk Chocolate Block 180g"
76-
# "Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"
77-
# --to_addrs <me@gmail.com> <you@gmail.com>
65+
# -h, --help show this help message and exit
66+
# -o OUT_DIR, --out_dir OUT_DIR
67+
# Directory for saving copy of the email HTML template.
68+
# -d, --dry_run Disable email delivery
7869
```
7970

8071
```shell
8172
cp .env.example .env
8273
# populate .env to simplify calls
74+
cp shopping-list.example.json shopping-list.json
75+
# populate the shopping list with your email & desired items
8376
```
8477

8578
## Install w/ GitHub Actions

coles_vs_woolies/__main__.py

Lines changed: 22 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,41 @@
11
import argparse
22
import json
3-
import os
4-
import textwrap
53
from argparse import ArgumentParser
6-
from typing import List
74

8-
from pydantic import BaseModel
5+
from pydantic import BaseModel, Extra
96

10-
from coles_vs_woolies.main import display, send
7+
from coles_vs_woolies.main import send
118

129

13-
def cli():
14-
example_usage = '''example:
15-
python coles_vs_woolies display
16-
"Cadbury Dairy Milk Chocolate Block 180g"
17-
"Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"
18-
19-
python coles_vs_woolies send
20-
"Cadbury Dairy Milk Chocolate Block 180g"
21-
"Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"
22-
--to_addrs <me@gmail.com> <you@gmail.com>
23-
'''
10+
class ShoppingList(BaseModel, extra=Extra.allow):
11+
""" Model for the `shopping-list` json config file. """
12+
to_addrs: list[str]
13+
products: list[str]
14+
2415

16+
def cli():
2517
parser = ArgumentParser(
2618
prog='coles_vs_woolies',
2719
description='Compare prices between Aussie grocers',
2820
formatter_class=argparse.RawDescriptionHelpFormatter,
29-
epilog=textwrap.dedent(example_usage)
3021
)
31-
32-
subparsers = parser.add_subparsers(dest='action')
33-
34-
help_product = 'List of descriptive product search terms. Brand, package weight or size should be included. ' \
35-
'Can be file path. E.g. "Cadbury Dairy Milk Chocolate Block 180g"' \
36-
'"Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"'
37-
38-
# Display parser
39-
display_parser = subparsers.add_parser('display', help='Display product price comparisons')
40-
display_parser.add_argument('products', nargs='+', help=help_product)
41-
42-
# Send parser
43-
send_parser = subparsers.add_parser('send', help='Email product price comparisons')
44-
send_parser.add_argument('products', nargs='+', help=help_product)
45-
send_parser.add_argument('-t', '--to_addrs', nargs='+', help="Recipients' email address.", required=False)
46-
send_parser.add_argument('-o', '--out_dir', type=str, help='Directory for saving copy of the email HTML template.',
47-
required=False)
48-
send_parser.add_argument('-d', '--dry_run', action='store_true', help='Disable email delivery',
49-
default=False, required=False)
22+
parser.add_argument('file_path', type=str,
23+
help='File path to a JSON config shopping list; see `shopping-list.example.json`')
24+
parser.add_argument('-o', '--out_dir', type=str, required=False,
25+
help='Directory for saving copy of the email HTML template.')
26+
parser.add_argument('-d', '--dry_run', action='store_true', default=False, required=False,
27+
help='Disable email delivery')
5028

5129
# Parse inputs
5230
kwargs = vars(parser.parse_args())
53-
action = kwargs.pop('action')
54-
55-
_product_inputs = kwargs.pop('products')
56-
if os.path.isfile(fp := _product_inputs[0]) and fp.endswith('.json'):
57-
with open(fp, 'r') as f:
58-
jobs = [_JsonInput.parse_obj(x) for x in json.load(f)]
59-
_ = kwargs.pop('to_addrs', None)
60-
for job in jobs:
61-
_run(action, job.products, to_addrs=job.to_addrs, **kwargs)
62-
else:
63-
if action == 'send' and kwargs.get('to_addrs', None) is None:
64-
parser.error('the following arguments are required: -t/--to_addrs')
65-
products = _parse_product_inputs(_product_inputs)
66-
_run(action, products, **kwargs)
67-
68-
69-
class _JsonInput(BaseModel):
70-
to_addrs: List[str]
71-
products: List[str]
72-
73-
74-
def _run(action: str, products: List[str], **kwargs):
75-
if action == 'send':
76-
send(products=products, **kwargs)
77-
else:
78-
display(products=products)
79-
80-
81-
def _parse_product_inputs(args: List[str]) -> List[str]:
82-
""" Return product list from input list of products/file-paths """
83-
products = []
84-
for input_ in args:
85-
if os.path.isfile(input_):
86-
with open(input_, 'r') as f:
87-
products.extend(f.read().splitlines())
88-
else:
89-
products.append(input_)
90-
return sorted(list(set(products)))
31+
with open(kwargs.pop('file_path'), 'r') as fp:
32+
shopping_lists: list[ShoppingList] = [ShoppingList.parse_obj(list_) for list_ in json.load(fp)]
33+
34+
# Run for each shopping list
35+
for shopping_list in shopping_lists:
36+
send(products=shopping_list.products,
37+
to_addrs=shopping_list.to_addrs,
38+
**kwargs)
9139

9240

9341
if __name__ == '__main__':

coles_vs_woolies/emailing/generate.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import pathlib
21
import datetime
2+
import pathlib
33

44
from rich.console import Console
55
from rich.table import Table
66

7-
from coles_vs_woolies.search.types import ProductOffers
7+
from coles_vs_woolies.search import available_merchants_names
8+
from coles_vs_woolies.search.types import Merchant, ProductOffers
89

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

28-
# Replace template variables
29+
# Build merchant offer HTML rows from template
2930
rows = []
30-
green = '#008000'
31-
light_grey = '#afafaf'
31+
green, light_grey = '#008000', '#afafaf'
32+
html_padding = '<span style="opacity:0;">0</span>'
3233
for product_name, offers in product_offers.items():
3334
row_template = html_template_table_row
3435
row_template = row_template.replace('{{ product }}', product_name)
3536

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

45-
html_replacement = f'<a href="{offer.link}" style="color:{colour};text-decoration:inherit;">${price}{zero_padding}</a>'
45+
# Determine text replacement details
46+
price = f'${offer.price}' if offer.price is not None else '-'
47+
colour = green if is_sales and offer.price == lowest_price else light_grey
48+
zero_padding = html_padding if len(price.split('.')[-1]) == 1 else ''
49+
50+
# Insert merchant offer into HTML template
51+
html_replacement = f'<a href="{offer.link}" style="color:{colour};text-decoration:inherit;">{price}{zero_padding}</a>'
4652
row_template = row_template.replace('{{ %(merchant)s_price }}' % {"merchant": merchant}, html_replacement)
4753

54+
# Format email for merchants without offers
55+
for missing_merchant in available_merchants_names.difference(merchants_with_offers):
56+
html_replacement = f'<span style="color:{light_grey};">-<span style="opacity:0;">00</span></span>'
57+
row_template = row_template.replace('{{ %(merchant)s_price }}' % {"merchant": missing_merchant},
58+
html_replacement)
59+
4860
rows.append(row_template)
4961

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

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

6174
# Output formatted template

coles_vs_woolies/emailing/mailer_send.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import datetime
22
import os
3-
from typing import List
43

5-
from dotenv import load_dotenv, find_dotenv
4+
from dotenv import find_dotenv, load_dotenv
65
from mailersend import emails
76

87
load_dotenv(dotenv_path=find_dotenv())
@@ -11,7 +10,7 @@
1110

1211

1312
def send(email_html: str,
14-
to_addrs: List[str],
13+
to_addrs: list[str],
1514
from_addr: str = None,
1615
mailersend_api_key: str = None):
1716
"""

coles_vs_woolies/emailing/templates/weekly.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@
161161
<!--<h1 style="margin-top:0;color:#111111;font-size:24px;line-height:36px;font-weight:600;margin-bottom:24px;" >Welcome, {$name}!</h1>-->
162162

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

168168

coles_vs_woolies/examples.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
""" A collection of display examples for product comparisons """
22

33
from collections import defaultdict
4-
from typing import Dict, List, Literal
4+
from typing import Literal
55

66
from rich import box
77
from rich.console import Console
88
from rich.table import Table
99

10-
from coles_vs_woolies.search.types import ProductOffers, Merchant, Product
10+
from coles_vs_woolies.search.similarity import jaccard_similarity
11+
from coles_vs_woolies.search.types import Merchant, Product, ProductOffers
1112

1213
_console = Console()
1314

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

2931

3032
def best_offers_by_merchant(product_offers: ProductOffers):
3133
# Collect the cheapest offer
32-
cheapest_products_by_merchant: Dict[Merchant | Literal['either'], List[Product]] = defaultdict(list)
34+
cheapest_products_by_merchant: dict[Merchant | Literal['either'], list[Product]] = defaultdict(list)
3335
for products in product_offers.values():
3436
is_all_same_price = len(set(p.price for p in products)) == 1
3537
if is_all_same_price:

0 commit comments

Comments
 (0)