ntdservices commited on
Commit
c562248
·
verified ·
1 Parent(s): 6fbab4f

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile.txt +19 -0
  2. app.py +197 -0
  3. requirements.txt +3 -0
Dockerfile.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Docker Space (works on Hugging Face)
2
+ FROM python:3.11-slim
3
+
4
+ ENV PYTHONDONTWRITEBYTECODE=1 \
5
+ PYTHONUNBUFFERED=1 \
6
+ PIP_DISABLE_PIP_VERSION_CHECK=1
7
+
8
+ WORKDIR /app
9
+ COPY requirements.txt .
10
+ RUN pip install -r requirements.txt
11
+
12
+ COPY app.py .
13
+
14
+ # HF sets $PORT; default to 7860 for local docker runs
15
+ ENV PORT=7860
16
+ EXPOSE 7860
17
+
18
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
19
+
app.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py — AP Elections API simulator for Hugging Face Spaces (Docker)
2
+ # Simulates: GET /v2/elections/{date}?statepostal=XX&raceTypeId=G&raceId=0&level=ru
3
+ # Returns XML containing <ReportingUnit Name="…"><Candidate …/></ReportingUnit> per county.
4
+ #
5
+ # Design:
6
+ # - On startup, downloads US-Atlas counties TopoJSON to build state→counties list.
7
+ # - For each requested state, emits ReportingUnit for each county with AP-like name variants.
8
+ # - Vote counts are deterministic but tick upward over time to look "live".
9
+ #
10
+ # Usage with your app:
11
+ # In your wrapper (BASE_URL), change to: https://<your-space>.hf.space/v2/elections
12
+ #
13
+ # Notes:
14
+ # - We ignore x-api-key. Your wrapper still sends it; that's fine. (We don't enforce it.)
15
+ # - We accept any date string; we don't filter by raceTypeId/raceId/level (but we keep them for parity).
16
+
17
+ import os, time, math, json, re, asyncio, hashlib
18
+ from typing import Dict, List, Tuple
19
+ import httpx
20
+ from fastapi import FastAPI, Query, Request
21
+ from fastapi.responses import PlainTextResponse, Response
22
+
23
+ app = FastAPI(title="AP Elections API Simulator")
24
+
25
+ US_ATLAS_COUNTIES_URL = "https://cdn.jsdelivr.net/npm/us-atlas@3/counties-10m.json"
26
+
27
+ # Map state FIPS (2 digits) → USPS
28
+ STATE_FIPS_TO_USPS = {
29
+ "01":"AL","02":"AK","04":"AZ","05":"AR","06":"CA","08":"CO","09":"CT","10":"DE","11":"DC",
30
+ "12":"FL","13":"GA","15":"HI","16":"ID","17":"IL","18":"IN","19":"IA","20":"KS","21":"KY",
31
+ "22":"LA","23":"ME","24":"MD","25":"MA","26":"MI","27":"MN","28":"MS","29":"MO","30":"MT",
32
+ "31":"NE","32":"NV","33":"NH","34":"NJ","35":"NM","36":"NY","37":"NC","38":"ND","39":"OH",
33
+ "40":"OK","41":"OR","42":"PA","44":"RI","45":"SC","46":"SD","47":"TN","48":"TX","49":"UT",
34
+ "50":"VT","51":"VA","53":"WA","54":"WV","55":"WI","56":"WY",
35
+ # Territories (we won't emit unless statepostal matches)
36
+ "72":"PR"
37
+ }
38
+
39
+ # USPS → state FIPS
40
+ USPS_TO_STATE_FIPS = {v:k for k,v in STATE_FIPS_TO_USPS.items()}
41
+
42
+ # USPS that use "Parish" instead of "County"
43
+ PARISH_STATES = {"LA"}
44
+
45
+ # USPS that often use "city" county-equivalents
46
+ INDEPENDENT_CITY_STATES = {"VA"} # (also some in MO e.g., St. Louis city)
47
+
48
+ # A few AP-style "wrong" name transforms to exercise your fixups.
49
+ # Applied after suffixing County/Parish/city when appropriate.
50
+ def apize_name(usps: str, canonical: str) -> str:
51
+ n = canonical
52
+
53
+ # Common Saint → St. contraction
54
+ if n.startswith("Saint "):
55
+ n = "St. " + n[6:]
56
+
57
+ # Diacritics drops often seen
58
+ n = n.replace("Doña", "Dona").replace("Niobrara", "Niobrara") # keep example stable
59
+
60
+ # Collapsed spacing variants occasionally seen
61
+ n = re.sub(r"\bLa\s+Salle\b", "LaSalle", n)
62
+ n = n.replace("DeKalb", "De Kalb")
63
+
64
+ # MO "St. Louis city" is a city-county equivalent; keep 'city'
65
+ # leave as-is if endswith('city')
66
+ return n
67
+
68
+ def county_suffix(usps: str, name: str) -> str:
69
+ # Heuristics based on state
70
+ if usps in PARISH_STATES:
71
+ # LA data in maps typically already contains "Parish". If not, add it.
72
+ return f"{name}" if name.lower().endswith("parish") else f"{name} Parish"
73
+
74
+ # Independent cities (e.g., VA) usually already end with "city" in map data
75
+ # If not, default to County.
76
+ if usps in INDEPENDENT_CITY_STATES and name.lower().endswith("city"):
77
+ return name
78
+
79
+ # Default: County
80
+ return name if name.lower().endswith("county") else f"{name} County"
81
+
82
+ def seeded_rng_u32(seed: str) -> int:
83
+ # Deterministic 32-bit integer from any string (FIPS)
84
+ h = hashlib.blake2b(seed.encode("utf-8"), digest_size=4).digest()
85
+ return int.from_bytes(h, "big")
86
+
87
+ def simulated_votes(fips: str) -> Tuple[int,int,int]:
88
+ """
89
+ Deterministic but 'live' votes.
90
+ We derive base from FIPS, then add a minute-tick so numbers grow slowly.
91
+ """
92
+ base_seed = seeded_rng_u32(fips)
93
+ r1 = (base_seed & 0xFFFF)
94
+ r2 = ((base_seed >> 16) & 0xFFFF)
95
+
96
+ # minute "tick" so values rise over time
97
+ minute_tick = int(time.time() // 60)
98
+
99
+ # Scale to plausible county sizes
100
+ base_total = 2000 + (base_seed % 250000) # up to ~250k baseline
101
+ # skew rep/dem by seed; keep IND small
102
+ rep = int(base_total * (0.35 + (r1 % 30)/100.0)) # 35–65% of base_total
103
+ dem = base_total - rep
104
+ ind = int(base_total * (0.01 + (r2 % 3)/100.0)) # ~1–4%
105
+
106
+ # grow gently each minute
107
+ growth = 50 + (base_seed % 50) # 50–99 votes per minute
108
+ rep += (growth * (minute_tick % 10)) // 2
109
+ dem += (growth * (minute_tick % 10)) // 2
110
+ ind += (growth * (minute_tick % 10)) // 10
111
+
112
+ # cap at non-negative
113
+ rep = max(rep, 0); dem = max(dem, 0); ind = max(ind, 0)
114
+ return rep, dem, ind
115
+
116
+ # --- In-memory registry: USPS → List[(FIPS, canonical_name, ap_name)] ---
117
+ STATE_REGISTRY: Dict[str, List[Tuple[str,str,str]]] = {}
118
+
119
+ @app.on_event("startup")
120
+ async def bootstrap():
121
+ # Build county registry from US-Atlas TopoJSON (same source your frontend uses).
122
+ async with httpx.AsyncClient(timeout=30) as client:
123
+ r = await client.get(US_ATLAS_COUNTIES_URL)
124
+ r.raise_for_status()
125
+ topo = r.json()
126
+
127
+ # Extract geometries
128
+ geoms = topo.get("objects", {}).get("counties", {}).get("geometries", [])
129
+ # Some us-atlas builds place names under 'properties.name'. Others under 'properties.NAMELSAD' —
130
+ # we check both and fall back to FIPS if missing.
131
+ for g in geoms:
132
+ fips = str(g.get("id", "")).zfill(5)
133
+ props = g.get("properties", {}) or {}
134
+ name = props.get("name") or props.get("NAMELSAD") or fips
135
+ state_fips = fips[:2]
136
+ usps = STATE_FIPS_TO_USPS.get(state_fips)
137
+ if not usps:
138
+ continue
139
+
140
+ # Strip common suffixes from canonical to resemble your client map’s county label.
141
+ canonical = re.sub(r"\s+(County|Parish|city)$", "", name)
142
+
143
+ # Build an AP-style reporting name (adds County/Parish and applies quirks)
144
+ apname = county_suffix(usps, canonical)
145
+ apname = apize_name(usps, apname)
146
+
147
+ STATE_REGISTRY.setdefault(usps, []).append((fips, canonical, apname))
148
+
149
+ # Ensure deterministic order
150
+ for usps in STATE_REGISTRY:
151
+ STATE_REGISTRY[usps].sort(key=lambda t: t[0])
152
+
153
+ @app.get("/api/ping", response_class=PlainTextResponse)
154
+ def ping():
155
+ return "pong"
156
+
157
+ @app.get("/v2/elections/{date}")
158
+ def elections_state_ru(
159
+ request: Request,
160
+ date: str,
161
+ statepostal: str = Query(..., min_length=2, max_length=2),
162
+ raceTypeId: str = Query("G"),
163
+ raceId: str = Query("0"),
164
+ level: str = Query("ru"),
165
+ ):
166
+ """
167
+ Simulated AP endpoint. We ignore race filters but mirror the URL/shape.
168
+ Response: XML with <ReportingUnit Name="..."><Candidate .../></ReportingUnit>
169
+ Candidates: Trump (REP), Harris (DEM), Kennedy (IND)
170
+ """
171
+ usps = statepostal.upper()
172
+ counties = STATE_REGISTRY.get(usps, [])
173
+
174
+ # If we somehow don't have this state, return empty but valid XML (your code handles this).
175
+ if not counties:
176
+ xml = f'<ElectionResults Date="{date}" StatePostal="{usps}"></ElectionResults>'
177
+ return Response(content=xml, media_type="application/xml")
178
+
179
+ # Assemble XML
180
+ parts = [f'<ElectionResults Date="{date}" StatePostal="{usps}">']
181
+ for fips, canonical, apname in counties:
182
+ rep, dem, ind = simulated_votes(fips)
183
+
184
+ # Example AP attributes your wrapper reads: First, Last, Party, VoteCount
185
+ parts.append(f' <ReportingUnit Name="{apname}" FIPS="{fips}">')
186
+ parts.append(f' <Candidate First="Donald" Last="Trump" Party="REP" VoteCount="{rep}"/>')
187
+ parts.append(f' <Candidate First="Kamala" Last="Harris" Party="DEM" VoteCount="{dem}"/>')
188
+ parts.append(f' <Candidate First="Robert" Last="Kennedy" Party="IND" VoteCount="{ind}"/>')
189
+ parts.append( " </ReportingUnit>")
190
+ parts.append("</ElectionResults>")
191
+ xml = "\n".join(parts)
192
+ return Response(content=xml, media_type="application/xml")
193
+
194
+ # Local run (for dev): uvicorn app:app --reload
195
+ if __name__ == "__main__":
196
+ import uvicorn, os
197
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ httpx==0.27.2