1- #!/usr/bin/env python
1+ #!/usr/bin/env python3
2+
3+ from __future__ import annotations
24
35from argparse import ArgumentParser
46from pathlib import Path
7+ import sys
8+ from uuid import UUID
59
610import 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
38215def 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