diff --git a/README.md b/README.md index 61e2a32..b66993f 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,39 @@ Python: ## 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: +`drawcal` uses a structured event object format: ```json [ - ["3/1/2025", "3/2/2025", "3/3/2025"], - ["3/14/2025", "3/15/2025"] + { + "start_date": "3/1/2025", + "end_date": "3/3/2025", + "color": "#ee2233", + "style": "rounded", + "markers": ["3/3/2025"] + }, + { + "start_date": "3/14/2025", + "end_date": "3/15/2025", + "style": "filled", + "markers": ["3/14/2025", "3/15/2025"] + } ] ``` + +Supported `style` values are `filled`, `rounded`, and `diagonal`. Use +`markers` to draw green marker indicators on specific dates within the event +range, or use marker-only events when you only want calendar annotations. + +Legacy list-based events are still supported for backward compatibility and are +documented in [docs/events.md](docs/events.md). + +## Documentation + +Additional docs: + +- [docs/README.md](docs/README.md) +- [docs/events.md](docs/events.md) +- [docs/rendering.md](docs/rendering.md) +- [docs/customization.md](docs/customization.md) +- [docs/python-api.md](docs/python-api.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b2f32f3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,13 @@ +# Documentation + +This folder collects more detailed usage notes and examples for `drawcal`. + +## Guides + +- [events.md](events.md): supported event formats, including legacy, structured, + and marker-only events +- [rendering.md](rendering.md): event styles, markers, and legacy rendering + behavior +- [customization.md](customization.md): overriding default colors and other + advanced tweaks +- [python-api.md](python-api.md): calling `draw_calendar()` directly from Python diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 0000000..46b7112 --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,44 @@ +# Customization + +`drawcal` currently uses module-level color defaults in +`drawcal.drawlib.colors`. + +Example: + +```python +from drawcal.drawlib import colors, draw_calendar + +colors.background = "#f7f4ee" +colors.text = "#555555" +colors.border = "#e7dfcf" + +draw_calendar(month=3, year=2025, events=events, outfile="drawcal.png") +``` + +Available color attributes include: + +- `background` +- `border` +- `border_fill` +- `cell_border` +- `checkin_text` +- `checkout_text` +- `conflict` +- `conflict_border` +- `highlight` +- `highlight_fill` +- `occupied` +- `occupied_text` +- `other` +- `past` +- `past_text` +- `past_border` +- `text` +- `title_text` + +Notes: + +- this is global mutable state +- changes affect later renders in the same Python process +- this works today, but it is better thought of as an advanced usage pattern + than a polished public theming API diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..7410430 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,71 @@ +# Events + +`drawcal` supports a structured event format and also keeps the original legacy +format for backward compatibility. + +## Structured Format + +Structured events are objects: + +```json +[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "rounded", + "color": "#123456", + "markers": ["3/5/2025"] + } +] +``` + +Rules: + +- `start_date` and `end_date` are inclusive +- both dates must be present together when using a range event +- `end_date` must not be earlier than `start_date` +- `markers` must be inside the event range when a range is present + +Structured events are explicit. They do not create an automatic checkout day. + +Example structured render: + +![Rounded Event](images/rounded.png) + +## Marker-Only Events + +You can also draw markers without a date span: + +```json +[ + { + "markers": ["3/5/2025", "3/18/2025"] + } +] +``` + +This is useful for simple calendar annotations where you do not want a filled +range. + +Example marker-only render: + +![Markers Only](images/markers-only.png) + +## Legacy Compatibility Format + +Legacy events are lists of consecutive date strings: + +```json +[ + ["3/1/2025", "3/2/2025", "3/3/2025"] +] +``` + +Rules: + +- dates must use `M/D/YYYY` +- dates must be strictly increasing +- dates must be consecutive with no gaps + +Legacy events preserve the original drawcal behavior, including the implicit +checkout marker on the day after the final date. diff --git a/docs/images/diagonal.png b/docs/images/diagonal.png new file mode 100644 index 0000000..293a499 Binary files /dev/null and b/docs/images/diagonal.png differ diff --git a/docs/images/filled.png b/docs/images/filled.png new file mode 100644 index 0000000..8bccde9 Binary files /dev/null and b/docs/images/filled.png differ diff --git a/docs/images/markers-only.png b/docs/images/markers-only.png new file mode 100644 index 0000000..8fba564 Binary files /dev/null and b/docs/images/markers-only.png differ diff --git a/docs/images/rounded.png b/docs/images/rounded.png new file mode 100644 index 0000000..4daad6f Binary files /dev/null and b/docs/images/rounded.png differ diff --git a/docs/python-api.md b/docs/python-api.md new file mode 100644 index 0000000..9bf2600 --- /dev/null +++ b/docs/python-api.md @@ -0,0 +1,48 @@ +# Python API + +## Basic Usage + +```python +from drawcal import draw_calendar + +events = [ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "rounded", + "markers": ["3/5/2025"], + } +] + +result = draw_calendar(month=3, year=2025, events=events, outfile="drawcal.png") +``` + +## Return Value + +`draw_calendar()` returns a dictionary like: + +```python +{ + "checkins": [...], + "checkouts": [...], + "conflicts": [...], + "occupied": [...], + "outfile": "drawcal.png", +} +``` + +Notes: + +- `checkouts` is mainly relevant for legacy list-based events +- structured events only produce markers you explicitly request +- invalid event payloads raise `ValueError` + +## Reading Events From JSON + +```python +from drawcal.events import read_events + +events = read_events("events.json") +``` + +`read_events()` validates the schema before returning data. diff --git a/docs/rendering.md b/docs/rendering.md new file mode 100644 index 0000000..e9d6573 --- /dev/null +++ b/docs/rendering.md @@ -0,0 +1,50 @@ +# Rendering + +Structured events support three span styles: + +## `filled` + +Draws a solid rectangular span. + +![Filled Event](images/filled.png) + +## `rounded` + +Draws half-width rounded caps on the inside edges of the start and end dates. +This helps adjacent events avoid visually clobbering each other. + +![Rounded Event](images/rounded.png) + +## `diagonal` + +Draws diagonal caps similar to a gantt-style slash. + +![Diagonal Event](images/diagonal.png) + +## Markers + +Use `markers` to draw green marker circles on specific dates: + +```json +{ + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "markers": ["3/5/2025"] +} +``` + +Marker-only events are also supported: + +```json +{ + "markers": ["3/5/2025"] +} +``` + +![Markers Only](images/markers-only.png) + +## Legacy Behavior + +Legacy list-based events still render with the original checkout-style marker on +the day after the final date. Structured events do not do this unless you add +the marker explicitly. diff --git a/drawcal.png b/drawcal.png index 62d0cd9..ef3c7be 100644 Binary files a/drawcal.png and b/drawcal.png differ diff --git a/events.json b/events.json index 4689d6b..86b7839 100644 --- a/events.json +++ b/events.json @@ -1,22 +1,21 @@ [ - [ - "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 + { + "start_date": "3/1/2025", + "end_date": "3/6/2025", + "markers": ["3/6/2025"], + "style": "rounded" + }, + { + "start_date": "3/12/2025", + "end_date": "3/18/2025", + "markers": ["3/18/2025"], + "style": "rounded" + }, + { + "start_date": "3/24/2025", + "end_date": "3/27/2025", + "color": "#ff3300", + "markers": ["3/27/2025"], + "style": "rounded" + } +] diff --git a/lib/drawcal/__init__.py b/lib/drawcal/__init__.py index 066a180..ac60d7f 100644 --- a/lib/drawcal/__init__.py +++ b/lib/drawcal/__init__.py @@ -34,7 +34,7 @@ """ __prog__ = "drawcal" -__version__ = "0.5.9" +__version__ = "0.6.0" __author__ = "ryan@rsgalloway.com" diff --git a/lib/drawcal/cli.py b/lib/drawcal/cli.py index 7f3d4b0..94119a9 100755 --- a/lib/drawcal/cli.py +++ b/lib/drawcal/cli.py @@ -78,6 +78,7 @@ def parse_args(): help="which year to draw (defaults to current year)", ) parser.add_argument( + "-o", "--outfile", metavar="OUTFILE", type=str, @@ -97,14 +98,27 @@ def main(): args = parse_args() - if args.events: - events = read_events(args.events) - else: - events = get_events(args.month, args.year) + try: + if args.events: + events = read_events(args.events) + else: + events = get_events(args.month, args.year) + except (OSError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 from drawcal.drawlib import draw_calendar - draw_calendar(month=args.month, year=args.year, events=events, outfile=args.outfile) + try: + draw_calendar( + month=args.month, + year=args.year, + events=events, + outfile=args.outfile, + ) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 return 0 diff --git a/lib/drawcal/drawlib.py b/lib/drawcal/drawlib.py index da35ef7..4ea31e0 100644 --- a/lib/drawcal/drawlib.py +++ b/lib/drawcal/drawlib.py @@ -38,7 +38,7 @@ from PIL import Image, ImageFont, ImageDraw from drawcal import config -from drawcal.events import validate_events +from drawcal.models import normalize_events # set some global date values _d = datetime.today() @@ -76,6 +76,64 @@ def _text_size(draw, text, font): return right - left, bottom - top +def _lighten_color(color, amount=0.3): + """Return a slightly lighter hex color.""" + + if not isinstance(color, str) or not color.startswith("#") or len(color) != 7: + return color + + rgb = [int(color[index : index + 2], 16) for index in (1, 3, 5)] + adjusted = [] + for channel in rgb: + adjusted.append(min(255, int(channel + ((255 - channel) * amount)))) + return "#{:02x}{:02x}{:02x}".format(*adjusted) + + +def _draw_event_segment(draw, x1, y1, color, style, is_start, is_end): + """Draw one event cell using the configured cap style.""" + + top = y1 - 1 + bottom = y1 + 25 + left = x1 + right = x1 + 25 + center = x1 + 12 + + if style == "filled": + draw.line((left, y1 + 12, right - 1, y1 + 12), width=27, fill=color) + return + + if style == "diagonal": + if is_start and is_end: + draw.polygon( + [(center, top), (right, y1 + 12), (center, bottom), (left, y1 + 12)], + fill=color, + ) + return + if is_start: + draw.polygon( + [(left, bottom), (right, top), (right, bottom)], + fill=color, + ) + return + if is_end: + draw.polygon( + [(left, top), (left, bottom), (right, top)], + fill=color, + ) + return + draw.line((left, y1 + 12, right - 1, y1 + 12), width=27, fill=color) + return + + if is_start and is_end: + draw.ellipse((left, top, right, bottom), fill=color) + elif is_start: + draw.pieslice((x1 + 13, top, x1 + 39, bottom), 90, 270, fill=color) + elif is_end: + draw.pieslice((x1 - 13, top, x1 + 13, bottom), 270, 90, fill=color) + else: + draw.line((left, y1 + 12, right - 1, y1 + 12), width=27, fill=color) + + def draw_calendar( month=today.month, year=today.year, @@ -115,9 +173,9 @@ def draw_calendar( # make sure events is a list if events is None: - events = [] + normalized_events = [] else: - validate_events(events) + normalized_events = normalize_events(events) # categorize and track dates conflict_dates = set() @@ -195,9 +253,11 @@ def draw_calendar( event_color = colors.occupied checkin = False checkout = False + marker = False occupied = False past_date = False conflict = False + cell_border_color = None curr_day = None curr_date = None @@ -224,32 +284,46 @@ def draw_calendar( past_date = True # iterate over calendar events (date format: mm/dd/yyyy) - for event in events: - if not event: - continue + for event in normalized_events: + event_color = event.color or colors.occupied # change event color of past dates - if past_date: + if past_date and event.color is None: event_color = colors.past - first_day = event[0] - last_day = event[-1] - - try: - checkout_date = datetime.strptime(last_day, "%m/%d/%Y") + delta - checkout_day = f"{checkout_date.month}/{checkout_date.day}/{checkout_date.year}" - except ValueError: - print("invalid date!", event) - continue + first_day = None + last_day = None + is_explicit_end = False + is_explicit_start = False + if event.has_range: + first_day = event.start_date_str + last_day = event.end_date_str + is_explicit_end = (not event.legacy) and curr_day == last_day + is_explicit_start = curr_day == first_day + marker_days = set() + if event.markers: + marker_days = { + f"{marker.month}/{marker.day}/{marker.year}" + for marker in event.markers + } + + checkout_day = None + if event.legacy and last_day: + try: + checkout_date = datetime.strptime(last_day, "%m/%d/%Y") + delta + checkout_day = f"{checkout_date.month}/{checkout_date.day}/{checkout_date.year}" + except ValueError: + print("invalid date!", event) + continue # handle each day in event - if first_day == curr_day: + if curr_day and first_day == curr_day: s = 0 # check-in - if first_day == curr_day: + if curr_day and first_day == curr_day: checkin = True text_color = colors.checkin_text - if today_str == curr_day: + if event.color is None and today_str == curr_day: event_color = colors.occupied if ( @@ -259,15 +333,22 @@ def draw_calendar( conflict_dates.add(curr_day) event_color = colors.conflict - draw.pieslice( - (x1 + 13, y1 - 1, x1 + 39, y1 + 25), 90, 270, fill=event_color + _draw_event_segment( + draw, + x1, + y1, + event_color, + event.style, + is_explicit_start, + is_explicit_end, ) + cell_border_color = _lighten_color(event_color) # track checkin nights checkin_dates.add(curr_day) # check-out - elif curr_day == checkout_day: + elif curr_day and checkout_day and curr_day == checkout_day: checkout = True text_color = colors.border if today_str == checkout_day: @@ -284,12 +365,18 @@ def draw_calendar( draw.pieslice( (x1 - 13, y1 - 1, x1 + 13, y1 + 25), 270, 90, fill=event_color ) + cell_border_color = _lighten_color(event_color) # track checkout nights checkout_dates.add(curr_day) # occupied - elif curr_day in event: + elif ( + curr_date + and event.start_date is not None + and event.end_date is not None + and event.start_date <= curr_date <= event.end_date + ): occupied = True text_color = colors.border @@ -302,31 +389,39 @@ def draw_calendar( conflict_dates.add(curr_day) event_color = colors.conflict - draw.line( - (x1 + s, y1 + offset, x1 + e - 1, y1 + offset), - width=27, - fill=event_color, + _draw_event_segment( + draw, + x1, + y1, + event_color, + event.style, + is_explicit_start, + is_explicit_end, ) + cell_border_color = _lighten_color(event_color) # track occupied dates occupied_dates.add(curr_day) + if curr_day and curr_day in marker_days: + marker = True + # draw vertical lines between days if i > 1: fill_color = colors.other if occupied or checkout: if conflict and not checkin: fill_color = colors.conflict_border + elif cell_border_color: + fill_color = cell_border_color else: fill_color = colors.cell_border - if past_date: - fill_color = colors.past_border - draw.line((x1, y1, x1, y1 + 25), width=1, fill=fill_color) + draw.line((x1, y1 - 1, x1, y1 + 25), width=1, fill=fill_color) # end draw events # add a green circle on checkout dates - if checkout and do_highlights: + if (checkout and do_highlights) or marker: draw.ellipse( (x1 + 3, y1 + 3, x1 + 22, y1 + 22), fill=colors.highlight, @@ -346,7 +441,7 @@ def draw_calendar( text_color = colors.past_text else: text_color = colors.text - if checkout and do_highlights: + if (checkout and do_highlights) or marker: text_color = colors.checkout_text elif occupied: text_color = colors.occupied_text diff --git a/lib/drawcal/events.py b/lib/drawcal/events.py index 4ae8a89..4e6e9b5 100644 --- a/lib/drawcal/events.py +++ b/lib/drawcal/events.py @@ -37,6 +37,8 @@ from calendar import monthrange from datetime import datetime, timedelta +from drawcal.models import normalize_events + d = datetime.today() today_str = f"{d.month}/{d.day}/{d.year}" today = datetime.strptime(today_str, "%m/%d/%Y") @@ -45,28 +47,7 @@ def validate_events(events): """Validate drawcal event payloads.""" - - if not isinstance(events, list): - raise ValueError("events must be 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") - - parsed_days = [] - for day in event: - if not isinstance(day, str): - raise ValueError("each event date must be a string in M/D/YYYY format") - try: - parsed_days.append(datetime.strptime(day, "%m/%d/%Y")) - except (TypeError, ValueError) as exc: - raise ValueError(f"invalid event date: {day}") from exc - - for previous, current in zip(parsed_days, parsed_days[1:]): - if current <= previous: - raise ValueError("event dates must be in strictly increasing order") - if current - previous != delta: - raise ValueError("event dates must be consecutive with no gaps") + normalize_events(events) def get_events(month=today.month, year=today.year): diff --git a/lib/drawcal/models.py b/lib/drawcal/models.py new file mode 100644 index 0000000..a89ecf8 --- /dev/null +++ b/lib/drawcal/models.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +# +# Copyright (c) 2022-2026, Bnbnotify +# +# 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. +# + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional + +DATE_FORMAT = "%m/%d/%Y" +DEFAULT_STYLE = "rounded" +VALID_STYLES = {"filled", "rounded", "diagonal"} +_DAY = timedelta(days=1) + + +def parse_date(value: str, field_name: str) -> datetime: + """Parse an event date string.""" + + if not isinstance(value, str): + raise ValueError(f"{field_name} must be a string in M/D/YYYY format") + try: + return datetime.strptime(value, DATE_FORMAT) + except ValueError as exc: + raise ValueError(f"invalid {field_name}: {value}") from exc + + +def format_date(value: datetime) -> str: + """Return a drawcal date string without zero padding.""" + + return f"{value.month}/{value.day}/{value.year}" + + +@dataclass(frozen=True) +class Event: + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + color: Optional[str] = None + style: str = DEFAULT_STYLE + markers: Optional[List[datetime]] = None + legacy: bool = False + + def validate(self) -> "Event": + if self.start_date is None or self.end_date is None: + if self.start_date is not None or self.end_date is not None: + raise ValueError("start_date and end_date must be provided together") + if not self.markers: + raise ValueError( + "event must define a date range or at least one marker" + ) + elif self.end_date < self.start_date: + raise ValueError("end_date must be on or after start_date") + if self.style not in VALID_STYLES: + raise ValueError(f"style must be one of: {', '.join(sorted(VALID_STYLES))}") + if self.color is not None and not isinstance(self.color, str): + raise ValueError("color must be a string when provided") + if self.markers is not None: + if not isinstance(self.markers, list): + raise ValueError("markers must be a list") + for marker in self.markers: + if not isinstance(marker, datetime): + raise ValueError("markers must be datetime values") + if ( + self.start_date is not None + and self.end_date is not None + and (marker < self.start_date or marker > self.end_date) + ): + raise ValueError("markers must fall within the event date range") + return self + + @property + def has_range(self) -> bool: + return self.start_date is not None and self.end_date is not None + + @property + def dates(self) -> List[str]: + if not self.has_range: + return [] + dates = [] + current = self.start_date + while current <= self.end_date: + dates.append(format_date(current)) + current += _DAY + return dates + + @property + def start_date_str(self) -> str: + if self.start_date is None: + raise ValueError("event does not define start_date") + return format_date(self.start_date) + + @property + def end_date_str(self) -> str: + if self.end_date is None: + raise ValueError("event does not define end_date") + return format_date(self.end_date) + + def to_dict(self) -> Dict[str, Any]: + data = {} + if self.has_range: + data["start_date"] = self.start_date_str + data["end_date"] = self.end_date_str + if self.color is not None: + data["color"] = self.color + if self.style != DEFAULT_STYLE: + data["style"] = self.style + if self.markers: + data["markers"] = [format_date(marker) for marker in self.markers] + return data + + +def _event_from_legacy_dates(value: List[str]) -> Event: + if not value: + raise ValueError("events may not be empty") + + parsed_days = [parse_date(day, "event date") for day in value] + + for previous, current in zip(parsed_days, parsed_days[1:]): + if current <= previous: + raise ValueError("event dates must be in strictly increasing order") + if current - previous != _DAY: + raise ValueError("event dates must be consecutive with no gaps") + + return Event( + start_date=parsed_days[0], + end_date=parsed_days[-1], + legacy=True, + ).validate() + + +def _event_from_mapping(value: Dict[str, Any]) -> Event: + unknown_keys = set(value) - {"start_date", "end_date", "color", "style", "markers"} + if unknown_keys: + keys = ", ".join(sorted(unknown_keys)) + raise ValueError(f"unsupported event field(s): {keys}") + + start_date = value.get("start_date") + end_date = value.get("end_date") + if start_date is not None: + start_date = parse_date(start_date, "start_date") + if end_date is not None: + end_date = parse_date(end_date, "end_date") + color = value.get("color") + style = value.get("style", DEFAULT_STYLE) + markers = value.get("markers") + if markers is not None: + if not isinstance(markers, list): + raise ValueError("markers must be a list of date strings") + markers = [parse_date(marker, "marker") for marker in markers] + + return Event( + start_date=start_date, + end_date=end_date, + color=color, + style=style, + markers=markers, + ).validate() + + +def normalize_event(value: Any) -> Event: + """Convert supported user payloads into a normalized Event.""" + + if isinstance(value, Event): + return value.validate() + if isinstance(value, list): + return _event_from_legacy_dates(value) + if isinstance(value, dict): + return _event_from_mapping(value) + raise ValueError("each event must be a legacy date list or an event dict") + + +def normalize_events(events: Iterable[Any]) -> List[Event]: + """Normalize a list of event payloads.""" + + if not isinstance(events, list): + raise ValueError("events must be a list") + return [normalize_event(event) for event in events] diff --git a/pyproject.toml b/pyproject.toml index b89865c..1437bc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "drawcal" -version = "0.5.9" +version = "0.6.0" description = "Python library for drawing simple monthly calendar images with events." readme = "README.md" requires-python = ">=3.8" diff --git a/tests/fixtures/march-2025-events-v2.json b/tests/fixtures/march-2025-events-v2.json new file mode 100644 index 0000000..cdfb585 --- /dev/null +++ b/tests/fixtures/march-2025-events-v2.json @@ -0,0 +1,19 @@ +[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "color": "#123456", + "markers": ["3/5/2025"], + "style": "rounded" + }, + { + "start_date": "3/12/2025", + "end_date": "3/17/2025", + "style": "diagonal" + }, + { + "start_date": "3/24/2025", + "end_date": "3/26/2025", + "style": "filled" + } +] diff --git a/tests/fixtures/march-2025-expected-v2.png b/tests/fixtures/march-2025-expected-v2.png new file mode 100644 index 0000000..e0d7e67 Binary files /dev/null and b/tests/fixtures/march-2025-expected-v2.png differ diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4a6906e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,24 @@ +import io +import tempfile +import unittest +from contextlib import redirect_stderr +from unittest import mock + +from drawcal import cli + + +class CliTests(unittest.TestCase): + def test_main_reports_invalid_json_cleanly(self): + with tempfile.TemporaryDirectory() as tmpdir: + events_path = f"{tmpdir}/events.json" + with open(events_path, "w", encoding="utf-8") as fp: + fp.write("{not json}") + + stderr = io.StringIO() + with mock.patch( + "sys.argv", ["drawcal", "--events", events_path] + ), mock.patch("envstack.init"), redirect_stderr(stderr): + exit_code = cli.main() + + self.assertEqual(exit_code, 1) + self.assertIn("Error: invalid JSON in events file", stderr.getvalue()) diff --git a/tests/test_events.py b/tests/test_events.py index 558c4dc..1ed6475 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -3,6 +3,7 @@ import unittest from datetime import datetime +from drawcal.models import Event, normalize_events from drawcal.events import get_events, read_events, validate_events @@ -37,6 +38,29 @@ def test_read_events_rejects_gapped_events(self): with self.assertRaisesRegex(ValueError, "consecutive with no gaps"): read_events(path) + def test_read_events_accepts_dict_events(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = f"{tmpdir}/events.json" + + with open(path, "w", encoding="utf-8") as fp: + json.dump( + [ + { + "start_date": "3/1/2025", + "end_date": "3/3/2025", + "color": "#ee2233", + "style": "rounded", + "markers": ["3/3/2025"], + } + ], + fp, + ) + + self.assertEqual( + read_events(path)[0]["start_date"], + "3/1/2025", + ) + class GetEventsTests(unittest.TestCase): def test_get_events_stays_within_month(self): @@ -54,3 +78,78 @@ class ValidateEventsTests(unittest.TestCase): def test_validate_events_rejects_gapped_direct_input(self): with self.assertRaisesRegex(ValueError, "consecutive with no gaps"): validate_events([["6/3/2022", "6/6/2022", "6/7/2022"]]) + + def test_validate_events_accepts_dict_events(self): + validate_events( + [ + { + "start_date": "3/14/2022", + "end_date": "3/17/2022", + "style": "filled", + "markers": ["3/14/2022", "3/17/2022"], + } + ] + ) + + def test_validate_events_accepts_marker_only_events(self): + validate_events([{"markers": ["3/14/2022", "3/17/2022"]}]) + + def test_validate_events_rejects_non_datetime_markers_on_event_instance(self): + with self.assertRaisesRegex(ValueError, "markers must be datetime values"): + Event(markers=["3/14/2022"]).validate() + + +class NormalizeEventsTests(unittest.TestCase): + def test_normalize_events_supports_legacy_lists(self): + normalized = normalize_events([["3/14/2022", "3/15/2022", "3/16/2022"]]) + + self.assertEqual( + normalized, + [ + Event( + start_date=datetime(2022, 3, 14), + end_date=datetime(2022, 3, 16), + legacy=True, + ) + ], + ) + + def test_normalize_events_supports_dict_schema(self): + normalized = normalize_events( + [ + { + "start_date": "3/14/2022", + "end_date": "3/17/2022", + "color": "#ee2233", + "style": "rounded", + "markers": ["3/16/2022"], + } + ] + ) + + self.assertEqual( + normalized[0].dates, ["3/14/2022", "3/15/2022", "3/16/2022", "3/17/2022"] + ) + self.assertEqual(normalized[0].color, "#ee2233") + self.assertEqual(normalized[0].style, "rounded") + self.assertEqual(normalized[0].to_dict()["markers"], ["3/16/2022"]) + self.assertFalse(normalized[0].legacy) + + def test_normalize_events_rejects_markers_outside_range(self): + with self.assertRaisesRegex(ValueError, "within the event date range"): + normalize_events( + [ + { + "start_date": "3/14/2022", + "end_date": "3/17/2022", + "markers": ["3/18/2022"], + } + ] + ) + + def test_normalize_events_supports_marker_only_schema(self): + normalized = normalize_events([{"markers": ["3/16/2022"]}]) + + self.assertFalse(normalized[0].has_range) + self.assertEqual(normalized[0].dates, []) + self.assertEqual(normalized[0].to_dict()["markers"], ["3/16/2022"]) diff --git a/tests/test_render.py b/tests/test_render.py index d39bf92..ae1e3b4 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -5,21 +5,34 @@ from pathlib import Path from unittest import mock -from PIL import Image, ImageChops +from PIL import Image, ImageChops, ImageColor 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" +LEGACY_EXPECTED_IMAGE = FIXTURES_DIR / "march-2025-expected.png" +LEGACY_EVENTS_FILE = FIXTURES_DIR / "march-2025-events.json" +STRUCTURED_EXPECTED_IMAGE = FIXTURES_DIR / "march-2025-expected-v2.png" +STRUCTURED_EVENTS_FILE = FIXTURES_DIR / "march-2025-events-v2.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")) + def _render(self, month, year, events): + with tempfile.TemporaryDirectory() as tmpdir: + outfile = Path(tmpdir) / "render.png" + result = draw_calendar( + month=month, + year=year, + events=events, + outfile=str(outfile), + ) + return result, outfile + + def _assert_render_matches_fixture(self, events_file, expected_image): + events = json.loads(events_file.read_text(encoding="utf-8")) with tempfile.TemporaryDirectory() as tmpdir: outfile = Path(tmpdir) / "render.png" @@ -29,12 +42,62 @@ def test_draw_calendar_matches_expected_image(self): ): draw_calendar(month=3, year=2025, events=events, outfile=str(outfile)) - with Image.open(EXPECTED_IMAGE) as expected, Image.open(outfile) as actual: + 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" ) + def test_draw_calendar_matches_legacy_fixture(self): + self._assert_render_matches_fixture( + LEGACY_EVENTS_FILE, + LEGACY_EXPECTED_IMAGE, + ) + + def test_draw_calendar_matches_structured_fixture(self): + self._assert_render_matches_fixture( + STRUCTURED_EVENTS_FILE, + STRUCTURED_EXPECTED_IMAGE, + ) + + def test_draw_calendar_accepts_dict_events(self): + self._render( + month=3, + year=2025, + events=[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "filled", + "markers": ["3/1/2025", "3/5/2025"], + } + ], + ) + + def test_dict_events_do_not_draw_implicit_checkout_day(self): + result, _ = self._render( + month=3, + year=2025, + events=[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "filled", + } + ], + ) + + self.assertNotIn("3/6/2025", result["checkouts"]) + + def test_legacy_events_keep_implicit_checkout_day(self): + result, _ = self._render( + month=3, + year=2025, + events=[["3/1/2025", "3/2/2025", "3/3/2025", "3/4/2025", "3/5/2025"]], + ) + + self.assertIn("3/6/2025", result["checkouts"]) + def test_draw_calendar_rejects_gapped_events(self): with self.assertRaisesRegex(ValueError, "consecutive with no gaps"): draw_calendar( @@ -42,3 +105,38 @@ def test_draw_calendar_rejects_gapped_events(self): year=2022, events=[["6/3/2022", "6/6/2022", "6/7/2022"]], ) + + def test_explicit_event_color_wins(self): + 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=[ + { + "start_date": "3/1/2025", + "end_date": "3/5/2025", + "style": "filled", + "color": "#123456", + } + ], + outfile=str(outfile), + ) + + with Image.open(outfile) as image: + expected = ImageColor.getrgb("#123456") + self.assertEqual(image.getpixel((163, 53))[:3], expected) + + def test_marker_only_events_are_supported(self): + result, _ = self._render( + month=3, + year=2025, + events=[{"markers": ["3/5/2025", "3/18/2025"]}], + ) + + self.assertEqual(result["occupied"], []) + self.assertEqual(result["checkins"], [])