From 935bdba616c01885d8996af16b829629811ac77b Mon Sep 17 00:00:00 2001 From: Stupidu05 <227017607+Stupidu05@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:23:51 +0800 Subject: [PATCH] feat: add export-judges subcommand for csv and xlsx judge spreadsheets --- apps/api/src/hackproof/cli.py | 6 + apps/api/src/hackproof/export/judges.py | 159 ++++++++++++++++++++++++ apps/api/tests/test_judge_export.py | 39 ++++++ docs/judge-export.md | 24 ++++ 4 files changed, 228 insertions(+) create mode 100644 apps/api/src/hackproof/export/judges.py create mode 100644 apps/api/tests/test_judge_export.py create mode 100644 docs/judge-export.md diff --git a/apps/api/src/hackproof/cli.py b/apps/api/src/hackproof/cli.py index 2afb2dd..c95b721 100644 --- a/apps/api/src/hackproof/cli.py +++ b/apps/api/src/hackproof/cli.py @@ -3,6 +3,7 @@ import logging from hackproof.export.jobs import run_export +from hackproof.export.judges import run_export_judges from hackproof.github.jobs import run_discover, run_scan from hackproof.indexer.sync import run_sync from hackproof.linking.reconcile import run_reconcile @@ -23,6 +24,9 @@ def main() -> None: ) commands.add_parser("reconcile", help="link chain activity to repos and build projects") commands.add_parser("export", help="write one yaml file per project into data and its schema") + commands.add_parser( + "export-judges", help="write objective judge csv and xlsx spreadsheets into data" + ) args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") @@ -38,3 +42,5 @@ def main() -> None: asyncio.run(run_reconcile()) case "export": asyncio.run(run_export()) + case "export-judges": + asyncio.run(run_export_judges()) diff --git a/apps/api/src/hackproof/export/judges.py b/apps/api/src/hackproof/export/judges.py new file mode 100644 index 0000000..cc8765e --- /dev/null +++ b/apps/api/src/hackproof/export/judges.py @@ -0,0 +1,159 @@ +import csv +import logging +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +from hackproof.config import ROOT +from hackproof.db import open_pool, pool +from hackproof.export.build import STRICT_TIERS, build_export +from hackproof.export.models import HackathonExport, Project +from hackproof.hackathon import get_hackathon + +log = logging.getLogger("hackproof.export.judges") + +JUDGE_COLUMNS: tuple[str, ...] = ( + "repo_url", + "evidence_tier", + "contracts", + "deploys", + "first_deploy_time", + "invocations", + "failed_transactions", + "has_tests", + "test_file_count", + "has_ci", + "readme_length", + "readme_sections", + "has_demo_url", + "has_video_url", + "ai_attribution_present", + "mainnet_status", +) + +CONTENT_TYPES_XML = """ + + + + + + +""" + +RELS_XML = """ + + + +""" + +WORKBOOK_XML = """ + + + + + +""" + +WORKBOOK_RELS_XML = """ + + + +""" + + +def _col_letter(index: int) -> str: + letters = "" + current = index + 1 + while current > 0: + current, remainder = divmod(current - 1, 26) + letters = chr(65 + remainder) + letters + return letters + + +def _project_row(project: Project) -> dict[str, str | int]: + deploys = project.chain.contracts + first_deploy = ( + min(c.deployed_at for c in deploys).strftime("%Y-%m-%dT%H:%M:%SZ") if deploys else "" + ) + return { + "repo_url": project.repo_url, + "evidence_tier": project.tier, + "contracts": project.chain.contract_count, + "deploys": len(deploys), + "first_deploy_time": first_deploy, + "invocations": "", + "failed_transactions": project.chain.failed_tx_count, + "has_tests": "", + "test_file_count": "", + "has_ci": "", + "readme_length": "", + "readme_sections": "", + "has_demo_url": "true" if project.homepage else "false", + "has_video_url": "", + "ai_attribution_present": "", + "mainnet_status": "", + } + + +def _sheet_xml(rows: list[list[str | int]]) -> str: + xml_rows: list[str] = [] + for r_idx, row in enumerate(rows, start=1): + cells: list[str] = [] + for c_idx, value in enumerate(row): + ref = f"{_col_letter(c_idx)}{r_idx}" + if isinstance(value, int): + cells.append(f'{value}') + else: + escaped = escape(str(value)) + cells.append(f'{escaped}') + xml_rows.append(f'{"".join(cells)}') + body = "".join(xml_rows) + return ( + '' + '' + f"{body}" + "" + ) + + +def write_judge_files(export: HackathonExport, output_dir: Path) -> tuple[Path, Path]: + output_dir.mkdir(parents=True, exist_ok=True) + csv_path = output_dir / "judges.csv" + xlsx_path = output_dir / "judges.xlsx" + + dict_rows = [_project_row(project) for project in export.projects] + with csv_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(JUDGE_COLUMNS)) + writer.writeheader() + writer.writerows(dict_rows) + + matrix: list[list[str | int]] = [list(JUDGE_COLUMNS)] + for row in dict_rows: + matrix.append([row[col] for col in JUDGE_COLUMNS]) + + with zipfile.ZipFile(xlsx_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("[Content_Types].xml", CONTENT_TYPES_XML) + archive.writestr("_rels/.rels", RELS_XML) + archive.writestr("xl/workbook.xml", WORKBOOK_XML) + archive.writestr("xl/_rels/workbook.xml.rels", WORKBOOK_RELS_XML) + archive.writestr("xl/worksheets/sheet1.xml", _sheet_xml(matrix)) + + return csv_path, xlsx_path + + +async def run_export_judges(output_dir: Path | None = None) -> tuple[Path, Path]: + hackathon = get_hackathon() + await open_pool() + async with pool().acquire() as connection: + export = await build_export(connection, hackathon, STRICT_TIERS) + target = output_dir or (ROOT / "data" / str(export.hackathon.starts_at.year)) + csv_path, xlsx_path = write_judge_files(export, target) + log.info("wrote judge exports to %s and %s", csv_path, xlsx_path) + return csv_path, xlsx_path diff --git a/apps/api/tests/test_judge_export.py b/apps/api/tests/test_judge_export.py new file mode 100644 index 0000000..268634c --- /dev/null +++ b/apps/api/tests/test_judge_export.py @@ -0,0 +1,39 @@ +import csv +import re +import xml.etree.ElementTree as ET +import zipfile + +from hackproof.config import ROOT +from hackproof.export.judges import JUDGE_COLUMNS, write_judge_files +from tests.test_export import populated_export + + +def documented_columns() -> list[str]: + doc = (ROOT / "docs" / "judge-export.md").read_text(encoding="utf-8") + return re.findall(r"^\| `([a-z_]+)` \|", doc, flags=re.MULTILINE) + + +async def test_judge_export_headers_match_documented_columns_and_open_cleanly( + scratch_db, tmp_path +): + export = await populated_export(scratch_db) + csv_path, xlsx_path = write_judge_files(export, tmp_path) + + expected = documented_columns() + assert list(JUDGE_COLUMNS) == expected + + with csv_path.open(encoding="utf-8") as handle: + rows = list(csv.reader(handle)) + assert rows[0] == expected + assert len(rows) == 2 + assert rows[1][0] == "https://github.com/team/app" + assert rows[1][1] == "verified" + + with zipfile.ZipFile(xlsx_path) as archive: + sheet_xml = archive.read("xl/worksheets/sheet1.xml") + root = ET.fromstring(sheet_xml) + ns = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} + first_row = root.find(".//s:row[@r='1']", ns) + assert first_row is not None + xlsx_headers = [t.text for t in first_row.findall(".//s:t", ns)] + assert xlsx_headers == expected diff --git a/docs/judge-export.md b/docs/judge-export.md new file mode 100644 index 0000000..265c2cd --- /dev/null +++ b/docs/judge-export.md @@ -0,0 +1,24 @@ +# Judge Export + +`uv run hackproof export-judges` writes `judges.csv` and `judges.xlsx` with one row per submission and objective columns only. No subjective scores are included. + +## Columns + +| Column | Source / Meaning | Status | +| --- | --- | --- | +| `repo_url` | Public GitHub repository URL (`project.repo_url`) | Populated | +| `evidence_tier` | Attribution strength (`verified` or `likely`) | Populated | +| `contracts` | Count of Soroban contracts deployed inside the event window | Populated | +| `deploys` | Number of attributed contract deployment transactions in the window | Populated | +| `first_deploy_time` | Earliest `deployed_at` UTC timestamp across window contract deploys, empty when none | Populated | +| `invocations` | Contract invocation count during the event window | Empty (not indexed yet) | +| `failed_transactions` | Count of failed Soroban transactions by attributed accounts | Populated | +| `has_tests` | Whether the repository contains automated test files | Empty (not indexed yet) | +| `test_file_count` | Number of test files detected in the repository | Empty (not indexed yet) | +| `has_ci` | Whether the repository configures a CI workflow | Empty (not indexed yet) | +| `readme_length` | Character length of the repository README | Empty (not indexed yet) | +| `readme_sections` | Count of Markdown headings in the repository README | Empty (not indexed yet) | +| `has_demo_url` | `"true"` when the repository sets a homepage/demo URL, `"false"` otherwise | Populated | +| `has_video_url` | Whether the repository links a demo video | Empty (not indexed yet) | +| `ai_attribution_present` | Whether AI tool attribution is declared in commits or docs | Empty (not indexed yet) | +| `mainnet_status` | Whether the project has deployed on Stellar pubnet/mainnet | Empty (not indexed yet) |