diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml new file mode 100644 index 0000000..9f5ab1d --- /dev/null +++ b/.github/workflows/screenshots.yml @@ -0,0 +1,50 @@ +name: Update chart screenshots + +on: + workflow_dispatch: + inputs: + corporate_travel: + description: Include corporate-travel (requires the external source to respond) + type: boolean + default: true + +permissions: + contents: write + pull-requests: write + +concurrency: + group: chart-screenshots + cancel-in-progress: false + +jobs: + render: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + cache: pip + - run: python -m pip install -r requirements.txt + - name: Render charts + env: + INCLUDE_CORPORATE_TRAVEL: ${{ inputs.corporate_travel }} + run: | + if [ "$INCLUDE_CORPORATE_TRAVEL" = "true" ]; then + python render.py + else + python render.py --charts pay food richest-by-age store-sales + fi + - uses: peter-evans/create-pull-request@v8 + with: + branch: automated/chart-screenshots + commit-message: Update chart screenshots + title: Update chart screenshots + body: | + Regenerated with render.py on Python 3.12. + Corporate travel included: ${{ inputs.corporate_travel }}. + Please review the image changes before merging. + add-paths: | + */screenshot.png + pay/zoomed.png diff --git a/README.md b/README.md index 666d701..eec242a 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,30 @@ Company Data + +## Regenerate screenshots + +Use Python 3.12, install `requirements.txt`, then run `python render.py`. +This saves PNGs without opening a window, including the pay detail view. +The scripts still support interactive use when run individually. + +`corporate-travel` fetches 100 company pages. Requests have timeouts and HTTP +or missing-data failures stop the run before any existing screenshots are +replaced. The source can deny automated requests (HTTP 403); resolve source +access before retrying. To explicitly render only local datasets, use: + +``` +python render.py --charts pay food richest-by-age store-sales +``` + +Use `--output-dir /path/to/previews` to inspect outputs elsewhere first. +The charts retain their original historical datasets; regeneration does not +make them current. The pay detail view uses x=85..753 and y=63..248 to approximate +the existing screenshot's visible region. + +In GitHub Actions, manually run **Update chart screenshots**. It renders all +charts by default; uncheck `corporate_travel` to leave that image unchanged. +Successful runs create or update a PR on `automated/chart-screenshots` when +images differ. Enable **Allow GitHub Actions to create and approve pull +requests** in the repository's Actions settings. This workflow opens PRs; +it does not approve or merge them. diff --git a/corporate-travel/main.py b/corporate-travel/main.py index 597b288..c82a1bf 100644 --- a/corporate-travel/main.py +++ b/corporate-travel/main.py @@ -110,9 +110,14 @@ for line in s.splitlines(): if 'McKinsey' in line: line += ' ' - response = SESSION.get("https://www.businesstravelnews.com/Corporate-Travel-100/2018/" + re.sub('\s*&\s*', '-', line).replace(' ', '-').replace('.', '').replace('Intel', 'Intel1')).text + response = SESSION.get("https://www.businesstravelnews.com/Corporate-Travel-100/2018/" + re.sub(r'\s*&\s*', '-', line).replace(' ', '-').replace('.', '').replace('Intel', 'Intel1'), timeout=(10, 30)) + response.raise_for_status() + response = response.text volumeStrings = [line for line in response.splitlines() if "Volume" in line] - volumePerCompany[line] = float(re.search("\$(.*?) million", volumeStrings[0]).group(1)) + match = re.search(r'\$(.*?) million', volumeStrings[0]) if volumeStrings else None + if match is None: + raise ValueError(f'No air volume found for {line}') + volumePerCompany[line] = float(match.group(1)) plt.figure(figsize=(8, 15)) plt.barh(*zip(*OrderedDict(sorted(volumePerCompany.items(), key=itemgetter(1))).items())) diff --git a/render.py b/render.py new file mode 100644 index 0000000..041160f --- /dev/null +++ b/render.py @@ -0,0 +1,46 @@ +"""Render charts without a display; publish files only after every chart succeeds.""" +import argparse +from pathlib import Path +import runpy +import shutil +import tempfile + +import matplotlib + +matplotlib.use('Agg') +import matplotlib.pyplot as plt + +ROOT = Path(__file__).resolve().parent +CHARTS = ('pay', 'food', 'richest-by-age', 'store-sales', 'corporate-travel') + + +def render(charts, output): + with tempfile.TemporaryDirectory() as directory: + staged = Path(directory) + for chart in charts: + target = staged / chart + target.mkdir(exist_ok=True) + try: + runpy.run_path(str(ROOT / chart / 'main.py'), run_name='__main__') + plt.savefig(target / 'screenshot.png') + if chart == 'pay': + # Reproduce the detail view in the existing pay/zoomed.png. + plt.gcf().set_size_inches(20.48, 10.24) + plt.xlim(85, 753) + plt.ylim(63, 248) + plt.savefig(target / 'zoomed.png') + finally: + plt.close('all') + for source in sorted(staged.glob('*/*.png')): + destination = output / source.relative_to(staged) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + print(destination) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--charts', nargs='+', choices=CHARTS, default=CHARTS) + parser.add_argument('--output-dir', type=Path, default=ROOT) + args = parser.parse_args() + render(args.charts, args.output_dir) diff --git a/requirements.txt b/requirements.txt index d781d3e..987192e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -requests==2.22.0 -matplotlib==3.1.1 +requests==2.34.2 +matplotlib==3.11.1 diff --git a/test_render.py b/test_render.py new file mode 100644 index 0000000..e409eae --- /dev/null +++ b/test_render.py @@ -0,0 +1,36 @@ +import tempfile +from pathlib import Path +from unittest.mock import patch + +import matplotlib.pyplot as plt + +from render import render + + +def test_failed_chart_preserves_existing_outputs(): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + previous = output / 'pay' / 'screenshot.png' + previous.parent.mkdir() + previous.write_bytes(b'previous screenshot') + + def run_chart(path, **kwargs): + if 'food' in path: + raise ValueError('source unavailable') + plt.plot([1, 2], [3, 4]) + + with patch('render.runpy.run_path', side_effect=run_chart): + try: + render(('pay', 'food'), output) + except ValueError as error: + assert str(error) == 'source unavailable' + else: + raise AssertionError('Failed chart must fail the rendering run') + assert previous.read_bytes() == b'previous screenshot' + assert not (output / 'pay' / 'zoomed.png').exists() + assert not (output / 'food').exists() + + +if __name__ == '__main__': + test_failed_chart_preserves_existing_outputs() + print('Failed chart preserves existing outputs: passed')