Spaces:
Sleeping
Sleeping
Commit ·
94c25c0
1
Parent(s): e8072a2
Added 2 more delimiters to delimiters sniffing code, and logic to skip, count and add to a csv bad rows with more than allowed columns
Browse files
app.py
CHANGED
|
@@ -122,11 +122,13 @@ def get_safety_list_path() -> str:
|
|
| 122 |
_SAFETY_CACHE: dict | None = None
|
| 123 |
|
| 124 |
|
| 125 |
-
def detect_delimiter(path, candidates=(",", "|", "^", "\t", ";")):
|
| 126 |
"""Sniff a text file's field delimiter from its first lines. SQL exports here
|
| 127 |
-
are sometimes '^' or '|' rather than ','.
|
| 128 |
-
'
|
| 129 |
-
|
|
|
|
|
|
|
| 130 |
try:
|
| 131 |
with open(path, "r", encoding="utf-8", errors="replace", newline="") as fh:
|
| 132 |
lines = []
|
|
@@ -155,12 +157,62 @@ def detect_delimiter(path, candidates=(",", "|", "^", "\t", ";")):
|
|
| 155 |
|
| 156 |
|
| 157 |
def _read_csv_auto(path, **kwargs):
|
| 158 |
-
"""pd.read_csv with the delimiter auto-detected (unless caller passed sep)
|
|
|
|
| 159 |
if "sep" not in kwargs:
|
| 160 |
kwargs["sep"] = detect_delimiter(path)
|
|
|
|
| 161 |
return pd.read_csv(path, **kwargs)
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def build_whitelist_from_path(path):
|
| 165 |
"""Parse a safety-list file (xlsx or csv) into {pairs, cities, rows, fips_name}."""
|
| 166 |
if str(path).lower().endswith(".csv"):
|
|
@@ -700,6 +752,10 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 700 |
except Exception as e:
|
| 701 |
return "\n".join(log) + f"\n\nCould not parse reference list: {e}", []
|
| 702 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
# ---- Load & merge uploads (Excel or CSV) -------------------------
|
| 704 |
dfs = []
|
| 705 |
for f in uploaded_files:
|
|
@@ -707,7 +763,8 @@ def run_pipeline(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 707 |
try:
|
| 708 |
if str(name).lower().endswith(".csv"):
|
| 709 |
sep = detect_delimiter(name)
|
| 710 |
-
dfs.append(pd.read_csv(name, low_memory=False, sep=sep
|
|
|
|
| 711 |
_step(log, f"Loaded {Path(name).name} ({len(dfs[-1]):,} rows, "
|
| 712 |
f"delimiter {sep!r})")
|
| 713 |
else:
|
|
@@ -1293,7 +1350,8 @@ def _split_by_county(uploaded_files, out_dir, log, chunksize=STREAM_CHUNK_ROWS):
|
|
| 1293 |
# detect the FIPS-county column from a small sample of the first file
|
| 1294 |
first = uploaded_files[0].name
|
| 1295 |
if str(first).lower().endswith(".csv"):
|
| 1296 |
-
sample = pd.read_csv(first, nrows=50_000, low_memory=False,
|
|
|
|
| 1297 |
else:
|
| 1298 |
sample = pd.read_excel(first)
|
| 1299 |
fips_col, reason = detect_fips_county_column(sample)
|
|
@@ -1327,7 +1385,8 @@ def _split_by_county(uploaded_files, out_dir, log, chunksize=STREAM_CHUNK_ROWS):
|
|
| 1327 |
if str(nm).lower().endswith(".csv"):
|
| 1328 |
sep = detect_delimiter(nm)
|
| 1329 |
_step(log, f"Reading {Path(nm).name} (delimiter {sep!r})")
|
| 1330 |
-
for chunk in pd.read_csv(nm, chunksize=chunksize, low_memory=False,
|
|
|
|
| 1331 |
route(chunk)
|
| 1332 |
total += len(chunk)
|
| 1333 |
_step(log, f"Split: routed {total:,} rows into {len(county_paths)} counties")
|
|
@@ -1360,6 +1419,9 @@ def run_streaming(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 1360 |
except Exception as e:
|
| 1361 |
return "\n".join(log) + f"\n\nCould not parse reference list: {e}", []
|
| 1362 |
|
|
|
|
|
|
|
|
|
|
| 1363 |
_step(log, "Splitting input by county (streaming)...")
|
| 1364 |
fips_col, reason, counties, sample_cols = _split_by_county(uploaded_files, out_dir, log)
|
| 1365 |
if fips_col is None:
|
|
@@ -1418,6 +1480,7 @@ def run_streaming(uploaded_files, reference_file, blank_cities_not_in_list,
|
|
| 1418 |
outputs.append(str(p))
|
| 1419 |
output_parts = out_roll.finalize()
|
| 1420 |
outputs.extend(output_parts)
|
|
|
|
| 1421 |
|
| 1422 |
summary_cols = ["County", "FIPS", "Total Records in County", "Total Subdivisions",
|
| 1423 |
"Total Clusters Made", "Total Blank Cities", "Total Cities Assignment",
|
|
|
|
| 122 |
_SAFETY_CACHE: dict | None = None
|
| 123 |
|
| 124 |
|
| 125 |
+
def detect_delimiter(path, candidates=(",", "|", "^", "\t", ";", "\x1f", "\x1e")):
|
| 126 |
"""Sniff a text file's field delimiter from its first lines. SQL exports here
|
| 127 |
+
are sometimes '^' or '|' rather than ','. Also recognizes the ASCII control
|
| 128 |
+
separators US ('\\x1f') and RS ('\\x1e') -- ideal for messy unquoted data since
|
| 129 |
+
free-text fields never contain them. Returns the delimiter char (default ',').
|
| 130 |
+
Detects once from a small header sample so the actual (possibly chunked) read
|
| 131 |
+
can still use pandas' fast C parser with an explicit sep."""
|
| 132 |
try:
|
| 133 |
with open(path, "r", encoding="utf-8", errors="replace", newline="") as fh:
|
| 134 |
lines = []
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
def _read_csv_auto(path, **kwargs):
|
| 160 |
+
"""pd.read_csv with the delimiter auto-detected (unless caller passed sep),
|
| 161 |
+
tolerating malformed rows (a stray delimiter in one row won't abort the read)."""
|
| 162 |
if "sep" not in kwargs:
|
| 163 |
kwargs["sep"] = detect_delimiter(path)
|
| 164 |
+
kwargs.setdefault("on_bad_lines", "skip")
|
| 165 |
return pd.read_csv(path, **kwargs)
|
| 166 |
|
| 167 |
|
| 168 |
+
def scan_bad_rows(path, sep, out_dir):
|
| 169 |
+
"""Find rows with MORE fields than the header -- a stray delimiter inside a
|
| 170 |
+
field, which is exactly what pandas skips. Save them verbatim (with their line
|
| 171 |
+
number) to BadRows_<name>.csv so the offending fields can be reviewed and fixed
|
| 172 |
+
at the source. Returns (count, bad_path_or_None)."""
|
| 173 |
+
bad_path = Path(out_dir) / f"BadRows_{Path(path).stem}.csv"
|
| 174 |
+
count = 0
|
| 175 |
+
bw = None
|
| 176 |
+
try:
|
| 177 |
+
with open(path, "r", encoding="utf-8", errors="replace", newline="") as fin:
|
| 178 |
+
header = fin.readline()
|
| 179 |
+
if not header:
|
| 180 |
+
return 0, None
|
| 181 |
+
base = header.rstrip("\r\n").count(sep) # header delimiter count
|
| 182 |
+
for lineno, line in enumerate(fin, start=2):
|
| 183 |
+
s = line.rstrip("\r\n")
|
| 184 |
+
if not s:
|
| 185 |
+
continue
|
| 186 |
+
if s.count(sep) > base: # too many fields -> skipped by pandas
|
| 187 |
+
if bw is None:
|
| 188 |
+
bw = open(bad_path, "w", encoding="utf-8", newline="")
|
| 189 |
+
bw.write("line_number" + sep + header.rstrip("\r\n") + "\n")
|
| 190 |
+
bw.write(str(lineno) + sep + s + "\n")
|
| 191 |
+
count += 1
|
| 192 |
+
finally:
|
| 193 |
+
if bw is not None:
|
| 194 |
+
bw.close()
|
| 195 |
+
return count, (str(bad_path) if count else None)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def scan_inputs_for_bad_rows(uploaded_files, out_dir, log):
|
| 199 |
+
"""Scan every CSV input for malformed rows; log the count and save them for
|
| 200 |
+
review. Returns the list of BadRows_*.csv paths produced."""
|
| 201 |
+
bad_files = []
|
| 202 |
+
for f in uploaded_files:
|
| 203 |
+
nm = f.name
|
| 204 |
+
if not str(nm).lower().endswith(".csv"):
|
| 205 |
+
continue
|
| 206 |
+
count, bad_path = scan_bad_rows(nm, detect_delimiter(nm), out_dir)
|
| 207 |
+
if count:
|
| 208 |
+
bad_files.append(bad_path)
|
| 209 |
+
_step(log, f"{Path(nm).name}: {count:,} malformed row(s) skipped "
|
| 210 |
+
f"(saved to {Path(bad_path).name} for review)")
|
| 211 |
+
else:
|
| 212 |
+
_step(log, f"{Path(nm).name}: no malformed rows detected")
|
| 213 |
+
return bad_files
|
| 214 |
+
|
| 215 |
+
|
| 216 |
def build_whitelist_from_path(path):
|
| 217 |
"""Parse a safety-list file (xlsx or csv) into {pairs, cities, rows, fips_name}."""
|
| 218 |
if str(path).lower().endswith(".csv"):
|
|
|
|
| 752 |
except Exception as e:
|
| 753 |
return "\n".join(log) + f"\n\nCould not parse reference list: {e}", []
|
| 754 |
|
| 755 |
+
# ---- Scan inputs for malformed rows (save them for review) -------
|
| 756 |
+
bad_files = scan_inputs_for_bad_rows(uploaded_files, out_dir, log)
|
| 757 |
+
outputs.extend(bad_files)
|
| 758 |
+
|
| 759 |
# ---- Load & merge uploads (Excel or CSV) -------------------------
|
| 760 |
dfs = []
|
| 761 |
for f in uploaded_files:
|
|
|
|
| 763 |
try:
|
| 764 |
if str(name).lower().endswith(".csv"):
|
| 765 |
sep = detect_delimiter(name)
|
| 766 |
+
dfs.append(pd.read_csv(name, low_memory=False, sep=sep,
|
| 767 |
+
on_bad_lines="skip"))
|
| 768 |
_step(log, f"Loaded {Path(name).name} ({len(dfs[-1]):,} rows, "
|
| 769 |
f"delimiter {sep!r})")
|
| 770 |
else:
|
|
|
|
| 1350 |
# detect the FIPS-county column from a small sample of the first file
|
| 1351 |
first = uploaded_files[0].name
|
| 1352 |
if str(first).lower().endswith(".csv"):
|
| 1353 |
+
sample = pd.read_csv(first, nrows=50_000, low_memory=False,
|
| 1354 |
+
sep=detect_delimiter(first), on_bad_lines="skip")
|
| 1355 |
else:
|
| 1356 |
sample = pd.read_excel(first)
|
| 1357 |
fips_col, reason = detect_fips_county_column(sample)
|
|
|
|
| 1385 |
if str(nm).lower().endswith(".csv"):
|
| 1386 |
sep = detect_delimiter(nm)
|
| 1387 |
_step(log, f"Reading {Path(nm).name} (delimiter {sep!r})")
|
| 1388 |
+
for chunk in pd.read_csv(nm, chunksize=chunksize, low_memory=False,
|
| 1389 |
+
sep=sep, on_bad_lines="skip"):
|
| 1390 |
route(chunk)
|
| 1391 |
total += len(chunk)
|
| 1392 |
_step(log, f"Split: routed {total:,} rows into {len(county_paths)} counties")
|
|
|
|
| 1419 |
except Exception as e:
|
| 1420 |
return "\n".join(log) + f"\n\nCould not parse reference list: {e}", []
|
| 1421 |
|
| 1422 |
+
_step(log, "Scanning inputs for malformed rows...")
|
| 1423 |
+
bad_files = scan_inputs_for_bad_rows(uploaded_files, out_dir, log)
|
| 1424 |
+
|
| 1425 |
_step(log, "Splitting input by county (streaming)...")
|
| 1426 |
fips_col, reason, counties, sample_cols = _split_by_county(uploaded_files, out_dir, log)
|
| 1427 |
if fips_col is None:
|
|
|
|
| 1480 |
outputs.append(str(p))
|
| 1481 |
output_parts = out_roll.finalize()
|
| 1482 |
outputs.extend(output_parts)
|
| 1483 |
+
outputs.extend(bad_files) # malformed-row files for review
|
| 1484 |
|
| 1485 |
summary_cols = ["County", "FIPS", "Total Records in County", "Total Subdivisions",
|
| 1486 |
"Total Clusters Made", "Total Blank Cities", "Total Cities Assignment",
|