Support inexact conference dates

Closes: #188
This commit is contained in:
Edward Betts 2026-06-22 09:25:51 +01:00
parent 14f5baf77c
commit 098c7e4447
9 changed files with 464 additions and 66 deletions

View file

@ -16,6 +16,8 @@ import pycountry
import requests
import yaml
from agenda.conference import conference_date_fields
USER_AGENT = "add-new-conference/0.1"
COORDINATE_PATTERNS = (
re.compile(r"@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)"),
@ -160,7 +162,9 @@ def webpage_to_text(root: lxml.html.HtmlElement) -> str:
text_maker = html2text.HTML2Text()
text_maker.ignore_links = True
text_maker.ignore_images = True
return text_maker.handle(lxml.html.tostring(root_copy, encoding="unicode"))
return typing.cast(
str, text_maker.handle(lxml.html.tostring(root_copy, encoding="unicode"))
)
def parse_osm_url(url: str) -> tuple[float, float] | None:
@ -267,13 +271,13 @@ def insert_sorted(
) -> list[dict[str, typing.Any]]:
"""Insert a conference sorted by start date and skip duplicate URLs."""
new_url = new_conf.get("url")
new_start = parse_date(str(new_conf["start"]))
new_start = conference_sort_datetime(new_conf)
new_year = new_start.year
if new_url:
for conf in conferences:
if conf.get("url") == new_url:
existing_start = parse_date(str(conf["start"]))
existing_start = conference_sort_datetime(conf)
existing_year = existing_start.year
if url_has_year_component(new_url):
@ -287,7 +291,7 @@ def insert_sorted(
return conferences
for idx, conf in enumerate(conferences):
existing_start = parse_date(str(conf["start"]))
existing_start = conference_sort_datetime(conf)
if new_start < existing_start:
conferences.insert(idx, new_conf)
return conferences
@ -295,6 +299,14 @@ def insert_sorted(
return conferences
def conference_sort_datetime(conf: dict[str, typing.Any]) -> datetime:
"""Return conference sort date as a datetime."""
sort_date = conference_date_fields(conf)["sort_date"]
if isinstance(sort_date, datetime):
return sort_date
return datetime.combine(sort_date, time())
def validate_country(conf: dict[str, typing.Any]) -> None:
"""Ensure country is a valid ISO 3166-1 alpha-2 code, normalise if possible."""
country = conf.get("country")
@ -377,7 +389,11 @@ def maybe_extract_explicit_end_time(source_text: str) -> int | None:
def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> None:
"""Ensure an end value exists, with a Geomob-specific fallback."""
start_value = new_conf.get("start")
dates = new_conf.get("dates")
nested_dates = dates if isinstance(dates, dict) else None
start_value = (
nested_dates.get("start") if nested_dates is not None else new_conf.get("start")
)
if start_value is None:
return
@ -395,9 +411,16 @@ def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> No
end_hour = 22
geomob_end = start_dt.replace(hour=end_hour, minute=0, second=0, microsecond=0)
new_conf["end"] = same_type_as_start(
start_value, geomob_end, prefer_datetime=True
)
end_value = same_type_as_start(start_value, geomob_end, prefer_datetime=True)
if nested_dates is not None:
nested_dates["end"] = end_value
else:
new_conf["end"] = end_value
return
if nested_dates is not None:
if "end" not in nested_dates:
nested_dates["end"] = same_type_as_start(start_value, start_dt)
return
if "end" not in new_conf:

View file

@ -2,14 +2,18 @@
import dataclasses
import decimal
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"}
@dataclasses.dataclass
@ -19,8 +23,8 @@ class Conference:
name: str
topic: str
location: str
start: date | datetime
end: date | datetime
start: date | datetime | None = None
end: date | datetime | None = None
trip: date | None = None
country: str | None = None
venue: str | None = None
@ -46,6 +50,7 @@ class Conference:
attendees: int | None = None
hashtag: str | None = None
description: str | None = None
dates: StrDict | None = None
@property
def display_name(self) -> str:
@ -57,22 +62,135 @@ class Conference:
)
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 typing.cast(date, 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 get_list(filepath: str) -> list[Event]:
"""Read conferences from a YAML file and return a list of Event objects."""
events: list[Event] = []
for conf in (Conference(**conf) for conf in yaml.safe_load(open(filepath, "r"))):
assert conf.start <= conf.end
duration = (utils.as_date(conf.end) - utils.as_date(conf.start)).days
assert duration < MAX_CONF_DAYS
event = Event(
name="conference",
date=conf.start,
end_date=conf.end,
title=conf.display_name,
url=conf.url,
going=conf.going,
)
events.append(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(