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 +57 -0
- datemonkey/cli.py +213 -0
- datemonkey/detector.py +306 -0
- datemonkey/excel.py +120 -0
- datemonkey/formats.py +133 -0
- datemonkey/models.py +196 -0
- datemonkey/parser.py +174 -0
- datemonkey-0.1.0.dist-info/METADATA +198 -0
- datemonkey-0.1.0.dist-info/RECORD +13 -0
- datemonkey-0.1.0.dist-info/WHEEL +5 -0
- datemonkey-0.1.0.dist-info/entry_points.txt +2 -0
- datemonkey-0.1.0.dist-info/licenses/LICENSE +21 -0
- datemonkey-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|