diff --git a/git-hooks/pre-commit.py b/git-hooks/pre-commit.py index 9e477accd5..ad9265b7a6 100755 --- a/git-hooks/pre-commit.py +++ b/git-hooks/pre-commit.py @@ -138,51 +138,49 @@ def iter_csv(repo_root: Path) -> Iterable[Path]: yield csv_file def sort_csv_by_slug(repo_root: Path, delimiter: str = ",") -> None: - """ - Sort CSV files by slug column without modifying quoting. - Uses the same naive delimiter parsing as rewrite_urls(). - """ + """Sort CSV files by their parsed slug field without rewriting records.""" print("Sorting CSV files by slug") for csv_file in iter_csv(repo_root): newline_style = detect_newline(csv_file) - with csv_file.open("r", newline="") as f: - lines = f.readlines() + text = f.read() + physical_lines = text.splitlines(keepends=True) - if len(lines) <= 1: + if not physical_lines: continue - header_line = lines[0].rstrip("\r\n") - header_parts = [h.strip().strip('"') for h in header_line.split(delimiter)] + records = [] + reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter) + start_line = 0 + for fields in reader: + end_line = reader.line_num + raw_record = "".join(physical_lines[start_line:end_line]) + records.append((fields, raw_record)) + start_line = end_line + + if len(records) <= 1: + continue + header_fields, header_record = records[0] + header_parts = [field.strip().strip('"') for field in header_fields] if "slug" not in header_parts: continue slug_idx = header_parts.index("slug") - data_lines = lines[1:] - - def slug_key(raw_line: str) -> str: - parts = raw_line.rstrip("\r\n").split(delimiter) - - if slug_idx >= len(parts): + def slug_key(record: tuple[list[str], str]) -> str: + fields = record[0] + if slug_idx >= len(fields): return "" + return fields[slug_idx].strip().strip('"') - cell = parts[slug_idx].strip() - - # normalize quoted slug for sorting only - if cell.startswith('"') and cell.endswith('"'): - cell = cell[1:-1] - - return cell - - rows_sorted = sorted(data_lines, key=slug_key) + rows_sorted = sorted(records[1:], key=slug_key) with csv_file.open("w", newline="") as f: - f.write(header_line + newline_style) - for row in rows_sorted: - f.write(row.rstrip("\r\n") + newline_style) + f.write(header_record.rstrip("\r\n") + newline_style) + for _, raw_record in rows_sorted: + f.write(raw_record.rstrip("\r\n") + newline_style) subprocess.run(["git", "add", str(csv_file)], check=True) print(f" sorted: {csv_file}") diff --git a/tests/test_offer_schema.py b/tests/test_offer_schema.py new file mode 100644 index 0000000000..9c9e57c3f6 --- /dev/null +++ b/tests/test_offer_schema.py @@ -0,0 +1,42 @@ +import csv +import unittest +from pathlib import Path + + +class OfferSchemaTests(unittest.TestCase): + """Guard the common columns shared by every canonical offer category.""" + + OFFER_DIRECTORY = Path(__file__).parents[1] / "references" / "offers" + + def test_every_category_declares_tag_once(self): + category_files = sorted(self.OFFER_DIRECTORY.glob("*.csv")) + self.assertEqual( + {path.stem for path in category_files}, + { + "analytics", + "apis", + "bridges", + "explorers", + "faucets", + "mcpservers", + "oracles", + "platforms", + "ramps", + "sdks", + "security", + "services", + "storages", + "wallets", + }, + ) + + for category_file in category_files: + with category_file.open(encoding="utf-8-sig", newline="") as stream: + header = next(csv.reader(stream)) + columns = [column.strip() for column in header] + with self.subTest(category=category_file.stem): + self.assertEqual(columns.count("tag"), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pre_commit_sort.py b/tests/test_pre_commit_sort.py new file mode 100644 index 0000000000..47ab0f62ef --- /dev/null +++ b/tests/test_pre_commit_sort.py @@ -0,0 +1,57 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).parents[1] / "git-hooks" / "pre-commit.py" +SPEC = importlib.util.spec_from_file_location("pre_commit", MODULE_PATH) +pre_commit = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(pre_commit) + + +class SortCsvBySlugTests(unittest.TestCase): + def sort_bytes(self, source: bytes) -> bytes: + with tempfile.TemporaryDirectory() as directory: + csv_file = Path(directory) / "records.csv" + csv_file.write_bytes(source) + with patch.object(pre_commit, "iter_csv", return_value=[csv_file]), \ + patch.object(pre_commit.subprocess, "run"): + pre_commit.sort_csv_by_slug(Path(directory)) + return csv_file.read_bytes() + + def test_preserves_multiline_quoted_record(self): + source = ( + b"slug,notes\n" + b"zulu,\"line one\ncontinuation\"\n" + b"alpha,single\n" + ) + self.assertEqual( + self.sort_bytes(source), + b"slug,notes\nalpha,single\nzulu,\"line one\ncontinuation\"\n", + ) + + def test_parses_quoted_comma_before_slug(self): + source = b"notes,slug\n\"has,comma\",zulu\nplain,alpha\n" + self.assertEqual( + self.sort_bytes(source), + b"notes,slug\nplain,alpha\n\"has,comma\",zulu\n", + ) + + def test_keeps_duplicate_slug_order_and_crlf(self): + source = b"slug,value\r\nz,one\r\na,two\r\na,three\r\n" + self.assertEqual( + self.sort_bytes(source), + b"slug,value\r\na,two\r\na,three\r\nz,one\r\n", + ) + + def test_keeps_missing_final_newline(self): + source = b"slug,value\nz,one\na,two" + self.assertEqual( + self.sort_bytes(source), b"slug,value\na,two\nz,one\n" + ) + + +if __name__ == "__main__": + unittest.main()