parent
14f5baf77c
commit
098c7e4447
9 changed files with 464 additions and 66 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue