From 13e18f4ce65ae75121f162c44ff3b0674353110a Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 30 May 2026 19:13:07 -0700 Subject: [PATCH 1/5] Modernize packaging and prepare drawcal for PyPI --- LICENSE | 6 ++-- README.md | 26 ++++++++++++-- bin/drawcal | 4 +-- lib/drawcal/__init__.py | 12 ++++--- lib/drawcal/cli.py | 8 +++-- lib/drawcal/config.py | 2 +- lib/drawcal/drawlib.py | 19 ++++++---- lib/drawcal/events.py | 35 ++++++++++++------ lib/drawcal/test.py | 69 ----------------------------------- pyproject.toml | 59 ++++++++++++++++++++++++++++++ requirements.txt | 2 -- setup.py | 79 ----------------------------------------- tests/test_events.py | 36 +++++++++++++++++++ tests/test_package.py | 12 +++++++ 14 files changed, 187 insertions(+), 182 deletions(-) delete mode 100644 lib/drawcal/test.py create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 tests/test_events.py create mode 100644 tests/test_package.py diff --git a/LICENSE b/LICENSE index 43e9b83..4f39123 100644 --- a/LICENSE +++ b/LICENSE @@ -1,12 +1,12 @@ -envstack +drawcal -Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) +Copyright (c) 2022-2025, Bnbnotify All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - * Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + * Neither the name of Bnbnotify nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 1a5ad4e..9886d36 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,24 @@ Python library for drawing simple monthly calendar images with events. ## Installation -The easiest way to install: +Install from PyPI: ```bash $ pip install -U drawcal ``` -Alternatively, to install with distman: +Build locally: ```bash -$ dist [-d] +$ python -m pip install --upgrade build +$ python -m build +``` + +Upload to PyPI: + +```bash +$ python -m pip install --upgrade twine +$ python -m twine upload dist/* ``` ## Quickstart @@ -33,3 +41,15 @@ Python: >>> from drawcal import draw_calendar >>> draw_calendar(month, year, events=events, outfile=outfile) ``` + +## Events format + +`drawcal` expects a JSON file containing a list of events, where each event is a +list of dates in `M/D/YYYY` format: + +```json +[ + ["3/1/2025", "3/2/2025", "3/3/2025"], + ["3/14/2025", "3/15/2025"] +] +``` diff --git a/bin/drawcal b/bin/drawcal index dc532c2..d65c0d0 100755 --- a/bin/drawcal +++ b/bin/drawcal @@ -1,6 +1,6 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -# Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2022-2025, Bnbnotify # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/drawcal/__init__.py b/lib/drawcal/__init__.py index 3ec8fdc..10d95bb 100644 --- a/lib/drawcal/__init__.py +++ b/lib/drawcal/__init__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2022-2025, Bnbnotify # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -34,11 +34,13 @@ """ __prog__ = "drawcal" -__version__ = "0.5.6" +__version__ = "0.5.7" __author__ = "ryan@rsgalloway.com" -import envstack -envstack.init(__prog__) +def draw_calendar(*args, **kwargs): + """Lazily import the renderer so package metadata stays cheap to import.""" -from drawcal.drawlib import draw_calendar + from drawcal.drawlib import draw_calendar as _draw_calendar + + return _draw_calendar(*args, **kwargs) diff --git a/lib/drawcal/cli.py b/lib/drawcal/cli.py index 3b9d0d8..381a5dd 100755 --- a/lib/drawcal/cli.py +++ b/lib/drawcal/cli.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2022-2025, Bnbnotify # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -37,7 +37,6 @@ from datetime import datetime, timedelta from drawcal import __prog__, __version__ -from drawcal.drawlib import draw_calendar from drawcal.events import get_events, read_events d = datetime.today() @@ -92,6 +91,9 @@ def parse_args(): def main(): """Main event loop.""" + import envstack + + envstack.init(__prog__) args = parse_args() @@ -100,6 +102,8 @@ def main(): else: events = get_events(args.month, args.year) + from drawcal.drawlib import draw_calendar + draw_calendar(month=args.month, year=args.year, events=events, outfile=args.outfile) return 0 diff --git a/lib/drawcal/config.py b/lib/drawcal/config.py index 936f630..68f3661 100644 --- a/lib/drawcal/config.py +++ b/lib/drawcal/config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2022-2025, Bnbnotify # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: diff --git a/lib/drawcal/drawlib.py b/lib/drawcal/drawlib.py index 96fe00d..be0ee77 100644 --- a/lib/drawcal/drawlib.py +++ b/lib/drawcal/drawlib.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2022-2025, Bnbnotify # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -68,6 +68,13 @@ class colors: title_text = "#101010" # month/year text color +def _text_size(draw, text, font): + """Return text width/height across Pillow versions.""" + + left, top, right, bottom = draw.textbbox((0, 0), text, font=font) + return right - left, bottom - top + + def draw_calendar( month=today.month, year=today.year, @@ -106,7 +113,7 @@ def draw_calendar( pad = 20 # make sure events is a list - if events == None: + if events is None: events = [] # categorize and track dates @@ -133,7 +140,7 @@ def draw_calendar( # draw the month and year font = ImageFont.truetype(config.ARIAL_TTF_FILE, size=15) - header_w, header_h = draw.textsize(header, font=font) + header_w, header_h = _text_size(draw, header, font) draw.text( ((width - header_w) / 2, pad / 2), header, fill=colors.title_text, font=font ) @@ -233,9 +240,9 @@ def draw_calendar( continue # handle each day in event - if first_day == col: + if first_day == curr_day: s = 0 - if last_day == col: + if last_day == curr_day: e = 22 # check-in @@ -332,7 +339,7 @@ def draw_calendar( # draw days of the week and date numbers col_font = ImageFont.truetype(config.ARIAL_TTF_FILE, size=12) - col_w, col_h = draw.textsize(col, font=col_font) + col_w, col_h = _text_size(draw, col, col_font) # set date text color if past_date: diff --git a/lib/drawcal/events.py b/lib/drawcal/events.py index 3ae77f6..87de282 100644 --- a/lib/drawcal/events.py +++ b/lib/drawcal/events.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) +# Copyright (c) 2022-2025, Bnbnotify # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -34,6 +34,7 @@ """ import json +from calendar import monthrange from datetime import datetime, timedelta d = datetime.today() @@ -47,17 +48,20 @@ def get_events(month=today.month, year=today.year): from random import randint events = [] + last_day = monthrange(year, month)[1] num_events = randint(2, 8) i = 1 for _ in range(2, num_events): event = [] for dd in range(i, randint(i + 2, i + randint(3, 8))): + if dd > last_day: + break event.append(f"{month}/{dd}/{year}") i += 1 - if i >= 31: + if i > last_day: break - if i >= 31: + if i > last_day: break i += randint(1, 10) if event: @@ -74,13 +78,24 @@ def get_events(month=today.month, year=today.year): def read_events(filepath): """Returns JSON serialized events from a given filepath.""" - events = [] - try: - fp = open(filepath) - events = json.load(fp) - fp.close() - except Exception as e: - print(e) + with open(filepath, encoding="utf-8") as fp: + events = json.load(fp) + except OSError as exc: + raise OSError(f"unable to read events file: {filepath}") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON in events file: {filepath}") from exc + + if not isinstance(events, list): + raise ValueError("events file must contain a list of event lists") + + for event in events: + if not isinstance(event, list): + raise ValueError("each event must be a list of date strings") + for day in event: + try: + datetime.strptime(day, "%m/%d/%Y") + except ValueError as exc: + raise ValueError(f"invalid event date: {day}") from exc return events diff --git a/lib/drawcal/test.py b/lib/drawcal/test.py deleted file mode 100644 index 40902a6..0000000 --- a/lib/drawcal/test.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python -# -# Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# - Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# - Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# - Neither the name of the software nor the names of its contributors -# may be used to endorse or promote products derived from this software -# without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# - -__doc__ = """ -contains drawcal unit tests. -""" - -import unittest - - -# TODO: move these test events to separate json files in a tests folder -def test_events(): - """Returns a list of test events.""" - - # events = [] - # events = [['2/1/2022', '2/2/2022'], ['2/10/2022', '2/11/2022'], ['2/16/2022', '2/17/2022', '2/18/2022', '2/19/2022', '2/20/2022', '2/21/2022'], ['2/23/2022', '2/24/2022']] - # events = [['1/28/2022', '1/29/2022', '1/30/2022', '1/31/2022'], ['2/1/2022', '2/2/2022', '2/3/2022', '2/4/2022', '2/5/2022'], ['2/7/2022', '2/8/2022', '2/9/2022', '2/10/2022'], ['2/13/2022', '2/14/2022', '2/15/2022', '2/16/2022'], ['2/24/2022', '2/25/2022'], ['2/28/2022', '3/1/2022']] - # events = [['2/1/2022', '2/2/2022', '2/3/2022', '2/4/2022'], ['2/8/2022', '2/9/2022'], ['2/10/2022', '2/11/2022'], ['2/14/2022', '2/15/2022', '2/16/2022', '2/17/2022', '2/18/2022'], ['2/24/2022', '2/25/2022', '2/26/2022']] - # events = [['2/22/2022','2/23/2022','2/24/2022'], ['2/25/2022','2/26/2022','2/27/2022']] - # events = [['2/1/2022', '2/2/2022', '2/3/2022', '2/4/2022', '2/5/2022'], ['2/13/2022', '2/14/2022', '2/15/2022', '2/16/2022'], ['2/19/2022', '2/20/2022', '2/21/2022', '2/22/2022', '2/23/2022', '2/24/2022']] - # events = [['3/22/2022', '3/23/2022', '3/24/2022'], ['3/25/2022', '3/26/2022', '3/27/2022']] - # events = [['1/1/2022', '1/2/2022', '1/3/2022'], ['1/12/2022', '1/13/2022', '1/14/2022', '1/15/2022'], ['1/24/2022', '1/25/2022']] - # events = [['2/1/2022', '2/2/2022', '2/3/2022', '2/4/2022', '2/5/2022'], ['2/12/2022', '2/13/2022', '2/14/2022', '2/15/2022', '2/16/2022', '2/17/2022'], ['2/24/2022', '2/25/2022', '2/26/2022']] - - # contains a fake date: 2/29/2022 - # events = [['2/1/2022', '2/2/2022'], ['2/27/2022', '2/28/2022', '2/29/2022']] - - # contains conflicting events - # events = [['3/1/2022', '3/2/2022', '3/3/2022'], ['3/2/2022', '3/3/2022', '3/4/2022']] - - # issue no. 1 - # events = [['5/29/2022', '5/30/2022', '5/31/2022'], ['6/3/2022', '6/4/2022']] - - # gaps in events - events = [["6/3/2022", "6/4/2022", "6/5/2022", "6/6/2022", "6/7/2022"]] - - return events - - -if __name__ == "__main__": - unittest.main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..87447cd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "drawcal" +version = "0.5.7" +description = "Python library for drawing simple monthly calendar images with events." +readme = "README.md" +requires-python = ">=3.8" +license = {file = "LICENSE"} +authors = [ + {name = "Ryan Galloway", email = "ryan@rsgalloway.com"}, +] +keywords = ["calendar", "image", "pillow", "png"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Graphics", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "Pillow>=10.0.0", + "envstack>=0.8.3", +] + +[project.optional-dependencies] +dev = [ + "black>=24.0.0", + "build", + "pytest>=8.0.0", + "twine", +] + +[project.urls] +Homepage = "https://github.com/bnbnotify/drawcal" +Repository = "https://github.com/bnbnotify/drawcal" + +[project.scripts] +drawcal = "drawcal.cli:main" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["lib"] + +[tool.setuptools.package-dir] +"" = "lib" + +[tool.setuptools.package-data] +drawcal = ["arial.ttf"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6349bd7..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Pillow==9.0.1 -envstack>=0.8.3 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 03a9534..0000000 --- a/setup.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python -# -# Copyright (c) 2022-2025, Ryan Galloway (ryan@rsgalloway.com) -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# - Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# -# - Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# - Neither the name of the software nor the names of its contributors -# may be used to endorse or promote products derived from this software -# without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# - -import os -from setuptools import setup, find_packages - -here = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(here, "README.md")) as f: - long_description = f.read() - -setup( - name="drawcal", - version="0.5.6", - author="Ryan Galloway", - author_email="ryan@rsgalloway.com", - url="https://github.com/bnbnotify/drawcal", - license="BSD 3-Clause License", - classifiers=[ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - ], - install_requires=["Pillow==9.0.1", "envstack>=0.8.3"], - packages=find_packages("lib"), - package_dir={"": "lib"}, - package_data = { - "": ["*.py", "*.ttf"], - }, - python_requires='>=3.6', - scripts=["bin/drawcal"], - entry_points={ - "console_scripts": [ - "drawcal=drawcal.cli:main", - ], - }, - data_files=[ - ("drawcal", ["lib/drawcal/arial.ttf"]) - ], - zip_safe=False, -) diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..801968d --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,36 @@ +import json +import tempfile +import unittest +from datetime import datetime + +from drawcal.events import get_events, read_events + + +class ReadEventsTests(unittest.TestCase): + def test_read_events_rejects_invalid_json(self): + with tempfile.NamedTemporaryFile("w+", suffix=".json") as handle: + handle.write("{not json}") + handle.flush() + + with self.assertRaises(ValueError): + read_events(handle.name) + + def test_read_events_rejects_invalid_dates(self): + with tempfile.NamedTemporaryFile("w+", suffix=".json") as handle: + json.dump([["2/30/2025"]], handle) + handle.flush() + + with self.assertRaises(ValueError): + read_events(handle.name) + + +class GetEventsTests(unittest.TestCase): + def test_get_events_stays_within_month(self): + events = get_events(month=2, year=2025) + + for event in events: + for day in event: + parsed = datetime.strptime(day, "%m/%d/%Y") + self.assertEqual(parsed.month, 2) + self.assertEqual(parsed.year, 2025) + self.assertLessEqual(parsed.day, 28) diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..018664b --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,12 @@ +import unittest + +import drawcal + + +class PackageImportTests(unittest.TestCase): + def test_package_metadata_is_importable_without_renderer(self): + self.assertEqual(drawcal.__prog__, "drawcal") + self.assertRegex(drawcal.__version__, r"^\d+\.\d+\.\d+$") + + def test_draw_calendar_symbol_is_exposed(self): + self.assertTrue(callable(drawcal.draw_calendar)) From 24f979051a4057a89c8750d6e1ea68e63e71f2ee Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 30 May 2026 19:15:35 -0700 Subject: [PATCH 2/5] Adds github test matrix --- .github/workflows/tests.yml | 30 ++++++++++++++++++++++++++++++ .gitignore | 4 +++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..38c9cf6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: python -m pip install --upgrade pip && pip install -e ".[dev]" + + - name: Run tests + run: pytest diff --git a/.gitignore b/.gitignore index 3a79ef5..0a521b4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ __pycache__ build/ dist/ *.egg-info/ -tmp/ \ No newline at end of file +tmp/ +.codex* +.agents* \ No newline at end of file From 8bd0a04f9aef0496f91cb8583f0e1f21671dc4ec Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 30 May 2026 19:36:13 -0700 Subject: [PATCH 3/5] Add deterministic fixture-based render regression tests --- lib/drawcal/drawlib.py | 3 --- tests/fixtures/march-2025-events.json | 22 +++++++++++++++ tests/fixtures/march-2025-expected.png | Bin 0 -> 9634 bytes tests/test_render.py | 36 +++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/march-2025-events.json create mode 100644 tests/fixtures/march-2025-expected.png create mode 100644 tests/test_render.py diff --git a/lib/drawcal/drawlib.py b/lib/drawcal/drawlib.py index be0ee77..67c7427 100644 --- a/lib/drawcal/drawlib.py +++ b/lib/drawcal/drawlib.py @@ -242,9 +242,6 @@ def draw_calendar( # handle each day in event if first_day == curr_day: s = 0 - if last_day == curr_day: - e = 22 - # check-in if first_day == curr_day: checkin = True diff --git a/tests/fixtures/march-2025-events.json b/tests/fixtures/march-2025-events.json new file mode 100644 index 0000000..4689d6b --- /dev/null +++ b/tests/fixtures/march-2025-events.json @@ -0,0 +1,22 @@ +[ + [ + "3/1/2025", + "3/2/2025", + "3/3/2025", + "3/4/2025", + "3/5/2025" + ], + [ + "3/12/2025", + "3/13/2025", + "3/14/2025", + "3/15/2025", + "3/16/2025", + "3/17/2025" + ], + [ + "3/24/2025", + "3/25/2025", + "3/26/2025" + ] +] \ No newline at end of file diff --git a/tests/fixtures/march-2025-expected.png b/tests/fixtures/march-2025-expected.png new file mode 100644 index 0000000000000000000000000000000000000000..acb7c8a197e82b51abd0111554dcb82b5a7c1679 GIT binary patch literal 9634 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 literal 0 HcmV?d00001 diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..e9e1f37 --- /dev/null +++ b/tests/test_render.py @@ -0,0 +1,36 @@ +import json +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from unittest import mock + +from PIL import Image, ImageChops + +from drawcal.drawlib import draw_calendar +import drawcal.drawlib as drawlib + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +EXPECTED_IMAGE = FIXTURES_DIR / "march-2025-expected.png" +EVENTS_FILE = FIXTURES_DIR / "march-2025-events.json" +FIXED_TODAY = datetime.strptime("1/1/2026", "%m/%d/%Y") + + +class RenderTests(unittest.TestCase): + def test_draw_calendar_matches_expected_image(self): + events = json.loads(EVENTS_FILE.read_text(encoding="utf-8")) + + with tempfile.TemporaryDirectory() as tmpdir: + outfile = Path(tmpdir) / "render.png" + + with mock.patch.object(drawlib, "today", FIXED_TODAY), mock.patch.object( + drawlib, "today_str", "1/1/2026" + ): + draw_calendar(month=3, year=2025, events=events, outfile=str(outfile)) + + expected = Image.open(EXPECTED_IMAGE) + actual = Image.open(outfile) + + diff = ImageChops.difference(expected, actual) + self.assertIsNone(diff.getbbox(), "rendered image does not match fixture") From c1dfa5bec0906b0ab8ea8dda86d7dac48175abed Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 30 May 2026 19:37:47 -0700 Subject: [PATCH 4/5] Bump version to 0.5.8 --- lib/drawcal/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/drawcal/__init__.py b/lib/drawcal/__init__.py index 10d95bb..cae2a5c 100644 --- a/lib/drawcal/__init__.py +++ b/lib/drawcal/__init__.py @@ -34,7 +34,7 @@ """ __prog__ = "drawcal" -__version__ = "0.5.7" +__version__ = "0.5.8" __author__ = "ryan@rsgalloway.com" diff --git a/pyproject.toml b/pyproject.toml index 87447cd..d5b08f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "drawcal" -version = "0.5.7" +version = "0.5.8" description = "Python library for drawing simple monthly calendar images with events." readme = "README.md" requires-python = ">=3.8" From 4797d33ab7d5716d9380323d5d1315fe4629ddf5 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 30 May 2026 20:09:20 -0700 Subject: [PATCH 5/5] Harden event validation and clean up test fixtures --- lib/drawcal/events.py | 4 +++- tests/test_events.py | 20 ++++++++++++-------- tests/test_render.py | 10 +++++----- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/drawcal/events.py b/lib/drawcal/events.py index 87de282..27cec54 100644 --- a/lib/drawcal/events.py +++ b/lib/drawcal/events.py @@ -93,9 +93,11 @@ def read_events(filepath): if not isinstance(event, list): raise ValueError("each event must be a list of date strings") for day in event: + if not isinstance(day, str): + raise ValueError("each event date must be a string in M/D/YYYY format") try: datetime.strptime(day, "%m/%d/%Y") - except ValueError as exc: + except (TypeError, ValueError) as exc: raise ValueError(f"invalid event date: {day}") from exc return events diff --git a/tests/test_events.py b/tests/test_events.py index 801968d..a67b7dc 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -8,20 +8,24 @@ class ReadEventsTests(unittest.TestCase): def test_read_events_rejects_invalid_json(self): - with tempfile.NamedTemporaryFile("w+", suffix=".json") as handle: - handle.write("{not json}") - handle.flush() + with tempfile.TemporaryDirectory() as tmpdir: + path = f"{tmpdir}/events.json" + + with open(path, "w", encoding="utf-8") as fp: + fp.write("{not json}") with self.assertRaises(ValueError): - read_events(handle.name) + read_events(path) def test_read_events_rejects_invalid_dates(self): - with tempfile.NamedTemporaryFile("w+", suffix=".json") as handle: - json.dump([["2/30/2025"]], handle) - handle.flush() + with tempfile.TemporaryDirectory() as tmpdir: + path = f"{tmpdir}/events.json" + + with open(path, "w", encoding="utf-8") as fp: + json.dump([["2/30/2025"]], fp) with self.assertRaises(ValueError): - read_events(handle.name) + read_events(path) class GetEventsTests(unittest.TestCase): diff --git a/tests/test_render.py b/tests/test_render.py index e9e1f37..c5747d2 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -29,8 +29,8 @@ def test_draw_calendar_matches_expected_image(self): ): draw_calendar(month=3, year=2025, events=events, outfile=str(outfile)) - expected = Image.open(EXPECTED_IMAGE) - actual = Image.open(outfile) - - diff = ImageChops.difference(expected, actual) - self.assertIsNone(diff.getbbox(), "rendered image does not match fixture") + with Image.open(EXPECTED_IMAGE) as expected, Image.open(outfile) as actual: + diff = ImageChops.difference(expected, actual) + self.assertIsNone( + diff.getbbox(), "rendered image does not match fixture" + )