datemonkey 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.
datemonkey/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """datemonkey — Batch date parsing with ambiguity detection.
2
+
3
+ >>> from datemonkey import detect_format, parse_dates
4
+ >>> result = detect_format(["15/03/2024", "20/04/2024", "25/12/2024"])
5
+ >>> result.format.label
6
+ 'European date (DD/MM/YYYY)'
7
+ >>> batch = parse_dates(["2024-03-15", "2024-04-20", "2024-12-25"])
8
+ >>> batch.ok
9
+ True
10
+ """
11
+
12
+ from .detector import detect_format
13
+ from .excel import excel_serial_to_datetime
14
+ from .formats import (
15
+ EU_DASH,
16
+ EU_DOT,
17
+ EU_SLASH,
18
+ ISO_8601,
19
+ ISO_8601_T,
20
+ US_DASH,
21
+ US_SLASH,
22
+ DateFormat,
23
+ )
24
+ from .models import (
25
+ AmbiguityType,
26
+ BatchResult,
27
+ Confidence,
28
+ DateResult,
29
+ FormatCandidate,
30
+ FormatDetectionResult,
31
+ )
32
+ from .parser import parse_dates
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ # Core API
38
+ "detect_format",
39
+ "parse_dates",
40
+ "excel_serial_to_datetime",
41
+ # Models
42
+ "DateResult",
43
+ "BatchResult",
44
+ "FormatDetectionResult",
45
+ "FormatCandidate",
46
+ "DateFormat",
47
+ "AmbiguityType",
48
+ "Confidence",
49
+ # Common formats
50
+ "ISO_8601",
51
+ "ISO_8601_T",
52
+ "US_SLASH",
53
+ "US_DASH",
54
+ "EU_SLASH",
55
+ "EU_DASH",
56
+ "EU_DOT",
57
+ ]
datemonkey/cli.py ADDED
@@ -0,0 +1,213 @@
1
+ """datemonkey CLI — detect and parse dates from the command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import sys
9
+ from typing import Optional
10
+
11
+ from .detector import detect_format
12
+ from .formats import ALL_FORMATS
13
+ from .models import Confidence
14
+ from .parser import parse_dates
15
+
16
+
17
+ def _read_values(args: argparse.Namespace) -> list[str]:
18
+ """Read date values from arguments or stdin."""
19
+ if args.values:
20
+ return args.values
21
+
22
+ if args.file:
23
+ with open(args.file) as f:
24
+ if args.column is not None:
25
+ if args.column < 0:
26
+ print("Error: --column must be a non-negative integer", file=sys.stderr)
27
+ sys.exit(1)
28
+ reader = csv.reader(f)
29
+ col = args.column
30
+ values = []
31
+ for i, row in enumerate(reader):
32
+ if args.skip_header and i == 0:
33
+ continue
34
+ if col < len(row):
35
+ values.append(row[col])
36
+ return values
37
+ else:
38
+ return [line.strip() for line in f if line.strip()]
39
+
40
+ if not sys.stdin.isatty():
41
+ return [line.strip() for line in sys.stdin if line.strip()]
42
+
43
+ print("No input provided. Pass values as arguments, via --file, or pipe to stdin.", file=sys.stderr)
44
+ sys.exit(1)
45
+
46
+
47
+ def cmd_detect(args: argparse.Namespace) -> None:
48
+ """Handle the 'detect' subcommand."""
49
+ values = _read_values(args)
50
+ result = detect_format(
51
+ values,
52
+ locale_preference=args.locale,
53
+ )
54
+
55
+ if args.json:
56
+ out = {
57
+ "format": result.format.pattern if result.format else None,
58
+ "label": result.format.label if result.format else None,
59
+ "confidence": result.confidence.value,
60
+ "is_ambiguous": result.is_ambiguous,
61
+ "ambiguities": [a.value for a in result.ambiguities],
62
+ "sample_size": result.sample_size,
63
+ "match_count": result.match_count,
64
+ "match_ratio": round(result.match_ratio, 4),
65
+ "candidates": [
66
+ {
67
+ "format": c.format.pattern,
68
+ "label": c.format.label,
69
+ "match_count": c.match_count,
70
+ "match_ratio": round(c.match_ratio, 4),
71
+ }
72
+ for c in result.candidates[:5]
73
+ ],
74
+ "warnings": result.warnings,
75
+ }
76
+ print(json.dumps(out, indent=2))
77
+ else:
78
+ if result.format:
79
+ print(f"Detected format: {result.format.label}")
80
+ print(f" Pattern: {result.format.pattern}")
81
+ print(f" Confidence: {result.confidence.value}")
82
+ print(f" Matched: {result.match_count}/{result.sample_size}")
83
+ else:
84
+ print("Could not detect date format.")
85
+
86
+ if result.is_ambiguous:
87
+ print(f"\n AMBIGUOUS: {', '.join(a.value for a in result.ambiguities)}")
88
+
89
+ if result.candidates:
90
+ print(f"\n Top candidates:")
91
+ for c in result.candidates[:5]:
92
+ print(f" {c.format.label:40s} {c.match_count}/{c.sample_size} matches")
93
+
94
+ if result.warnings:
95
+ print(f"\n Warnings:")
96
+ for w in result.warnings:
97
+ print(f" - {w}")
98
+
99
+
100
+ def cmd_parse(args: argparse.Namespace) -> None:
101
+ """Handle the 'parse' subcommand."""
102
+ values = _read_values(args)
103
+ result = parse_dates(
104
+ values,
105
+ format=args.format,
106
+ locale_preference=args.locale,
107
+ strict=args.strict,
108
+ )
109
+
110
+ if args.json:
111
+ out = {
112
+ "detected_format": result.detected_format.pattern if result.detected_format else None,
113
+ "total": result.total,
114
+ "parsed_count": result.parsed_count,
115
+ "failed_count": result.failed_count,
116
+ "success_ratio": round(result.success_ratio, 4),
117
+ "warnings": result.warnings,
118
+ "results": [
119
+ {
120
+ "original": str(r.original),
121
+ "parsed": r.iso,
122
+ "confidence": r.confidence.value,
123
+ "warnings": r.warnings,
124
+ }
125
+ for r in result.results
126
+ ],
127
+ }
128
+ print(json.dumps(out, indent=2))
129
+ else:
130
+ if result.detected_format:
131
+ print(f"Format: {result.detected_format.label} ({result.detected_format.pattern})")
132
+ print(f"Parsed: {result.parsed_count}/{result.total}")
133
+ if result.warnings:
134
+ for w in result.warnings:
135
+ print(f" Warning: {w}")
136
+ print()
137
+ for r in result.results:
138
+ status = "OK" if r.ok else "FAIL"
139
+ parsed = r.iso or "---"
140
+ print(f" [{status:4s}] {str(r.original):30s} -> {parsed}")
141
+ for w in r.warnings:
142
+ print(f" {w}")
143
+
144
+
145
+ def cmd_formats(args: argparse.Namespace) -> None:
146
+ """Handle the 'formats' subcommand — list known formats."""
147
+ if args.json:
148
+ out = [
149
+ {
150
+ "pattern": f.pattern,
151
+ "label": f.label,
152
+ "example": f.example,
153
+ }
154
+ for f in ALL_FORMATS
155
+ ]
156
+ print(json.dumps(out, indent=2))
157
+ else:
158
+ print("Known date formats:\n")
159
+ for f in ALL_FORMATS:
160
+ print(f" {f.pattern:30s} {f.label}")
161
+ if f.example:
162
+ print(f" {'':30s} Example: {f.example}")
163
+ print()
164
+
165
+
166
+ def main(argv: Optional[list[str]] = None) -> None:
167
+ """CLI entry point."""
168
+ ap = argparse.ArgumentParser(
169
+ prog="datemonkey",
170
+ description="Batch date parsing with ambiguity detection.",
171
+ )
172
+ ap.add_argument("--version", action="version", version="datemonkey 0.1.0")
173
+
174
+ sub = ap.add_subparsers(dest="command", help="Available commands")
175
+
176
+ # ── detect ───────────────────────────────────────────────────────
177
+ p_detect = sub.add_parser("detect", help="Detect date format from values")
178
+ p_detect.add_argument("values", nargs="*", help="Date values to analyze")
179
+ p_detect.add_argument("--file", "-f", help="Read values from a file (one per line, or CSV)")
180
+ p_detect.add_argument("--column", "-c", type=int, help="CSV column index (0-based)")
181
+ p_detect.add_argument("--skip-header", action="store_true", help="Skip first row of CSV")
182
+ p_detect.add_argument("--locale", "-l", help="Locale preference: 'us' or 'eu'")
183
+ p_detect.add_argument("--json", "-j", action="store_true", help="Output as JSON")
184
+ p_detect.set_defaults(func=cmd_detect)
185
+
186
+ # ── parse ────────────────────────────────────────────────────────
187
+ p_parse = sub.add_parser("parse", help="Parse date values")
188
+ p_parse.add_argument("values", nargs="*", help="Date values to parse")
189
+ p_parse.add_argument("--file", "-f", help="Read values from a file")
190
+ p_parse.add_argument("--column", "-c", type=int, help="CSV column index (0-based)")
191
+ p_parse.add_argument("--skip-header", action="store_true", help="Skip first row of CSV")
192
+ p_parse.add_argument("--format", help="Enforce a specific strftime format")
193
+ p_parse.add_argument("--locale", "-l", help="Locale preference: 'us' or 'eu'")
194
+ p_parse.add_argument("--strict", "-s", action="store_true", help="Fail on ambiguity")
195
+ p_parse.add_argument("--json", "-j", action="store_true", help="Output as JSON")
196
+ p_parse.set_defaults(func=cmd_parse)
197
+
198
+ # ── formats ──────────────────────────────────────────────────────
199
+ p_formats = sub.add_parser("formats", help="List known date formats")
200
+ p_formats.add_argument("--json", "-j", action="store_true", help="Output as JSON")
201
+ p_formats.set_defaults(func=cmd_formats)
202
+
203
+ args = ap.parse_args(argv)
204
+
205
+ if not args.command:
206
+ ap.print_help()
207
+ sys.exit(1)
208
+
209
+ args.func(args)
210
+
211
+
212
+ if __name__ == "__main__":
213
+ main()
datemonkey/detector.py ADDED
@@ -0,0 +1,306 @@
1
+ """Format detection engine — the core of datemonkey.
2
+
3
+ Analyzes a batch of values and determines the most likely date format,
4
+ reporting ambiguity instead of silently guessing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ from typing import Any, Optional, Sequence
11
+
12
+ from .formats import (
13
+ ALL_FORMATS,
14
+ AMBIGUOUS_PAIRS,
15
+ could_be_excel_serial,
16
+ get_ambiguous_partner,
17
+ is_date_like,
18
+ )
19
+ from .models import (
20
+ AmbiguityType,
21
+ Confidence,
22
+ DateFormat,
23
+ FormatCandidate,
24
+ FormatDetectionResult,
25
+ )
26
+
27
+
28
+ def _try_parse(value: str, fmt: DateFormat) -> Optional[datetime.datetime]:
29
+ """Try to parse a string with a given format. Returns None on failure."""
30
+ try:
31
+ return datetime.datetime.strptime(value.strip(), fmt.pattern)
32
+ except (ValueError, OverflowError):
33
+ return None
34
+
35
+
36
+ def _has_two_digit_year(fmt: DateFormat) -> bool:
37
+ """Check if format uses a two-digit year."""
38
+ return "%y" in fmt.pattern and "%Y" not in fmt.pattern
39
+
40
+
41
+ def _score_candidates(
42
+ values: list[str], formats: list[DateFormat]
43
+ ) -> list[FormatCandidate]:
44
+ """Score each format against the batch of values."""
45
+ candidates = []
46
+ for fmt in formats:
47
+ match_count = 0
48
+ for v in values:
49
+ if _try_parse(v, fmt) is not None:
50
+ match_count += 1
51
+ if match_count > 0:
52
+ candidates.append(
53
+ FormatCandidate(
54
+ format=fmt,
55
+ match_count=match_count,
56
+ sample_size=len(values),
57
+ )
58
+ )
59
+ # Sort by match count descending, then by format specificity (longer pattern = more specific)
60
+ candidates.sort(key=lambda c: (c.match_count, len(c.format.pattern)), reverse=True)
61
+ return candidates
62
+
63
+
64
+ def _check_day_month_ambiguity(
65
+ values: list[str],
66
+ best: FormatCandidate,
67
+ candidates: list[FormatCandidate],
68
+ ) -> Optional[FormatCandidate]:
69
+ """Check if the best format has a DD/MM vs MM/DD ambiguous partner
70
+ that matches equally well.
71
+
72
+ Returns the ambiguous partner candidate if ambiguity exists, None otherwise.
73
+ """
74
+ partner_fmt = get_ambiguous_partner(best.format)
75
+ if partner_fmt is None:
76
+ return None
77
+
78
+ for c in candidates:
79
+ if c.format == partner_fmt and c.match_count == best.match_count:
80
+ return c
81
+ return None
82
+
83
+
84
+ def _resolve_day_month(
85
+ values: list[str],
86
+ fmt_a: DateFormat,
87
+ fmt_b: DateFormat,
88
+ ) -> Optional[DateFormat]:
89
+ """Try to resolve DD/MM vs MM/DD ambiguity by looking for values where
90
+ the day field exceeds 12 (which would be invalid as a month).
91
+
92
+ Returns the resolved format, or None if truly ambiguous.
93
+ """
94
+ a_only = 0 # Values that parse with A but not B
95
+ b_only = 0 # Values that parse with B but not A
96
+
97
+ for v in values:
98
+ parsed_a = _try_parse(v, fmt_a)
99
+ parsed_b = _try_parse(v, fmt_b)
100
+ if parsed_a is not None and parsed_b is None:
101
+ a_only += 1
102
+ elif parsed_b is not None and parsed_a is None:
103
+ b_only += 1
104
+
105
+ if a_only > 0 and b_only == 0:
106
+ return fmt_a
107
+ if b_only > 0 and a_only == 0:
108
+ return fmt_b
109
+ return None
110
+
111
+
112
+ def detect_format(
113
+ values: Sequence[Any],
114
+ *,
115
+ locale_preference: Optional[str] = None,
116
+ formats: Optional[list[DateFormat]] = None,
117
+ ) -> FormatDetectionResult:
118
+ """Detect the date format of a batch of values.
119
+
120
+ Examines the values and determines the most likely date format,
121
+ explicitly reporting ambiguity rather than silently guessing.
122
+
123
+ Args:
124
+ values: A sequence of date-like values (strings, ints, floats).
125
+ locale_preference: Hint for resolving DD/MM vs MM/DD ambiguity.
126
+ Use "us" for MM/DD preference, "eu" for DD/MM preference.
127
+ Only used when data alone cannot resolve the ambiguity.
128
+ formats: Custom list of DateFormat objects to test against.
129
+ If None, uses all built-in formats.
130
+
131
+ Returns:
132
+ FormatDetectionResult with detected format, confidence, and
133
+ ambiguity information.
134
+ """
135
+ if not values:
136
+ return FormatDetectionResult(
137
+ warnings=["Empty input: no values to analyze."]
138
+ )
139
+
140
+ # Coerce to strings, track excel serial candidates
141
+ str_values: list[str] = []
142
+ excel_count = 0
143
+ blank_count = 0
144
+
145
+ for v in values:
146
+ if v is None or (isinstance(v, str) and v.strip() == ""):
147
+ blank_count += 1
148
+ continue
149
+ s = str(v).strip()
150
+ if could_be_excel_serial(s):
151
+ excel_count += 1
152
+ str_values.append(s)
153
+
154
+ if not str_values:
155
+ return FormatDetectionResult(
156
+ sample_size=len(values),
157
+ warnings=["All values are blank or None."],
158
+ )
159
+
160
+ # Filter to date-like values for format detection
161
+ date_like = [v for v in str_values if is_date_like(v)]
162
+
163
+ # If most values look like Excel serials and few look like dates
164
+ if excel_count > len(date_like) and excel_count >= len(str_values) * 0.5:
165
+ from .excel import EXCEL_SERIAL_FORMAT
166
+
167
+ return FormatDetectionResult(
168
+ format=EXCEL_SERIAL_FORMAT,
169
+ confidence=Confidence.HIGH if excel_count == len(str_values) else Confidence.MEDIUM,
170
+ sample_size=len(str_values),
171
+ match_count=excel_count,
172
+ warnings=(
173
+ [f"{blank_count} blank values skipped."] if blank_count > 0 else []
174
+ ),
175
+ )
176
+
177
+ if not date_like:
178
+ return FormatDetectionResult(
179
+ sample_size=len(str_values),
180
+ warnings=[
181
+ "No values matched any known date pattern.",
182
+ f"{excel_count} values look like possible Excel serial numbers."
183
+ if excel_count > 0
184
+ else "No date-like values found.",
185
+ ],
186
+ )
187
+
188
+ # Score all candidate formats
189
+ test_formats = formats or ALL_FORMATS
190
+ candidates = _score_candidates(date_like, test_formats)
191
+
192
+ if not candidates:
193
+ return FormatDetectionResult(
194
+ sample_size=len(date_like),
195
+ warnings=["No formats matched any values."],
196
+ )
197
+
198
+ best = candidates[0]
199
+ ambiguities: list[AmbiguityType] = []
200
+ warnings: list[str] = []
201
+
202
+ if blank_count > 0:
203
+ warnings.append(f"{blank_count} blank values skipped.")
204
+
205
+ non_matching = len(date_like) - best.match_count
206
+ if non_matching > 0:
207
+ warnings.append(
208
+ f"{non_matching}/{len(date_like)} date-like values did not match "
209
+ f"the detected format ({best.format.label})."
210
+ )
211
+
212
+ # Check DD/MM vs MM/DD ambiguity
213
+ ambig_partner = _check_day_month_ambiguity(date_like, best, candidates)
214
+ if ambig_partner is not None:
215
+ # Try to resolve from data
216
+ resolved = _resolve_day_month(date_like, best.format, ambig_partner.format)
217
+ if resolved is not None:
218
+ # Data resolves it — pick the right one
219
+ if resolved != best.format:
220
+ # Swap: the partner is actually the correct one
221
+ for c in candidates:
222
+ if c.format == resolved:
223
+ best = c
224
+ break
225
+ else:
226
+ # Truly ambiguous
227
+ ambiguities.append(AmbiguityType.DAY_MONTH_SWAP)
228
+ warnings.append(
229
+ f"Ambiguous: cannot distinguish {best.format.label} from "
230
+ f"{ambig_partner.format.label} using data alone."
231
+ )
232
+ # Apply locale preference if provided
233
+ if locale_preference:
234
+ lp = locale_preference.lower()
235
+ if lp in ("us", "en_us", "en-us", "american"):
236
+ # Prefer MM/DD
237
+ for c in candidates:
238
+ if c.format.pattern.startswith("%m"):
239
+ best = c
240
+ break
241
+ warnings.append(
242
+ f"Locale preference '{locale_preference}' applied: "
243
+ f"using {best.format.label}."
244
+ )
245
+ elif lp in ("eu", "european", "en_gb", "en-gb", "british", "de", "fr", "es", "it"):
246
+ # Prefer DD/MM
247
+ for c in candidates:
248
+ if c.format.pattern.startswith("%d"):
249
+ best = c
250
+ break
251
+ warnings.append(
252
+ f"Locale preference '{locale_preference}' applied: "
253
+ f"using {best.format.label}."
254
+ )
255
+
256
+ # Check two-digit year
257
+ if _has_two_digit_year(best.format):
258
+ ambiguities.append(AmbiguityType.TWO_DIGIT_YEAR)
259
+ warnings.append(
260
+ "Two-digit year detected. Python's strptime interprets "
261
+ "00-68 as 2000-2068 and 69-99 as 1969-1999."
262
+ )
263
+
264
+ # Check mixed formats (multiple candidates with high match counts)
265
+ if len(candidates) >= 2:
266
+ second = candidates[1]
267
+ # If second-best matches a significant portion and is a different "family"
268
+ if (
269
+ second.match_count >= len(date_like) * 0.2
270
+ and get_ambiguous_partner(best.format) != second.format
271
+ ):
272
+ ambiguities.append(AmbiguityType.MIXED_FORMATS)
273
+ warnings.append(
274
+ f"Possible mixed formats: {best.format.label} "
275
+ f"({best.match_count} matches) and {second.format.label} "
276
+ f"({second.match_count} matches)."
277
+ )
278
+
279
+ # Determine confidence
280
+ if best.match_count == len(date_like) and not ambiguities:
281
+ confidence = Confidence.HIGH
282
+ elif best.match_count >= len(date_like) * 0.8 and AmbiguityType.DAY_MONTH_SWAP not in ambiguities:
283
+ confidence = Confidence.MEDIUM
284
+ elif AmbiguityType.DAY_MONTH_SWAP in ambiguities:
285
+ confidence = Confidence.LOW
286
+ else:
287
+ confidence = Confidence.LOW
288
+
289
+ # Assign confidence to candidates
290
+ for c in candidates:
291
+ if c.match_count == len(date_like) and get_ambiguous_partner(c.format) is None:
292
+ c.confidence = Confidence.HIGH
293
+ elif c.match_count >= len(date_like) * 0.8:
294
+ c.confidence = Confidence.MEDIUM
295
+ else:
296
+ c.confidence = Confidence.LOW
297
+
298
+ return FormatDetectionResult(
299
+ format=best.format,
300
+ confidence=confidence,
301
+ ambiguities=ambiguities,
302
+ candidates=candidates,
303
+ sample_size=len(date_like),
304
+ match_count=best.match_count,
305
+ warnings=warnings,
306
+ )
datemonkey/excel.py ADDED
@@ -0,0 +1,120 @@
1
+ """Excel serial date number support.
2
+
3
+ Excel stores dates as serial numbers:
4
+ - Integer part = days since epoch (1900-01-01 = 1 in the 1900 system)
5
+ - Fractional part = time of day (0.5 = noon)
6
+
7
+ Note: Excel has a known bug where it treats 1900 as a leap year.
8
+ Serial number 60 = 1900-02-29 (which doesn't exist). We handle this
9
+ by matching Excel's behavior for compatibility.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime
15
+ from typing import Optional, Union
16
+
17
+ from .models import Confidence, DateFormat, DateResult
18
+
19
+ # Sentinel format object for Excel serial dates
20
+ EXCEL_SERIAL_FORMAT = DateFormat(
21
+ pattern="EXCEL_SERIAL",
22
+ label="Excel serial date number",
23
+ example="45678",
24
+ )
25
+
26
+ # Excel epoch: 1899-12-31 (serial 1 = epoch + 1 day = 1900-01-01)
27
+ _EXCEL_EPOCH = datetime.datetime(1899, 12, 31)
28
+
29
+ # Reasonable range for Excel serial dates
30
+ _MIN_SERIAL = 1 # 1900-01-01
31
+ _MAX_SERIAL = 2958465 # 9999-12-31
32
+
33
+
34
+ def excel_serial_to_datetime(
35
+ serial: Union[int, float, str],
36
+ ) -> Optional[datetime.datetime]:
37
+ """Convert an Excel serial date number to a Python datetime.
38
+
39
+ Args:
40
+ serial: The Excel serial number (int, float, or numeric string).
41
+
42
+ Returns:
43
+ A datetime, or None if the value is out of range or invalid.
44
+ """
45
+ try:
46
+ num = float(serial)
47
+ except (ValueError, TypeError):
48
+ return None
49
+
50
+ if num < _MIN_SERIAL or num > _MAX_SERIAL:
51
+ return None
52
+
53
+ # Handle the Lotus 1-2-3 leap year bug:
54
+ # Excel thinks 1900-02-29 exists (serial 60).
55
+ # For serials > 60, subtract 1 to correct the off-by-one.
56
+ if int(num) == 60:
57
+ # Serial 60 is the non-existent Feb 29, 1900.
58
+ # Return March 1, 1900 to match common convention.
59
+ return datetime.datetime(1900, 3, 1)
60
+
61
+ days = int(num)
62
+ fraction = num - days
63
+
64
+ if days > 60:
65
+ days -= 1
66
+
67
+ dt = _EXCEL_EPOCH + datetime.timedelta(days=days)
68
+
69
+ # Add time component from fractional part
70
+ if fraction > 0:
71
+ total_seconds = round(fraction * 86400)
72
+ dt += datetime.timedelta(seconds=total_seconds)
73
+
74
+ return dt
75
+
76
+
77
+ def parse_excel_serial(
78
+ value: Union[int, float, str],
79
+ row_index: Optional[int] = None,
80
+ ) -> DateResult:
81
+ """Parse a single Excel serial date number.
82
+
83
+ Args:
84
+ value: The serial number to parse.
85
+ row_index: Optional row index for tracking.
86
+
87
+ Returns:
88
+ A DateResult with the parsed datetime.
89
+ """
90
+ parsed = excel_serial_to_datetime(value)
91
+ warnings: list[str] = []
92
+
93
+ if parsed is None:
94
+ return DateResult(
95
+ original=value,
96
+ confidence=Confidence.FAILED,
97
+ format_used=EXCEL_SERIAL_FORMAT,
98
+ warnings=["Not a valid Excel serial date number."],
99
+ row_index=row_index,
100
+ )
101
+
102
+ # Flag the Feb 29 1900 bug
103
+ try:
104
+ num = float(value)
105
+ if int(num) == 60:
106
+ warnings.append(
107
+ "Excel serial 60 maps to non-existent 1900-02-29. "
108
+ "Mapped to 1900-03-01."
109
+ )
110
+ except (ValueError, TypeError):
111
+ pass
112
+
113
+ return DateResult(
114
+ original=value,
115
+ parsed=parsed,
116
+ format_used=EXCEL_SERIAL_FORMAT,
117
+ confidence=Confidence.HIGH,
118
+ warnings=warnings,
119
+ row_index=row_index,
120
+ )