"""Conferences.""" import dataclasses import decimal import os import typing from datetime import date, datetime import yaml from . import utils from .event import Event from .types import DateOrDateTime, StrDict MAX_CONF_DAYS = 20 DATE_STATUSES = {"exact", "approximate", "tentative"} DATED_STATUSES = {"exact", "tentative"} class ConferenceSeries(typing.TypedDict, total=False): """Conference series metadata.""" name: str topic: str url: str notes: str cadence: str usual_location: str country: str @dataclasses.dataclass class Conference: """Conference.""" name: str topic: str location: str series: str | None = None start: date | datetime | None = None end: date | datetime | None = None trip: date | None = None country: str | None = None venue: str | None = None address: str | None = None url: str | None = None accommodation_booked: bool = False transport_booked: bool = False going: bool = False registered: bool = False speaking: bool = False online: bool = False price: decimal.Decimal | None = None currency: str | None = None latitude: float | None = None longitude: float | None = None attend_start: date | datetime | None = None attend_end: date | datetime | None = None cfp_end: date | None = None cfp_url: str | None = None free: bool | None = None hackathon: bool | None = None ticket_type: str | None = None attendees: int | None = None hashtag: str | None = None description: str | None = None dates: StrDict | None = None @property def display_name(self) -> str: """Add location if not already in conference name.""" return ( self.name if self.location in self.name else f"{self.name} ({self.location})" ) def _date_range_label(start: date, end: date) -> str: """Format conference date range for display.""" if start == end: return start.strftime("%a %-d %b %Y") if start.year == end.year and start.month == end.month: return f"{start.strftime('%a %-d')}-{end.strftime('%-d %b %Y')}" return f"{start.strftime('%a %-d %b')}-{end.strftime('%-d %b %Y')}" def _require_date_value(value: typing.Any, field_name: str) -> DateOrDateTime: """Return date-like field value or raise ValueError.""" if isinstance(value, (date, datetime)): return value raise ValueError(f"conference dates field {field_name!r} must be a date/datetime") def _require_date_only(value: typing.Any, field_name: str) -> date: """Return field value as a date or raise ValueError.""" return utils.as_date(_require_date_value(value, field_name)) def conference_date_fields(item: StrDict) -> StrDict: """Return derived date fields for a conference YAML item.""" raw_dates = item.get("dates") if raw_dates is None: status = typing.cast(str, item.get("date_status", "exact")) if status not in DATE_STATUSES: raise ValueError(f"unknown conference date status {status!r}") start = _require_date_value(item.get("start"), "start") end = _require_date_value(item.get("end", start), "end") start_date = utils.as_date(start) end_date = utils.as_date(end) return { "date_status": status, "start": start, "end": end, "start_date": start_date, "end_date": end_date, "sort_date": start_date, "latest_date": end_date, "display_date": _date_range_label(start_date, end_date), "has_exact_dates": status == "exact", } if not isinstance(raw_dates, dict): raise ValueError("conference dates must be a mapping") status_value = raw_dates.get("status", "exact") if not isinstance(status_value, str) or status_value not in DATE_STATUSES: raise ValueError(f"unknown conference date status {status_value!r}") if status_value in DATED_STATUSES: start = _require_date_value(raw_dates.get("start", item.get("start")), "start") end = _require_date_value(raw_dates.get("end", item.get("end", start)), "end") start_date = utils.as_date(start) end_date = utils.as_date(end) label = raw_dates.get("label") display_date = ( label if isinstance(label, str) else _date_range_label(start_date, end_date) ) return { "date_status": status_value, "start": start, "end": end, "start_date": start_date, "end_date": end_date, "sort_date": start_date, "latest_date": end_date, "display_date": display_date, "has_exact_dates": status_value == "exact", } earliest = _require_date_only(raw_dates.get("earliest"), "earliest") latest = _require_date_only(raw_dates.get("latest"), "latest") label = raw_dates.get("label") display_date = ( label if isinstance(label, str) else _date_range_label(earliest, latest) ) return { "date_status": status_value, "start_date": earliest, "end_date": latest, "sort_date": earliest, "latest_date": latest, "display_date": display_date, "has_exact_dates": False, } def validate_conference_date_fields(item: StrDict) -> StrDict: """Validate conference date fields and return derived values.""" fields = conference_date_fields(item) if fields["start_date"] > fields["end_date"]: raise ValueError("conference ends before it starts") if fields["date_status"] in DATED_STATUSES: duration = (fields["end_date"] - fields["start_date"]).days if duration >= MAX_CONF_DAYS: raise ValueError( f"conference is {duration} days; maximum is {MAX_CONF_DAYS - 1}" ) return fields def load_series(data_dir: str) -> dict[str, ConferenceSeries]: """Load conference series metadata.""" filepath = os.path.join(data_dir, "conference_series.yaml") if not os.path.exists(filepath): return {} loaded = yaml.safe_load(open(filepath, "r")) if loaded is None: return {} if not isinstance(loaded, dict): raise ValueError("conference_series.yaml must be a mapping") return typing.cast(dict[str, ConferenceSeries], loaded) def get_list(filepath: str) -> list[Event]: """Read conferences from a YAML file and return a list of Event objects.""" events: list[Event] = [] for item in yaml.safe_load(open(filepath, "r")): try: fields = validate_conference_date_fields(item) except ValueError as exc: raise AssertionError(str(exc)) from exc normalized_item = dict(item) if "start" in fields: normalized_item["start"] = fields["start"] normalized_item["end"] = fields["end"] conf = Conference(**normalized_item) if fields["has_exact_dates"]: assert conf.start is not None and conf.end is not None event = Event( name="conference", date=conf.start, end_date=conf.end, title=conf.display_name, url=conf.url, going=conf.going, ) events.append(event) if not conf.cfp_end: continue cfp_end_event = Event( name="cfp_end", date=conf.cfp_end, title="CFP end: " + conf.display_name, url=conf.cfp_url or conf.url, ) events.append(cfp_end_event) return events