Skip to content

Commit f33a0c6

Browse files
authored
release: v1.2.0 (#9)
* feat: added flag for dry-run * feat: self-hosted actions * feat: iga support (#7) * wip: adding IGA support n.b. IGA's search is inaccurate * wip: display support * feat: add email support for IGA * fix: various little tweaks * feat: improved IGA search results * refactor: moved merchants around * feat: import shopping list from file * feat: bulk operations with json (#8) * refactor: allow from-addr to be taken from env-vars * feat: support for bulk json args * feat: added session caching for bulk runs * fix: updated GH actions with self-hosted runner fall back * docs: updated docs for release
1 parent 429ff6b commit f33a0c6

27 files changed

+352
-76
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
MAILERSEND_API_KEY=
2+
FROM_ADDRESS=

.github/workflows/run.yml

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ on:
66
- cron: '30 20 * * 2'
77
workflow_dispatch:
88
inputs:
9+
dry_run:
10+
default: true
11+
type: boolean
912

13+
14+
# n.b shocked to learn GH actions don't support yaml anchors (https://github.com/actions/runner/issues/1182)
1015
jobs:
11-
coles_vs_woolies:
16+
github:
1217
runs-on: ubuntu-latest
1318
steps:
1419
- name: Check out repo
@@ -22,16 +27,41 @@ jobs:
2227
env:
2328
timezone: 'Australia/Melbourne'
2429
run: |
25-
sudo timedatectl set-timezone $timezone
2630
pip install -r requirements.txt
27-
- name: Unit Tests
28-
run: python -m unittest
31+
- name: Place shopping list
32+
run: |
33+
echo '${{ secrets.SHOPPING_LIST }}' >> shopping-list.json
34+
- name: Email comparisons
35+
env:
36+
MAILERSEND_API_KEY: ${{ secrets.MAILERSEND_API_KEY }}
37+
FROM_ADDRESS: no-reply@${{ secrets.DOMAIN }}
38+
run: |
39+
python coles_vs_woolies send shopping-list.json \
40+
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--dry_run' || '' }}
41+
local:
42+
needs: github
43+
if: always() && contains(needs.*.result, 'failure')
44+
runs-on: self-hosted
45+
steps:
46+
- name: Check out repo
47+
uses: actions/checkout@v3
48+
- name: Setup Python
49+
uses: actions/setup-python@v4
50+
with:
51+
python-version: '3.10'
52+
cache: 'pip'
53+
- name: Install dependencies
54+
env:
55+
timezone: 'Australia/Melbourne'
56+
run: |
57+
pip install -r requirements.txt
58+
- name: Place shopping list
59+
run: |
60+
echo '${{ secrets.SHOPPING_LIST }}' >> shopping-list.json
2961
- name: Email comparisons
3062
env:
3163
MAILERSEND_API_KEY: ${{ secrets.MAILERSEND_API_KEY }}
64+
FROM_ADDRESS: no-reply@${{ secrets.DOMAIN }}
3265
run: |
33-
python coles_vs_woolies send \
34-
${{ vars.GROCERY_LIST }} \
35-
--to_addrs ${{ inputs.to_addr || secrets.TO_ADDRS }} \
36-
--from_addr no-reply@${{ secrets.DOMAIN }}
37-
66+
python coles_vs_woolies send shopping-list.json \
67+
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--dry_run' || '' }}

.github/workflows/test.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
name: Tests
22

33
on:
4+
workflow_dispatch:
45
push:
56
branches:
67
- 'main'
78

89
jobs:
910
test:
10-
runs-on: ubuntu-latest
11+
runs-on: self-hosted
1112
strategy:
1213
matrix:
1314
python-version: [ "3.9", "3.10", "3.11" ]

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
.idea
2+
.github/actions-runner
3+
shopping-list.json
4+
shopping-list.bkup.json
25

36
# Byte-compiled / optimized / DLL files
47
__pycache__/

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# CHANGELOG.md
22

3+
## v1.2.0
4+
5+
* Added 'iga' merchant
6+
* Added CLI import shopping list from txt file
7+
* Added CLI import of multiple shopping lists from json file
8+
39
## v1.1.0 - 26/01/2023
410

511
* Added `bcc` when sending to multiple recipients

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
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.9%20%7C%203.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

7+
🍅 `iga` now supported.
8+
79
Receive an email every week comparing the price of products you buy often.
810

911
Scrap Aussie grocers' public APIs, schedule a GitHub Action to run weekly, and
@@ -73,15 +75,23 @@ $ python coles_vs_woolies --help
7375
# "Cadbury Dairy Milk Chocolate Block 180g"
7476
# "Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"
7577
# --to_addrs <me@gmail.com> <you@gmail.com>
76-
# --from_addr <no-reply@domain.com>
77-
# --mailersend_api_key=<MAILERSEND_API_KEY>
78+
```
79+
80+
```shell
81+
cp .env.example .env
82+
# populate .env to simplify calls
7883
```
7984

8085
## Install w/ GitHub Actions
8186

87+
n.b. I found sporadic success with GitHub-hosted runners; I would recommend setting
88+
up [self-hosted GitHub runners](https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners)
89+
for consistent success if you wished to continue using GitHub actions rather than running a simple cron job.
90+
8291
1. Fork this repo
8392
2. Read the GitHub Action workflow [run.yml](.github/workflows/run.yml)
8493
3. Add GitHub Action Variables & Secrets for those in the [run.yml](.github/workflows/run.yml)
94+
- n.b. storing a `shopping-list.json` as a minified json string should do the job
8595
4. Manually invoke the GitHub Action & confirm an email was received
8696

8797
## Getting the best results

coles_vs_woolies/__main__.py

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import argparse
2+
import json
3+
import os
24
import textwrap
35
from argparse import ArgumentParser
6+
from typing import List
7+
8+
from pydantic import BaseModel
49

510
from coles_vs_woolies.main import display, send
611

@@ -15,8 +20,6 @@ def cli():
1520
"Cadbury Dairy Milk Chocolate Block 180g"
1621
"Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"
1722
--to_addrs <me@gmail.com> <you@gmail.com>
18-
--from_addr <no-reply@domain.com>
19-
--mailersend_api_key=<MAILERSEND_API_KEY>
2023
'''
2124

2225
parser = ArgumentParser(
@@ -27,31 +30,65 @@ def cli():
2730
)
2831

2932
subparsers = parser.add_subparsers(dest='action')
30-
display_parser = subparsers.add_parser('display', help='Display product price comparisons')
31-
send_parser = subparsers.add_parser('send', help='Email product price comparisons')
3233

33-
help_product = 'List of descriptive product search terms. Brand, package weight or size should be included. E.g. ' \
34-
'"Cadbury Dairy Milk Chocolate Block 180g" "Connoisseur Ice Cream Vanilla Caramel Brownie 4 Pack"'
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')
3540
display_parser.add_argument('products', nargs='+', help=help_product)
36-
send_parser.add_argument('products', nargs='+', help=help_product)
3741

38-
send_parser.add_argument('-t', '--to_addrs', nargs='+', help="Recipients' email address.", required=True)
39-
send_parser.add_argument('-f', '--from_addr', type=str,
40-
help="Sender's email address. Domain must match that verified with MailerSend.",
41-
required=True)
42-
send_parser.add_argument('-m', '--mailersend_api_key', type=str, help='MailerSend API key.', required=False)
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)
4346
send_parser.add_argument('-o', '--out_dir', type=str, help='Directory for saving copy of the email HTML template.',
4447
required=False)
48+
send_parser.add_argument('-d', '--dry_run', action='store_true', help='Disable email delivery',
49+
default=False, required=False)
4550

46-
args = vars(parser.parse_args())
51+
# Parse inputs
52+
kwargs = vars(parser.parse_args())
53+
action = kwargs.pop('action')
4754

48-
action = args.pop('action')
49-
products = sorted(list(set(args.pop('products'))))
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):
5075
if action == 'send':
51-
send(products=products, **args)
76+
send(products=products, **kwargs)
5277
else:
5378
display(products=products)
5479

5580

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)))
91+
92+
5693
if __name__ == '__main__':
5794
cli()

coles_vs_woolies/emailing/generate.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,30 +21,34 @@ def generate_weekly_email(product_offers: ProductOffers, out_path: str = None) -
2121
with open(_TEMPLATE_DIR / 'weekly.html', 'r', encoding="utf-8") as f:
2222
html_template = f.read()
2323
with open(_TEMPLATE_DIR / 'snippets/table.html', 'r', encoding="utf-8") as f:
24-
html_table = f.read()
24+
html_template_table = f.read()
2525
with open(_TEMPLATE_DIR / 'snippets/table_row.html', 'r', encoding="utf-8") as f:
26-
html_table_row: str = f.read()
26+
html_template_table_row: str = f.read()
2727

2828
# Replace template variables
2929
rows = []
3030
green = '#008000'
3131
light_grey = '#afafaf'
3232
for product_name, offers in product_offers.items():
33-
row_ = html_table_row
34-
row_ = row_.replace('{{ product }}', product_name)
33+
row_template = html_template_table_row
34+
row_template = row_template.replace('{{ product }}', product_name)
35+
36+
# Replace merchant offers
3537
lowest_price = min(offers).price
3638
is_sales = any((offer.is_on_special for offer in offers))
3739
for offer in offers:
3840
merchant = offer.merchant
3941
price = offer.price if offer.price is not None else 'n/a'
4042
colour = green if is_sales and price == lowest_price else light_grey
41-
row_ = row_.replace('{{ %(merchant)s_price }}' % {"merchant": merchant},
42-
f'<a href="{offer.link}" style="color:{colour};text-decoration:inherit;">${price}</a>')
43+
zero_padding = '<span style="opacity:0;">0</span>' if len(str(price).split('.')[-1]) == 1 else ''
44+
45+
html_replacement = f'<a href="{offer.link}" style="color:{colour};text-decoration:inherit;">${price}{zero_padding}</a>'
46+
row_template = row_template.replace('{{ %(merchant)s_price }}' % {"merchant": merchant}, html_replacement)
4347

44-
rows.append(row_)
48+
rows.append(row_template)
4549

46-
html_table = html_table.replace('{{ rows }}', ''.join(rows))
47-
html_template = html_template.replace('{{ table }}', html_table)
50+
html_template_table = html_template_table.replace('{{ rows }}', ''.join(rows))
51+
html_template = html_template.replace('{{ table }}', html_template_table)
4852

4953
# Add time
5054
year, week, weekday = datetime.datetime.now().isocalendar()

coles_vs_woolies/emailing/mailer_send.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55
from dotenv import load_dotenv, find_dotenv
66
from mailersend import emails
77

8+
load_dotenv(dotenv_path=find_dotenv())
9+
_MAILERSEND_API_KEY = os.getenv('MAILERSEND_API_KEY')
10+
_FROM_ADDR = os.getenv('FROM_ADDRESS')
811

9-
def send(email_html: str, to_addrs: List[str], from_addr: str, mailersend_api_key: str = None):
12+
13+
def send(email_html: str,
14+
to_addrs: List[str],
15+
from_addr: str = None,
16+
mailersend_api_key: str = None):
1017
"""
1118
Send an email with MailerSend.
1219
:param email_html: email html template or file path to template.
@@ -15,17 +22,19 @@ def send(email_html: str, to_addrs: List[str], from_addr: str, mailersend_api_ke
1522
:param mailersend_api_key: API key for MailerSend. If not given, pulled from env-vars.
1623
:return:
1724
"""
25+
# Validate MailerSend requirements
26+
from_addr = from_addr or _FROM_ADDR
27+
if from_addr is None:
28+
raise ValueError("MailerSend `from_addr` not provided")
29+
mailersend_api_key = mailersend_api_key or _MAILERSEND_API_KEY
30+
if mailersend_api_key is None:
31+
raise ValueError("MailerSend API Key not provided")
32+
1833
# If file path, load email html template
1934
if os.path.isfile(email_html):
2035
with open(email_html, 'r', encoding='utf-8') as fp:
2136
email_html = fp.read()
2237

23-
# Setup MailerSend email
24-
if mailersend_api_key is None:
25-
load_dotenv(dotenv_path=find_dotenv())
26-
mailersend_api_key = os.getenv('MAILERSEND_API_KEY')
27-
if mailersend_api_key is None:
28-
raise ValueError("MailerSend API Key not provided")
2938
mailer = emails.NewEmail(mailersend_api_key)
3039

3140
# Define an empty dict to populate with mail values

coles_vs_woolies/emailing/templates/snippets/table.html

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
22
<tr>
33
<td
4-
style="padding-top:10px;padding-bottom:20px;padding-right:0;padding-left:0;word-break:break-word;font-family:'Inter', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;">
4+
style="
5+
padding: 10px 0 20px;
6+
word-break:break-word;
7+
font-size:16px;
8+
line-height:24px;"
9+
>
510
<table class="table" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
611
<tr>
7-
<th align="left"
8-
style="font-family:'Inter', Helvetica, Arial, sans-serif;padding-top:10px;padding-bottom:10px;padding-right:0;padding-left:0;color:#85878E;font-size:13px;font-weight:600;line-height:18px;">
12+
<th align="left" class="product-table-header">
913
Products
1014
</th>
11-
<th align="right"
12-
style="font-family:'Inter', Helvetica, Arial, sans-serif;padding-top:10px;padding-bottom:10px;padding-right:0;padding-left:0;color:#85878E;font-size:13px;font-weight:600;line-height:18px;width:60px;">
15+
<th align="right" class="product-table-header" style="width:60px;">
1316
Coles
1417
</th>
15-
<th align="right"
16-
style="font-family:'Inter', Helvetica, Arial, sans-serif;padding-top:10px;padding-bottom:10px;padding-right:0;padding-left:0;color:#85878E;font-size:13px;font-weight:600;line-height:18px;width:60px;">
18+
<th align="right" class="product-table-header" style="width:60px;">
1719
Woolies
1820
</th>
21+
<th align="middle" class="product-table-header" style="width:60px;">
22+
IGA
23+
</th>
1924
</tr>
2025
{{ rows }}
2126
</table>

0 commit comments

Comments
 (0)