forked from JesperDramsch/python-deadlines
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsort_yaml.py
More file actions
368 lines (299 loc) · 11.5 KB
/
Copy pathsort_yaml.py
File metadata and controls
368 lines (299 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
#!/usr/bin/env python3
# Sort and Clean conference data.
import contextlib
import datetime
import operator
import time
from datetime import timezone
from pathlib import Path
from urllib.parse import urlparse
import pydantic
import pytz
import yaml
from tqdm import tqdm
try:
from logging_config import get_tqdm_logger
from tidy_conf import auto_add_sub
from tidy_conf import write_conference_yaml
from tidy_conf.date import clean_dates
from tidy_conf.latlon import add_latlon
from tidy_conf.links import check_link_availability
from tidy_conf.links import check_mastodon_migration
from tidy_conf.links import get_cache
from tidy_conf.schema import Conference
from tidy_conf.schema import get_schema
from tidy_conf.titles import tidy_titles
from tidy_conf.utils import Loader
except ImportError:
from .logging_config import get_tqdm_logger
from .tidy_conf import auto_add_sub
from .tidy_conf import write_conference_yaml
from .tidy_conf.date import clean_dates
from .tidy_conf.latlon import add_latlon
from .tidy_conf.links import check_link_availability
from .tidy_conf.links import check_mastodon_migration
from .tidy_conf.links import get_cache
from .tidy_conf.schema import Conference
from .tidy_conf.schema import get_schema
from .tidy_conf.titles import tidy_titles
from .tidy_conf.utils import Loader
# Constants
DATEFORMAT = "%Y-%m-%d %H:%M:%S"
TBA_WORDS = ["tba", "tbd", "cancelled", "none", "na", "n/a", "nan", "n.a."]
CFP_WARNING_DAYS = 37 # Days after conference end to keep on main page
DEFAULT_CFP_TIME = " 23:59:00" # Default time for CFP if not specified
def sort_by_cfp(data: Conference) -> str:
"""Sort by CFP date.
Parameters
----------
data : Conference
Conference object to get sort key from
Returns
-------
str
Sort key based on CFP date
"""
if data.cfp.lower() in TBA_WORDS:
return data.cfp
if " " not in data.cfp:
data.cfp += DEFAULT_CFP_TIME
timezone = data.timezone or "AoE"
# Convert timezone strings to IANA format
iana_tz = timezone.replace("AoE", "Etc/GMT+12").replace("UTC+", "Etc/GMT-").replace("UTC-", "Etc/GMT+")
return pytz.utc.normalize(
datetime.datetime.strptime(data.cfp, DATEFORMAT).replace(
tzinfo=pytz.timezone(iana_tz),
),
).strftime(DATEFORMAT)
def sort_by_date(data: Conference) -> str:
"""Sort by starting date.
Parameters
----------
data : Conference
Conference object to get sort key from
Returns
-------
str
Sort key based on start date
"""
return str(data.start)
def sort_by_date_passed(data: Conference) -> bool:
"""Sort data by date passed.
Parameters
----------
data : Conference
Conference object to check
Returns
-------
bool
True if the CFP date has passed
"""
right_now = datetime.datetime.now(tz=timezone.utc).replace(microsecond=0).strftime(DATEFORMAT)
return sort_by_cfp(data) < right_now
def sort_by_name(data: Conference) -> str:
"""Sort by name.
Parameters
----------
data : Conference
Conference object to get sort key from
Returns
-------
str
Lowercase conference name and year
"""
return f"{data.conference} {data.year}".lower()
def order_keywords(data: list[Conference]) -> list[Conference]:
"""Order the keywords in the data.
Parameters
----------
data : list[Conference]
List of Conference objects to process
Returns
-------
list[Conference]
Processed list with ordered keywords
"""
schema = get_schema().columns.tolist()
data_flag = False
if isinstance(data, Conference):
data = data.dict()
data_flag = True
new_dict = {}
for key in schema:
if key in data:
new_dict[key] = data[key]
if data_flag:
return Conference(**new_dict)
return new_dict
def merge_duplicates(data):
"""Merge duplicates in the data."""
filtered = []
filtered_reduced = []
for q in tqdm(data):
q_reduced = f'{q.get("conference", None)} {q.get("year", None)} {q.get("place", None)}'
if q_reduced not in filtered_reduced:
filtered.append(q)
filtered_reduced.append(q_reduced)
else:
index = filtered_reduced.index(q_reduced)
for key, value in q.items():
if value and key not in filtered[index]:
filtered[index][key] = value
else:
if len(str(value)) > len(str(filtered[index][key])):
filtered[index][key] = value
return filtered
def tidy_dates(data):
"""Clean dates in the data."""
for i, q in tqdm(enumerate(data.copy()), total=len(data)):
data[i] = clean_dates(q)
# data[i] = create_nice_date(q)
return data
def split_data(data: list[Conference]) -> tuple[list, list, list, list]:
"""Split the data into conferences, tba, expired, and legacy.
The data is split based on the `cfp` field. If the `cfp` field is in the `tba_words` list, it is considered a TBA.
Legacy is considered anything old with the `cfp` still being TBA.
Parameters
----------
data : list[Conference]
List of Conference objects to split
Returns
-------
tuple[list, list, list, list]
Tuple of (conferences, tba, expired, legacy) lists
"""
conf, tba, expired, legacy = [], [], [], []
for q in tqdm(data):
if q.cfp.lower() not in TBA_WORDS and " " not in q.cfp:
q.cfp += DEFAULT_CFP_TIME
if "cfp_ext" in q and " " not in q.cfp_ext:
q.cfp_ext += DEFAULT_CFP_TIME
date_today = datetime.datetime.now(tz=timezone.utc).replace(microsecond=0).date()
# if the conference is older than CFP_WARNING_DAYS, it moves off the main page
if q.end < date_today - datetime.timedelta(days=CFP_WARNING_DAYS):
legacy_year = (date_today - datetime.timedelta(days=7 * 365)).replace(month=1, day=1)
if q.end < legacy_year:
legacy.append(q)
else:
expired.append(q)
continue
try:
if q.cfp.lower() in TBA_WORDS:
tba.append(q)
else:
conf.append(q)
except KeyError:
pass
return conf, tba, expired, legacy
def check_links(data):
"""Check the links in the data iteratively."""
cache, cache_archived = get_cache()
for i, q in tqdm(enumerate(sorted(data, key=operator.itemgetter("year"), reverse=True)), total=len(data)):
for key in ("link", "cfp_link", "sponsor", "finaid"):
if key in q:
new_link = check_link_availability(q[key], q["start"], cache=cache, cache_archived=cache_archived)
parsed_url = urlparse(new_link)
if q[key] != new_link and parsed_url.hostname and parsed_url.hostname.endswith(".archive.org"):
time.sleep(0.5)
q[key] = new_link
data[i] = q
# Check Mastodon account migrations
if q.get("mastodon"):
new_mastodon = check_mastodon_migration(q["mastodon"])
if new_mastodon:
q["mastodon"] = new_mastodon
data[i] = q
return data
# Sort:
def sort_data(base="", prefix="", skip_links=False):
"""Sort and clean the conference data."""
logger = get_tqdm_logger(__name__)
# Load different data files
current = Path(base, "_data", "conferences.yml")
out_current = Path(base, "_data", f"{prefix}conferences.yml")
archive = Path(base, "_data", "archive.yml")
out_archive = Path(base, "_data", f"{prefix}archive.yml")
legacy = Path(base, "_data", "legacy.yml")
out_legacy = Path(base, "_data", f"{prefix}legacy.yml")
logger.info("📊 Loading conference data files")
data = []
files_loaded = 0
for url in (current, archive, legacy):
if url.exists():
with url.open(encoding="utf-8") as stream, contextlib.suppress(yaml.YAMLError):
if stream:
file_data = yaml.load(stream, Loader=Loader) # nosec B506 # noqa: S506
if file_data:
data += file_data
files_loaded += 1
logger.debug(f"Loaded {len(file_data)} entries from {url.name}")
logger.info(f"📋 Loaded {len(data)} conferences from {files_loaded} files")
from tidy_conf.schema import Conference
logger.debug("🔧 Ordering keywords")
for i, q in enumerate(data.copy()):
data[i] = order_keywords(q)
# Clean Dates
logger.info("📅 Cleaning dates")
data = tidy_dates(data)
# Clean Titles
logger.info("🏷️ Cleaning titles")
data = tidy_titles(data)
# Add Sub
logger.info("🏢 Adding submission types")
data = auto_add_sub(data)
# Geocode Data
logger.info("🗺️ Adding geolocation data")
data = add_latlon(data)
# Merge duplicates
logger.info("🔄 Merging duplicates")
data = merge_duplicates(data)
# Check Links
if not skip_links:
logger.info("🔗 Checking link availability")
data = check_links(data)
else:
logger.info("⏭️ Skipping link checking")
for i, q in enumerate(data.copy()):
data[i] = order_keywords(q)
def validate_conference(q: dict) -> Conference | None:
"""Validate a single conference entry, returning None if invalid."""
try:
return Conference(**q)
except pydantic.ValidationError as e:
logger.error(f"❌ Validation error in conference: {e}")
logger.debug(f"Invalid data: \n{yaml.dump(q, default_flow_style=False)}")
return None
logger.info("✅ Validating conference data with Pydantic schema")
validated = [validate_conference(q) for q in data]
new_data = [c for c in validated if c is not None]
validation_errors = len(validated) - len(new_data)
if validation_errors > 0:
logger.warning(f"⚠️ {validation_errors} conferences failed validation and were skipped")
data = new_data
logger.info(f"✅ {len(data)} conferences passed validation")
# Split data by cfp
logger.info("📂 Splitting data by CFP status")
conf, tba, expired, legacy = split_data(data)
logger.info(
f"📊 Split results: {len(conf)} active, {len(tba)} TBA, {len(expired)} expired, {len(legacy)} legacy",
)
# Sort data
logger.info("🔄 Sorting conferences by CFP date")
conf.sort(key=sort_by_cfp, reverse=True)
conf.sort(key=sort_by_date_passed)
tba.sort(key=sort_by_date, reverse=True)
logger.info(f"💾 Writing {len(conf + tba)} active conferences to {out_current.name}")
write_conference_yaml(conf + tba, out_current)
expired.sort(key=sort_by_date, reverse=True)
logger.info(f"📦 Writing {len(expired)} expired conferences to {out_archive.name}")
write_conference_yaml(expired, out_archive)
legacy.sort(key=sort_by_name, reverse=True)
logger.info(f"🗂️ Writing {len(legacy)} legacy conferences to {out_legacy.name}")
write_conference_yaml(legacy, out_legacy)
logger.info("🎉 Conference data sorting and cleaning completed successfully")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Sort and clean conference data.")
parser.add_argument("--skip_links", action="store_true", help="Skip checking links", default=False)
args = parser.parse_args()
sort_data(skip_links=args.skip_links)