wiki-dump-extractor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ """My Project package."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .wiki_dump_extractor import WikiXmlDumpExtractor, WikiAvroDumpExtractor
6
+ from .wiki_sql_extractor import WikiSqlExtractor
7
+ from .download_utils import download_file
8
+
9
+ __all__ = [
10
+ "WikiXmlDumpExtractor",
11
+ "WikiAvroDumpExtractor",
12
+ "WikiSqlExtractor",
13
+ "download_file",
14
+ ]
@@ -0,0 +1,529 @@
1
+ import re
2
+ from datetime import datetime
3
+ from typing import List, Dict, ClassVar, Pattern, Optional
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass, asdict
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class Date:
10
+ year: int
11
+ month: Optional[int] = None
12
+ day: Optional[int] = None
13
+ is_approximate: bool = False
14
+
15
+ def __post_init__(self):
16
+ self.validate()
17
+
18
+ def validate(self):
19
+ if self.month is None:
20
+ return True
21
+ if not 1 <= self.month <= 12:
22
+ raise ValueError(f"Month must be between 1 and 12, got {self.month}")
23
+
24
+ if self.day is None:
25
+ return True
26
+
27
+ # Validate day based on month and year
28
+ max_days = 31 # Default for most months
29
+
30
+ if self.month in [4, 6, 9, 11]: # April, June, September, November
31
+ max_days = 30
32
+ elif self.month == 2: # February
33
+ # Check for leap year
34
+ if (self.year % 4 == 0 and self.year % 100 != 0) or (self.year % 400 == 0):
35
+ max_days = 29
36
+ else:
37
+ max_days = 28
38
+
39
+ if not 1 <= self.day <= max_days:
40
+ raise ValueError(
41
+ f"Day must be between 1 and {max_days} for month {self.month}, got {self.day}"
42
+ )
43
+
44
+ def to_string(self) -> str:
45
+ if self.year < 0:
46
+ result = f"{-self.year:04d} BC"
47
+ else:
48
+ result = f"{self.year:04d}"
49
+ if self.month is not None:
50
+ result += f"/{self.month:02d}"
51
+ if self.day is not None:
52
+ result += f"/{self.day:02d}"
53
+ return result
54
+
55
+ def to_dict(self) -> Dict:
56
+ return asdict(self)
57
+
58
+
59
+ # Define month name to number mapping
60
+ _MONTH_MAP = {
61
+ "january": 1,
62
+ "jan": 1,
63
+ "february": 2,
64
+ "feb": 2,
65
+ "march": 3,
66
+ "mar": 3,
67
+ "april": 4,
68
+ "apr": 4,
69
+ "may": 5,
70
+ "june": 6,
71
+ "jun": 6,
72
+ "july": 7,
73
+ "jul": 7,
74
+ "august": 8,
75
+ "aug": 8,
76
+ "september": 9,
77
+ "sep": 9,
78
+ "october": 10,
79
+ "oct": 10,
80
+ "november": 11,
81
+ "nov": 11,
82
+ "december": 12,
83
+ "dec": 12,
84
+ }
85
+
86
+ # Dictionary to convert written numbers to integers
87
+ _WRITTEN_NUMBERS = {
88
+ "first": 1,
89
+ "second": 2,
90
+ "third": 3,
91
+ "fourth": 4,
92
+ "fifth": 5,
93
+ "sixth": 6,
94
+ "seventh": 7,
95
+ "eighth": 8,
96
+ "ninth": 9,
97
+ "tenth": 10,
98
+ "eleventh": 11,
99
+ "twelfth": 12,
100
+ "thirteenth": 13,
101
+ "fourteenth": 14,
102
+ "fifteenth": 15,
103
+ "sixteenth": 16,
104
+ "seventeenth": 17,
105
+ "eighteenth": 18,
106
+ "nineteenth": 19,
107
+ "twentieth": 20,
108
+ "twenty-first": 21,
109
+ "twenty-second": 22,
110
+ "twenty-third": 23,
111
+ "twenty-fourth": 24,
112
+ "twenty-fifth": 25,
113
+ "twenty-sixth": 26,
114
+ "twenty-seventh": 27,
115
+ "twenty-eighth": 28,
116
+ "twenty-ninth": 29,
117
+ "thirtieth": 30,
118
+ "thirty-first": 31,
119
+ }
120
+
121
+ # Common month pattern for reuse
122
+ _MONTHS_PATTERN = (
123
+ "January|February|March|April|May|June|July|August|September|October|November|December|"
124
+ "Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec"
125
+ )
126
+
127
+
128
+ @dataclass(slots=True)
129
+ class DetectedDate:
130
+ date: Date
131
+ format: str
132
+ date_str: str
133
+
134
+ def to_dict(self) -> Dict:
135
+ return {
136
+ "date": self.date.to_dict(),
137
+ "format": self.format,
138
+ "date_str": self.date_str,
139
+ }
140
+
141
+
142
+ class DateFormat(ABC):
143
+ """Base class for all date format detectors."""
144
+
145
+ name: ClassVar[str]
146
+ pattern: ClassVar[Pattern]
147
+
148
+ @classmethod
149
+ @abstractmethod
150
+ def match_to_date(cls, match: re.Match) -> datetime:
151
+ """Convert a regex match to a datetime object.
152
+
153
+ Parameters
154
+ ----------
155
+ match : re.Match
156
+ The regex match object containing the date information
157
+
158
+ Returns
159
+ -------
160
+ datetime
161
+ The parsed datetime object
162
+
163
+ Raises
164
+ ------
165
+ ValueError
166
+ If the match cannot be converted to a valid datetime
167
+ """
168
+ pass
169
+
170
+ @classmethod
171
+ def convert_month_to_number(cls, month_name: str) -> int:
172
+ """Convert month name to its numerical representation.
173
+
174
+ Raises
175
+ ------
176
+ ValueError
177
+ If the month name is not recognized
178
+ """
179
+ month = _MONTH_MAP.get(month_name.lower())
180
+ if month is None:
181
+ raise ValueError(f"Unknown month name: {month_name}")
182
+ return month
183
+
184
+ @classmethod
185
+ def list_dates(cls, text: str) -> bool:
186
+ """Check if the text contains any dates detected by regex patterns."""
187
+ results = []
188
+ errors = []
189
+ for match in cls.pattern.finditer(text):
190
+ try:
191
+ date = cls.match_to_date(match)
192
+ if date is not None:
193
+ detected_date = DetectedDate(
194
+ date_str=match.group(0), format=cls.name, date=date
195
+ )
196
+ results.append(detected_date)
197
+
198
+ except ValueError as err:
199
+ errors.append(
200
+ f"Error parsing {cls.name} date: {match.group(0)} - {err}"
201
+ )
202
+ return results, errors
203
+
204
+
205
+ class SlashDMYMDYFormat(DateFormat):
206
+ """Format for DD/MM/YYYY or MM/DD/YYYY dates."""
207
+
208
+ name = "SLASH_DMY_MDY"
209
+ pattern = re.compile(
210
+ r"\B[^|](\d{1,2})[-/](\d{1,2})[-/](\d{1,4})(?:\s+(BC|BCE))?\b", re.IGNORECASE
211
+ )
212
+
213
+ @classmethod
214
+ def match_to_date(cls, match: re.Match) -> Date:
215
+ day, month, year, bc = match.groups()
216
+ day, month, year = int(day), int(month), int(year)
217
+ if bc:
218
+ year = -year
219
+
220
+ # Try MM/DD/YYYY first (American format)
221
+ try:
222
+ return Date(year, month, day)
223
+ except (ValueError, IndexError):
224
+ # Try DD/MM/YYYY (European format)
225
+ try:
226
+ return Date(year, day, month)
227
+ except (ValueError, IndexError):
228
+ return None
229
+
230
+
231
+ class DashYMDFormat(DateFormat):
232
+ """Format for YYYY-MM-DD dates."""
233
+
234
+ name = "DASH_YMD"
235
+ pattern = re.compile(
236
+ r"\b(\d{1,4})[-/](\d{1,2})[-/](\d{1,2})(?:\s+(BC|BCE))?\b", re.IGNORECASE
237
+ )
238
+
239
+ @classmethod
240
+ def match_to_date(cls, match: re.Match) -> Date:
241
+ groups = match.groups()
242
+ year, month, day, bc = groups
243
+ if bc:
244
+ year = -int(year)
245
+ return Date(int(year), int(month), int(day))
246
+
247
+
248
+ class DayMonthYearFormat(DateFormat):
249
+ """Format for DD Month YYYY dates."""
250
+
251
+ name = "DAY_MONTH_YEAR"
252
+ re_dmy = rf"""\b
253
+ (\d{{1,2}}) # Day (1-2 digits)
254
+ \s+
255
+ ({_MONTHS_PATTERN}) # Month (provided externally)
256
+ [,\s]+
257
+ (?:AD\s*)?
258
+ (\d{{1,4}}) # Year (1-4 digits)
259
+ (?:\s+(BC|BCE))? # Optional ' BC'
260
+ \b
261
+ """
262
+ pattern = re.compile(re_dmy, re.VERBOSE | re.IGNORECASE)
263
+
264
+ @classmethod
265
+ def match_to_date(cls, match: re.Match) -> Date:
266
+ day, month_str, year, bc = match.groups()
267
+ month = cls.convert_month_to_number(month_str)
268
+ if bc:
269
+ year = -int(year)
270
+ return Date(int(year), month, int(day))
271
+
272
+
273
+ class MonthDayYearFormat(DateFormat):
274
+ """Format for Month DD YYYY dates."""
275
+
276
+ name = "MONTH_DAY_YEAR"
277
+ re_mdy = rf"""
278
+ \b
279
+ ({_MONTHS_PATTERN}) # Month name
280
+ \s+
281
+ (\d{{1,2}}) # Day (1 or 2 digits)
282
+ (?:st|nd|rd|th)? # Optional ordinal suffix
283
+ [,\s]+
284
+ (?:AD\s*)?
285
+ (\d{{1,4}}) # Year (1 to 4 digits)
286
+ (?:\s+(BC|BCE))? # Optional ' BC'
287
+ \b
288
+ """
289
+ pattern = re.compile(re_mdy, re.VERBOSE | re.IGNORECASE)
290
+
291
+ @classmethod
292
+ def match_to_date(cls, match: re.Match) -> Date:
293
+ month_str, day, year, bc = match.groups()
294
+ month = cls.convert_month_to_number(month_str)
295
+ if bc:
296
+ year = -int(year)
297
+ return Date(int(year), month, int(day))
298
+
299
+
300
+ class MonthYearFormat(DateFormat):
301
+ """Format for Month YYYY dates."""
302
+
303
+ name = "MONTH_YEAR"
304
+ pattern = re.compile(
305
+ rf"\b({_MONTHS_PATTERN})\s*(?:AD\s*)?(\d{{2,4}})(?:\s+(BC|BCE))?\b",
306
+ re.IGNORECASE,
307
+ )
308
+
309
+ @classmethod
310
+ def match_to_date(cls, match: re.Match) -> Date:
311
+ # Check if there's a digit before the month
312
+ start_pos = match.start()
313
+
314
+ # If this is preceded by a digit and space, it's likely a "7 December 2012" format
315
+ # which should be handled by DayMonthYearFormat instead
316
+ if start_pos >= 2 and match.string[start_pos - 2 : start_pos].strip().isdigit():
317
+ return None
318
+
319
+ month_str, year, bc = match.groups()
320
+ month = cls.convert_month_to_number(month_str)
321
+ if bc:
322
+ year = -int(year)
323
+ return Date(year=int(year), month=month, day=None)
324
+
325
+
326
+ class YearFormat(DateFormat):
327
+ """Format for YYYY dates."""
328
+
329
+ name = "YEAR"
330
+ pattern = re.compile(
331
+ r"\b(?:c\.|in|from|to)\s*(?:AD\s*)?(\d{1,4})(?:\s*(BC|BCE))?[\s,\.,\)]",
332
+ re.IGNORECASE,
333
+ )
334
+
335
+ @classmethod
336
+ def match_to_date(cls, match: re.Match) -> Date:
337
+ year, bc = match.groups()
338
+ year = int(year)
339
+ if bc:
340
+ year = -year
341
+ return Date(year=year, month=None, day=None)
342
+
343
+
344
+ class WrittenDateFormat(DateFormat):
345
+ """Format for Month the day, year dates."""
346
+
347
+ name = "WRITTEN_DATE"
348
+ pattern = re.compile(
349
+ rf"\b({_MONTHS_PATTERN})\s+the\s+(?:(\d{{1,2}})(?:st|nd|rd|th)?|([a-z]+))[,\s]+(\d{{1,4}})+(?:\s+(BC|BCE))?\b",
350
+ re.IGNORECASE,
351
+ )
352
+
353
+ @classmethod
354
+ def match_to_date(cls, match: re.Match) -> datetime:
355
+ groups = match.groups()
356
+ month_str = groups[0]
357
+
358
+ # Check if the day is a number or written out
359
+ if groups[1] is not None: # Numeric day like "the 15th"
360
+ day = int(groups[1])
361
+ else: # Written day like "the third"
362
+ written_day = groups[2].lower()
363
+ if written_day in _WRITTEN_NUMBERS:
364
+ day = _WRITTEN_NUMBERS[written_day]
365
+ else:
366
+ raise ValueError(f"Unsupported written day number: {match.group(0)}")
367
+
368
+ year = int(groups[3])
369
+ if groups[4]:
370
+ year = -year
371
+ month = cls.convert_month_to_number(month_str)
372
+
373
+ return Date(year, month, day)
374
+
375
+
376
+ class WikiDateFormat(DateFormat):
377
+ """Format for {{Birth date|YYYY|MM|DD|...}}."""
378
+
379
+ name = "WIKI_BIRTH_DATE"
380
+ pattern = re.compile(r"{{[^|]*\|(\d{1,4})\|(\d{1,2})\|(\d{1,2}).*}}", re.IGNORECASE)
381
+
382
+ @classmethod
383
+ def match_to_date(cls, match: re.Match) -> datetime:
384
+ year, month, day = match.groups()
385
+ # Check if the template is a birth date template
386
+ return Date(int(year), int(month), int(day))
387
+
388
+
389
+ # Register all date format handlers
390
+ _DATE_FORMATS = [
391
+ SlashDMYMDYFormat,
392
+ DashYMDFormat,
393
+ DayMonthYearFormat,
394
+ MonthDayYearFormat,
395
+ MonthYearFormat,
396
+ WrittenDateFormat,
397
+ WikiDateFormat,
398
+ YearFormat,
399
+ ]
400
+
401
+
402
+ def extract_dates(text: str) -> List[Dict]:
403
+ """Extract dates from text with context information.
404
+
405
+ Parameters
406
+ ----------
407
+ text : str
408
+ The text to extract dates from.
409
+
410
+ Returns
411
+ -------
412
+ List[Dict]
413
+ A list of dictionaries containing:
414
+ - 'date_str': The original date string found
415
+ - 'format': The name of the date format
416
+ - 'datetime': The parsed datetime object (if parsing was successful)
417
+ """
418
+ all_results = []
419
+ all_errors = []
420
+ for date_format in _DATE_FORMATS:
421
+ results, errors = date_format.list_dates(text)
422
+ all_results.extend(results)
423
+ all_errors.extend(errors)
424
+ return all_results, all_errors
425
+
426
+
427
+ @dataclass
428
+ class DateRange:
429
+ start: Date
430
+ end: Date
431
+
432
+ def to_string(self) -> str:
433
+ start = self.start.to_string()
434
+ end = self.end.to_string()
435
+ if self.start.is_approximate:
436
+ start = f"~{start}"
437
+ if self.end.is_approximate:
438
+ end = f"~{end}"
439
+ return f"{start} - {end}"
440
+
441
+ @classmethod
442
+ def from_parsed_string(cls, date: str) -> "DateRange":
443
+ """
444
+ Parse a string representation of a date or date range into a DateRange object.
445
+
446
+ Examples:
447
+ 1810 -> ~1810/01/01 - ~1810/12/31
448
+ 1810-1812 -> ~1810/01/01 - ~1812/12/31
449
+ 1810/1812 -> ~1810/01/01 - ~1812/12/31
450
+ 1810/03/05 -> 1810/03/05 - 1810/03/05
451
+ 1810/03 -> ~1810/03/01 - ~1810/03/31
452
+ 1810/03 - 1812/05 -> ~1810/03/01 - ~1812/05/31
453
+ 1810/03/05 - 1812/05/07 -> 1810/03/05 - 1812/05/07
454
+ 1611/1612 - 1615/1617 -> ~1611/01/01 - ~1617/12/31
455
+ 1930s - 1940s -> ~1930/01/01 - ~1949/12/31
456
+ 1930s -> ~1930/01/01 - ~1939/12/31
457
+ """
458
+ date = date.strip()
459
+
460
+ if "-" in date:
461
+ start_str, end_str = date.split("-")
462
+ start_range = cls.from_parsed_string(start_str)
463
+ end_range = cls.from_parsed_string(end_str)
464
+ return DateRange(start=start_range.start, end=end_range.end)
465
+
466
+ # Replace YYYY BC with negative year
467
+ date = re.sub(r"\b(\d{1,4})\s*BC\b", r"-\1", date)
468
+
469
+ match date:
470
+ case _ if match := re.match(r"^-?\d{1,4}$", date):
471
+ # Single year (e.g., "1810")
472
+ year = int(match.group(0))
473
+ return DateRange(
474
+ start=Date(year, 1, 1, is_approximate=True),
475
+ end=Date(year, 12, 31, is_approximate=True),
476
+ )
477
+ case _ if match := re.match(r"^(\d{1,3}0)s$", date):
478
+ # Decade (e.g., "1930s")
479
+ decade_start = int(match.group(1))
480
+ return DateRange(
481
+ start=Date(decade_start, 1, 1, is_approximate=True),
482
+ end=Date(decade_start + 9, 12, 31, is_approximate=True),
483
+ )
484
+ case _ if match := re.match(r"^(-?\d{1,4})/(-?\d{1,4})$", date):
485
+ # Year range (e.g., "1810/1812")
486
+
487
+ # Only treat as year/year if the second number is > 12 (not a month)
488
+ if not 1 <= int(match.group(2)) <= 12:
489
+ # Year range (e.g., "1810/1812")
490
+ start_year, end_year = map(int, match.groups())
491
+ return DateRange(
492
+ start=Date(start_year, 1, 1, is_approximate=True),
493
+ end=Date(end_year, 12, 31, is_approximate=True),
494
+ )
495
+ else:
496
+ year, month = map(int, match.groups())
497
+ # Get last day of month
498
+ if month == 12:
499
+ last_day = 31
500
+ elif month in [4, 6, 9, 11]:
501
+ last_day = 30
502
+ elif month == 2:
503
+ # Simple leap year calculation
504
+ last_day = (
505
+ 29
506
+ if (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)
507
+ else 28
508
+ )
509
+ else:
510
+ last_day = 31
511
+ return DateRange(
512
+ start=Date(year, month, 1, is_approximate=True),
513
+ end=Date(year, month, last_day, is_approximate=True),
514
+ )
515
+ case _ if match := re.match(r"^(-?\d{1,4})/(\d{1,2})/(\d{1,2})$", date):
516
+ # Full date (e.g., "1810/03/05")
517
+ year, month, day = map(int, match.groups())
518
+ return DateRange(
519
+ start=Date(year, month, day, is_approximate=False),
520
+ end=Date(year, month, day, is_approximate=False),
521
+ )
522
+ case _:
523
+ raise ValueError(f"Unsupported date format: {date}")
524
+
525
+ def to_dict(self) -> Dict:
526
+ return {
527
+ "start": self.start.to_dict(),
528
+ "end": self.end.to_dict(),
529
+ }
@@ -0,0 +1,29 @@
1
+ import urllib.request
2
+ import os
3
+ from tqdm.auto import tqdm
4
+
5
+
6
+ def download_file(url, filepath, replace=False):
7
+ """Download a web file to a filepath, with the option to skip."""
8
+ if os.path.exists(filepath) and not replace:
9
+ print(f"{filepath} already exists, skipping download.")
10
+ return
11
+
12
+ print(f"Downloading {filepath} from {url}...")
13
+
14
+ # Get file size for progress bar
15
+ response = urllib.request.urlopen(url)
16
+ total_size = int(response.headers.get('content-length', 0))
17
+
18
+ # Create progress bar instance
19
+ progress_bar = tqdm(total=total_size, unit='iB', unit_scale=True)
20
+
21
+ def reporthook(blocknum, blocksize, totalsize):
22
+ progress_bar.update(blocksize)
23
+
24
+ try:
25
+ urllib.request.urlretrieve(url, filepath, reporthook=reporthook)
26
+ finally:
27
+ progress_bar.close()
28
+
29
+ print("Download complete")