232 lines
7.5 KiB
Python
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
|