agenda/agenda/conference.py

232 lines
7.5 KiB
Python

"""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