Skip to content

Commit bd9ffcb

Browse files
authored
Merge pull request #153 from KristjanESPERANTO/validator
Improve holiday validator & data
2 parents 703f04d + e3938f1 commit bd9ffcb

12 files changed

Lines changed: 278 additions & 82 deletions

File tree

.github/workflows/check-data.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Check Data
22

3-
on: [push]
3+
on: [push, pull_request]
44

55
jobs:
66
build:
@@ -9,9 +9,9 @@ jobs:
99
matrix:
1010
python-version: ["3.10"]
1111
steps:
12-
- uses: actions/checkout@v3
12+
- uses: actions/checkout@v6
1313
- name: Set up Python ${{ matrix.python-version }}
14-
uses: actions/setup-python@v3
14+
uses: actions/setup-python@v6
1515
with:
1616
python-version: ${{ matrix.python-version }}
1717
- name: Install dependencies

bin/test_all_tables.py

Lines changed: 239 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,215 @@
1-
#!/usr/bin/env python
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
24

35
from argparse import ArgumentParser
46
from pathlib import Path
7+
import sys
8+
from uuid import UUID
59

610
import pandas as pd
711

812

9-
def _check_duration(df: pd.DataFrame, filename: Path) -> None:
10-
"""Parses StartDate and EndDate as YYYY-MM-DD and checks that EndDate if after the
11-
StartDate"""
13+
REQUIRED_COLUMNS = {
14+
"Id",
15+
"Country",
16+
"StartDate",
17+
"EndDate",
18+
"Type",
19+
"RegionalScope",
20+
"Name",
21+
}
22+
23+
# Columns that must exist and must not be empty per row.
24+
REQUIRED_NON_EMPTY = {
25+
"Id",
26+
"Country",
27+
"StartDate",
28+
"Type",
29+
"RegionalScope",
30+
"Name",
31+
}
32+
33+
34+
def _csv_line(row_index: int) -> int:
35+
# +1 for header, +1 for 0-based index -> line number
36+
return row_index + 2
37+
38+
39+
def _read_csv(path: Path) -> pd.DataFrame:
40+
# Use UTF-8 with BOM and force strings to avoid accidental type coercion.
41+
# Treat only empty fields as missing.
42+
return pd.read_csv(
43+
path,
44+
sep=";",
45+
encoding="utf-8-sig",
46+
dtype=str,
47+
keep_default_na=False,
48+
na_values=[""],
49+
)
50+
51+
52+
def _check_required_columns(df: pd.DataFrame, filename: Path) -> list[str]:
53+
missing = REQUIRED_COLUMNS - set(df.columns)
54+
if not missing:
55+
return []
56+
return [f"{filename}: missing required columns: {sorted(missing)}"]
57+
58+
59+
def _check_required_values(df: pd.DataFrame, filename: Path) -> list[str]:
60+
errors: list[str] = []
61+
for col in sorted(REQUIRED_NON_EMPTY):
62+
if col not in df.columns:
63+
continue
64+
missing = df[col].isna()
65+
if not missing.any():
66+
continue
67+
for idx in missing[missing].index[:10]:
68+
errors.append(f"{filename} (line {_csv_line(int(idx))}): missing value in column '{col}'")
69+
more = int(missing.sum()) - min(10, int(missing.sum()))
70+
if more > 0:
71+
errors.append(f"{filename}: {more} more missing '{col}' values not shown")
72+
return errors
73+
1274

13-
df.StartDate = pd.to_datetime(df.StartDate, format="%Y-%m-%d")
14-
df.EndDate = pd.to_datetime(df.EndDate, format="%Y-%m-%d")
15-
positive_duration_mask = df.EndDate.isna() | ((df.EndDate >= df.StartDate))
16-
if not positive_duration_mask.all():
17-
raise ValueError(
18-
f"Holidays with negative duration in '{filename}':\n"
19-
f"{df[~positive_duration_mask]}"
75+
def _check_country_column(df: pd.DataFrame, filename: Path, expected_country: str) -> list[str]:
76+
if "Country" not in df:
77+
return []
78+
wrong = df[~df["Country"].isna() & (df["Country"] != expected_country)]
79+
if wrong.empty:
80+
return []
81+
lines = ", ".join(str(_csv_line(int(i))) for i in wrong.index[:10])
82+
return [
83+
f"{filename}: Country column mismatch (expected '{expected_country}') at lines {lines}"
84+
]
85+
86+
87+
def _parse_dates(df: pd.DataFrame, filename: Path) -> tuple[pd.Series, pd.Series, list[str]]:
88+
errors: list[str] = []
89+
90+
start_raw = df["StartDate"]
91+
end_raw = df["EndDate"]
92+
93+
start_parsed = pd.to_datetime(start_raw, format="%Y-%m-%d", errors="coerce")
94+
end_parsed = pd.to_datetime(end_raw, format="%Y-%m-%d", errors="coerce")
95+
96+
invalid_start = start_raw.notna() & start_parsed.isna()
97+
invalid_end = end_raw.notna() & end_parsed.isna()
98+
99+
if invalid_start.any():
100+
bad = df[invalid_start].head(10)
101+
for idx, val in bad["StartDate"].items():
102+
errors.append(f"{filename} (line {_csv_line(int(idx))}): invalid StartDate '{val}'")
103+
104+
if invalid_end.any():
105+
bad = df[invalid_end].head(10)
106+
for idx, val in bad["EndDate"].items():
107+
errors.append(f"{filename} (line {_csv_line(int(idx))}): invalid EndDate '{val}'")
108+
109+
return start_parsed, end_parsed, errors
110+
111+
112+
def _check_duration(
113+
start_dates: pd.Series, end_dates: pd.Series, filename: Path
114+
) -> list[str]:
115+
# EndDate may be empty -> allowed. If present, must be >= StartDate.
116+
mask = end_dates.notna() & start_dates.notna() & (end_dates < start_dates)
117+
if not mask.any():
118+
return []
119+
120+
errors: list[str] = []
121+
for idx in mask[mask].index[:10]:
122+
errors.append(
123+
f"{filename} (line {_csv_line(int(idx))}): EndDate < StartDate ({end_dates.loc[idx].date()} < {start_dates.loc[idx].date()})"
20124
)
125+
more = int(mask.sum()) - len(errors)
126+
if more > 0:
127+
errors.append(f"{filename}: {more} more negative durations not shown")
128+
return errors
129+
130+
131+
def _check_sorting(start_dates: pd.Series, filename: Path) -> list[str]:
132+
# Only compare rows with valid StartDate values.
133+
errors: list[str] = []
134+
for i in range(len(start_dates) - 1):
135+
a = start_dates.iloc[i]
136+
b = start_dates.iloc[i + 1]
137+
if pd.isna(a) or pd.isna(b):
138+
continue
139+
if a > b:
140+
errors.append(
141+
f"{filename}: not sorted by StartDate: line {_csv_line(i)} ({a.date()}) > line {_csv_line(i + 1)} ({b.date()})"
142+
)
143+
if len(errors) >= 5:
144+
break
145+
return errors
146+
147+
148+
def _split_csv_list(value: str) -> list[str]:
149+
return [part.strip() for part in value.split(",") if part.strip()]
150+
151+
152+
def _check_subdivisions(df: pd.DataFrame, subdivisions: set[str], filename: Path) -> list[str]:
153+
if not subdivisions:
154+
return []
155+
if "Subdivisions" not in df.columns:
156+
return []
157+
158+
used: set[str] = set()
159+
for val in df["Subdivisions"].dropna():
160+
used.update(_split_csv_list(val))
21161

162+
unknown = used - subdivisions
163+
if not unknown:
164+
return []
165+
return [f"{filename}: unknown Subdivisions values: {sorted(unknown)}"]
166+
167+
168+
def _check_uuids_and_global_uniqueness(
169+
df: pd.DataFrame, filename: Path, seen: dict[str, tuple[Path, int]]
170+
) -> list[str]:
171+
if "Id" not in df.columns:
172+
return [f"{filename}: missing Id column"]
173+
174+
errors: list[str] = []
175+
for idx, raw in df["Id"].items():
176+
line = _csv_line(int(idx))
177+
if pd.isna(raw) or str(raw).strip() == "":
178+
errors.append(f"{filename} (line {line}): missing UUID")
179+
continue
180+
181+
try:
182+
normalized = str(UUID(str(raw))).lower()
183+
except (ValueError, AttributeError, TypeError):
184+
errors.append(f"{filename} (line {line}): invalid UUID '{raw}'")
185+
continue
22186

23-
def _check_subdivisions(
24-
df: pd.DataFrame, subdivisions: set[str], filename: Path
25-
) -> None:
26-
"""Checks that the subdivisions in df are also present in subdivisions.csv"""
27-
if "Subdivisions" in df:
28-
unknown_subdivisions = set(
29-
df.Subdivisions.dropna().map(lambda x: x.split(",")).explode()
30-
) - set(subdivisions)
31-
if unknown_subdivisions:
32-
raise ValueError(
33-
f"Unknown subdivisions in {filename}: {unknown_subdivisions}. "
34-
f"Known are {subdivisions}"
187+
if normalized in seen:
188+
prev_file, prev_line = seen[normalized]
189+
errors.append(
190+
"Duplicate UUID "
191+
+ normalized
192+
+ ":\n"
193+
+ f" - {prev_file} (line {prev_line})\n"
194+
+ f" - {filename} (line {line})"
35195
)
196+
else:
197+
seen[normalized] = (filename, line)
198+
199+
return errors
200+
201+
202+
def _load_subdivisions(country_dir: Path) -> set[str]:
203+
subdivisions_csv = country_dir / "subdivisions.csv"
204+
if not subdivisions_csv.exists():
205+
return set()
206+
try:
207+
df = _read_csv(subdivisions_csv)
208+
except pd.errors.ParserError:
209+
return set()
210+
if "ShortName" not in df.columns:
211+
return set()
212+
return set(df["ShortName"].dropna().astype(str))
36213

37214

38215
def main() -> None:
@@ -42,23 +219,48 @@ def main() -> None:
42219
)
43220
args = parser.parse_args()
44221

45-
for country_dir in sorted(args.data_folder.iterdir()):
46-
if not country_dir.is_dir():
47-
continue
222+
errors: list[str] = []
223+
seen_uuids: dict[str, tuple[Path, int]] = {}
48224

49-
try:
50-
df_subdivisions = pd.read_csv(country_dir / "subdivisions.csv", sep=";", keep_default_na=False)
51-
subdivisions = set(df_subdivisions.ShortName)
52-
except FileNotFoundError:
53-
subdivisions = {}
225+
for country_dir in sorted([p for p in args.data_folder.iterdir() if p.is_dir()]):
226+
expected_country = country_dir.name.upper()
227+
subdivisions = _load_subdivisions(country_dir)
228+
229+
holidays_dir = country_dir / "holidays"
230+
if not holidays_dir.exists():
231+
continue
54232

55-
for holidays_file in (country_dir / "holidays").iterdir():
233+
for holidays_file in sorted(holidays_dir.glob("*.csv")):
56234
try:
57-
df = pd.read_csv(holidays_file, sep=";")
235+
df = _read_csv(holidays_file)
58236
except pd.errors.ParserError as error:
59-
raise ValueError(f"Could not parse '{holidays_file}'") from error
60-
_check_subdivisions(df, subdivisions, holidays_file)
61-
_check_duration(df, holidays_file)
237+
errors.append(f"{holidays_file}: could not parse CSV - {error}")
238+
continue
239+
240+
errors.extend(_check_required_columns(df, holidays_file))
241+
if REQUIRED_COLUMNS - set(df.columns):
242+
# Don’t cascade on missing columns.
243+
continue
244+
245+
errors.extend(_check_required_values(df, holidays_file))
246+
247+
errors.extend(_check_country_column(df, holidays_file, expected_country))
248+
errors.extend(_check_uuids_and_global_uniqueness(df, holidays_file, seen_uuids))
249+
250+
start_dates, end_dates, date_errors = _parse_dates(df, holidays_file)
251+
errors.extend(date_errors)
252+
errors.extend(_check_duration(start_dates, end_dates, holidays_file))
253+
errors.extend(_check_sorting(start_dates, holidays_file))
254+
errors.extend(_check_subdivisions(df, subdivisions, holidays_file))
255+
256+
if errors:
257+
print(f"Validation failed with {len(errors)} error(s):\n", file=sys.stderr)
258+
for message in errors:
259+
print(f"- {message}", file=sys.stderr)
260+
sys.exit(1)
261+
262+
print("✓ All validations passed")
62263

63264

64-
main()
265+
if __name__ == "__main__":
266+
main()

0 commit comments

Comments
 (0)