Skip to content

Commit df09cae

Browse files
authored
Merge pull request #397 from CodeForPhilly/396-create-way-to-export-example-screener-from-local-emulator
feat(seed-data): Add export-example-screener script
2 parents 65f7455 + 0691808 commit df09cae

16 files changed

Lines changed: 1006 additions & 0 deletions

bin/export-emulator-data.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""
2+
Export Firestore and Cloud Storage data from running Firebase emulators
3+
into a portable format suitable for seeding other environments (including production).
4+
5+
Usage:
6+
python3 bin/export-emulator-data.py [output-dir] [--skip-storage-prefix PREFIX ...]
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import json
13+
import os
14+
import sys
15+
from datetime import datetime, timezone
16+
from pathlib import Path
17+
from typing import Any
18+
19+
import firebase_admin
20+
from firebase_admin import credentials, firestore, storage
21+
from google.cloud.firestore import Client as FirestoreClient
22+
from google.cloud.firestore_v1.base_document import DocumentSnapshot
23+
from google.cloud.storage import Bucket
24+
import google.auth.credentials
25+
26+
27+
# ---------------------------------------------------------------------------
28+
# Emulator credentials (same pattern as load-library-metadata.py)
29+
# ---------------------------------------------------------------------------
30+
31+
class EmulatorCredentials(credentials.Base):
32+
"""Mock credentials for use with Firebase emulators."""
33+
_mock_credential: google.auth.credentials.Credentials
34+
35+
def __init__(self) -> None:
36+
self._mock_credential = google.auth.credentials.AnonymousCredentials()
37+
38+
def get_credential(self) -> google.auth.credentials.Credentials:
39+
return self._mock_credential
40+
41+
42+
# ---------------------------------------------------------------------------
43+
# Configuration
44+
# ---------------------------------------------------------------------------
45+
46+
parser = argparse.ArgumentParser(description="Export Firebase emulator data")
47+
parser.add_argument("output_dir", nargs="?",
48+
default="exported-data-" + datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
49+
help="Output directory (default: timestamped directory)")
50+
parser.add_argument("--skip-storage-prefix", action="append", default=[],
51+
metavar="PREFIX",
52+
help="Skip storage blobs whose path starts with PREFIX (can be repeated)")
53+
args = parser.parse_args()
54+
55+
OUTPUT_DIR = Path(args.output_dir)
56+
SKIP_STORAGE_PREFIXES: list[str] = args.skip_storage_prefix
57+
58+
# Ensure emulator env vars are set
59+
if not os.getenv("FIRESTORE_EMULATOR_HOST"):
60+
os.environ["FIRESTORE_EMULATOR_HOST"] = "localhost:8080"
61+
62+
storage_host_override = os.getenv("QUARKUS_GOOGLE_CLOUD_STORAGE_HOST_OVERRIDE")
63+
if storage_host_override:
64+
os.environ["STORAGE_EMULATOR_HOST"] = storage_host_override
65+
elif not os.getenv("STORAGE_EMULATOR_HOST"):
66+
os.environ["STORAGE_EMULATOR_HOST"] = "http://localhost:9199"
67+
68+
STORAGE_BUCKET = os.getenv("GCS_BUCKET_NAME", "demo-bdt-dev.appspot.com")
69+
PROJECT_ID = os.getenv("QUARKUS_GOOGLE_CLOUD_PROJECT_ID", "demo-bdt-dev")
70+
71+
72+
# ---------------------------------------------------------------------------
73+
# Initialize Firebase
74+
# ---------------------------------------------------------------------------
75+
76+
cred = EmulatorCredentials()
77+
firebase_admin.initialize_app(cred, {
78+
"storageBucket": STORAGE_BUCKET,
79+
"projectId": PROJECT_ID,
80+
})
81+
82+
db: FirestoreClient = firestore.client()
83+
bucket: Bucket = storage.bucket()
84+
85+
86+
# ---------------------------------------------------------------------------
87+
# Helpers
88+
# ---------------------------------------------------------------------------
89+
90+
def serialize_value(value: Any) -> Any:
91+
"""Convert Firestore field values into JSON-serializable types."""
92+
if value is None:
93+
return None
94+
if isinstance(value, datetime):
95+
return {"_type": "timestamp", "value": value.isoformat()}
96+
if isinstance(value, dict):
97+
return {k: serialize_value(v) for k, v in value.items()}
98+
if isinstance(value, list):
99+
return [serialize_value(v) for v in value]
100+
if isinstance(value, (str, int, float, bool)):
101+
return value
102+
# Fallback: convert to string
103+
return str(value)
104+
105+
106+
def export_document(doc: DocumentSnapshot, base_path: Path) -> None:
107+
"""Export a single Firestore document and its subcollections."""
108+
data = doc.to_dict()
109+
if data is None:
110+
return
111+
112+
# Add the document ID into the exported data for reference
113+
data["_id"] = doc.id
114+
115+
serialized = serialize_value(data)
116+
117+
# Write document JSON
118+
doc_file = base_path / f"{doc.id}.json"
119+
doc_file.parent.mkdir(parents=True, exist_ok=True)
120+
with open(doc_file, "w", encoding="utf-8") as f:
121+
json.dump(serialized, f, indent=2, ensure_ascii=False)
122+
123+
# Recurse into subcollections
124+
for subcol_ref in doc.reference.collections():
125+
subcol_path = base_path / doc.id / subcol_ref.id
126+
docs = subcol_ref.stream()
127+
for sub_doc in docs:
128+
export_document(sub_doc, subcol_path)
129+
130+
131+
def export_firestore(output_dir: Path) -> int:
132+
"""Export all Firestore collections to JSON files. Returns document count."""
133+
firestore_dir = output_dir / "firestore"
134+
doc_count = 0
135+
136+
# Iterate over all root-level collections
137+
for collection_ref in db.collections():
138+
col_name = collection_ref.id
139+
col_path = firestore_dir / col_name
140+
print(f" Exporting collection: {col_name}")
141+
142+
for doc in collection_ref.stream():
143+
export_document(doc, col_path)
144+
doc_count += 1
145+
146+
return doc_count
147+
148+
149+
def export_storage(output_dir: Path, skip_prefixes: list[str]) -> int:
150+
"""Export all Storage blobs to files. Returns file count."""
151+
storage_dir = output_dir / "storage"
152+
file_count = 0
153+
154+
blobs = bucket.list_blobs()
155+
for blob in blobs:
156+
# Skip zero-byte placeholder objects
157+
if blob.size == 0:
158+
continue
159+
160+
# Skip blobs matching any of the skip prefixes
161+
if any(blob.name.startswith(prefix) for prefix in skip_prefixes):
162+
print(f" Skipping blob: {blob.name} (matched skip prefix)")
163+
continue
164+
165+
dest = storage_dir / blob.name
166+
dest.parent.mkdir(parents=True, exist_ok=True)
167+
168+
print(f" Exporting blob: {blob.name} ({blob.size} bytes)")
169+
blob.download_to_filename(str(dest))
170+
file_count += 1
171+
172+
return file_count
173+
174+
175+
# ---------------------------------------------------------------------------
176+
# Main
177+
# ---------------------------------------------------------------------------
178+
179+
def main() -> None:
180+
print(f"Output directory: {OUTPUT_DIR}")
181+
print()
182+
183+
# Clean previous export
184+
if OUTPUT_DIR.exists():
185+
import shutil
186+
shutil.rmtree(OUTPUT_DIR)
187+
188+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
189+
190+
# Export Firestore
191+
print("Exporting Firestore data...")
192+
doc_count = export_firestore(OUTPUT_DIR)
193+
print(f" -> {doc_count} document(s) exported")
194+
print()
195+
196+
# Export Storage
197+
print("Exporting Storage data...")
198+
file_count = export_storage(OUTPUT_DIR, SKIP_STORAGE_PREFIXES)
199+
print(f" -> {file_count} file(s) exported")
200+
print()
201+
202+
# Write a manifest for reference
203+
manifest = {
204+
"exportedAt": datetime.now(tz=timezone.utc).isoformat() + "Z",
205+
"source": "firebase-emulators",
206+
"projectId": PROJECT_ID,
207+
"storageBucket": STORAGE_BUCKET,
208+
"firestoreDocuments": doc_count,
209+
"storageFiles": file_count,
210+
}
211+
manifest_path = OUTPUT_DIR / "manifest.json"
212+
with open(manifest_path, "w", encoding="utf-8") as f:
213+
json.dump(manifest, f, indent=2)
214+
215+
print(f"Manifest written to {manifest_path}")
216+
217+
218+
if __name__ == "__main__":
219+
main()

bin/export-example-screener

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env bash
2+
3+
# Export Firestore and Storage data from running emulators
4+
# in a portable JSON format that can be used to seed other environments (including production).
5+
# Assumes that all the firestore and storage data in the emulators exactly comprises the data needed for the example screener.
6+
#
7+
# Usage: bin/export-emulator-data
8+
9+
set -e
10+
11+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12+
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
13+
14+
source "$SCRIPT_DIR/functions"
15+
16+
# Load environment from root .env if it exists
17+
if [ -f "$PROJECT_ROOT/.env" ]; then
18+
set -a
19+
source "$PROJECT_ROOT/.env"
20+
set +a
21+
fi
22+
23+
OUTPUT_DIR="$PROJECT_ROOT/seed-data/example-screener"
24+
25+
echo "=========================================="
26+
echo "Example Screener Data Export"
27+
echo "=========================================="
28+
echo "Output: $OUTPUT_DIR"
29+
echo ""
30+
echo "Prerequisites:"
31+
echo " - Firebase emulators must be running"
32+
echo " - Assumes all data in the emulators pertains to the example screener"
33+
echo ""
34+
35+
# Helper function to check URL using Python
36+
check_url() {
37+
python3 -c "
38+
import urllib.request
39+
import urllib.error
40+
import sys
41+
try:
42+
urllib.request.urlopen('$1', timeout=5)
43+
sys.exit(0)
44+
except urllib.error.HTTPError:
45+
sys.exit(0)
46+
except Exception as e:
47+
print(f'DEBUG: {type(e).__name__}: {e}')
48+
sys.exit(1)
49+
"
50+
}
51+
52+
# Check if Firebase emulators are running
53+
MAX_RETRIES=5
54+
RETRY_DELAY=2
55+
56+
for i in $(seq 1 $MAX_RETRIES); do
57+
if check_url "http://localhost:9199"; then
58+
break
59+
fi
60+
if [ $i -eq $MAX_RETRIES ]; then
61+
print_error "Firebase Storage emulator not responding at localhost:9199"
62+
exit 1
63+
fi
64+
echo "Waiting for emulators... ($i/$MAX_RETRIES)"
65+
sleep $RETRY_DELAY
66+
done
67+
68+
for i in $(seq 1 $MAX_RETRIES); do
69+
if check_url "http://localhost:8080"; then
70+
break
71+
fi
72+
if [ $i -eq $MAX_RETRIES ]; then
73+
print_error "Firebase Firestore emulator not responding at localhost:8080"
74+
exit 1
75+
fi
76+
echo "Waiting for emulators... ($i/$MAX_RETRIES)"
77+
sleep $RETRY_DELAY
78+
done
79+
80+
print_success "Firebase emulators are running"
81+
echo ""
82+
83+
if [[ "$VENV_DIR" != "" ]]; then
84+
if [ -f "$VENV_DIR/bin/activate" ]; then
85+
source "$VENV_DIR/bin/activate"
86+
print_status "Activated virtual environment at $VENV_DIR"
87+
fi
88+
fi
89+
90+
pip install -q -r "$SCRIPT_DIR/library/requirements.txt"
91+
92+
print_status "Exporting emulator data..."
93+
python3 "$SCRIPT_DIR/export-emulator-data.py" "$OUTPUT_DIR" \
94+
--skip-storage-prefix "LibraryApiSchemaExports/"
95+
96+
echo ""
97+
echo "=========================================="
98+
print_success "Export complete! Data saved to $OUTPUT_DIR"
99+
echo "=========================================="
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"id": "P-4ylHvnZVDmLTU08beOGfZa0ml8nV-testchecks-test-2.0.0",
3+
"datePublished": 1774637469632,
4+
"isArchived": false,
5+
"ownerId": "4ylHvnZVDmLTU08beOGfZa0ml8nV",
6+
"parameterDefinitions": [
7+
{
8+
"type": "string",
9+
"key": "p1",
10+
"label": "P 1",
11+
"required": true
12+
}
13+
],
14+
"version": "2.0.0",
15+
"module": "testchecks",
16+
"name": "test",
17+
"inputDefinition": {
18+
"type": "object",
19+
"properties": {
20+
"custom": {
21+
"type": "object",
22+
"properties": {
23+
"testinput": {}
24+
},
25+
"required": [
26+
"testinput"
27+
]
28+
}
29+
},
30+
"required": [
31+
"custom"
32+
]
33+
},
34+
"dmnModel": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<dmn:definitions xmlns:dmn=\"http://www.omg.org/spec/DMN/20180521/MODEL/\" xmlns=\"https://kiegroup.org/dmn/_D0E90302-6058-416A-96E3-779658B04AE8\" xmlns:feel=\"http://www.omg.org/spec/DMN/20180521/FEEL/\" xmlns:kie=\"http://www.drools.org/kie/dmn/1.2\" xmlns:dmndi=\"http://www.omg.org/spec/DMN/20180521/DMNDI/\" xmlns:di=\"http://www.omg.org/spec/DMN/20180521/DI/\" xmlns:dc=\"http://www.omg.org/spec/DMN/20180521/DC/\" id=\"_48E2724A-6167-41A4-92B2-FA8BC687355B\" name=\"2793048D-2664-4915-8E51-8F40A6C3D4ED\" typeLanguage=\"http://www.omg.org/spec/DMN/20180521/FEEL/\" namespace=\"https://kiegroup.org/dmn/_D0E90302-6058-416A-96E3-779658B04AE8\">\n <dmn:extensionElements/>\n <dmn:inputData id=\"_CAD54B50-6088-4F0C-AED5-E8BEC9F33DEF\" name=\"testinput\">\n <dmn:extensionElements/>\n <dmn:variable id=\"_521661DE-9DA8-4908-8795-2FB78B7BC72C\" name=\"testinput\" typeRef=\"Any\"/>\n </dmn:inputData>\n <dmn:decision id=\"_6A5A2558-0282-4789-957A-ADE53EAD4CC6\" name=\"test\">\n <dmn:extensionElements/>\n <dmn:variable id=\"_13CF3EFF-653D-4676-A3BD-66655565055D\" name=\"test\" typeRef=\"boolean\"/>\n <dmn:informationRequirement id=\"_1998B89E-BE3D-468F-841A-4558E131866B\">\n <dmn:requiredInput href=\"#_CAD54B50-6088-4F0C-AED5-E8BEC9F33DEF\"/>\n </dmn:informationRequirement>\n <dmn:literalExpression id=\"_CBF835CA-7660-4E18-9015-5367A6F0623C\">\n <dmn:text>true</dmn:text>\n </dmn:literalExpression>\n </dmn:decision>\n <dmndi:DMNDI>\n <dmndi:DMNDiagram id=\"_839C64D7-3137-4E22-A2F2-3D54CDEE1FF7\" name=\"DRG\">\n <di:extension>\n <kie:ComponentsWidthsExtension>\n <kie:ComponentWidths dmnElementRef=\"_CBF835CA-7660-4E18-9015-5367A6F0623C\">\n <kie:width>300</kie:width>\n </kie:ComponentWidths>\n </kie:ComponentsWidthsExtension>\n </di:extension>\n <dmndi:DMNShape id=\"dmnshape-drg-_CAD54B50-6088-4F0C-AED5-E8BEC9F33DEF\" dmnElementRef=\"_CAD54B50-6088-4F0C-AED5-E8BEC9F33DEF\" isCollapsed=\"false\">\n <dmndi:DMNStyle>\n <dmndi:FillColor red=\"255\" green=\"255\" blue=\"255\"/>\n <dmndi:StrokeColor red=\"0\" green=\"0\" blue=\"0\"/>\n <dmndi:FontColor red=\"0\" green=\"0\" blue=\"0\"/>\n </dmndi:DMNStyle>\n <dc:Bounds x=\"411\" y=\"187\" width=\"100\" height=\"50\"/>\n <dmndi:DMNLabel/>\n </dmndi:DMNShape>\n <dmndi:DMNShape id=\"dmnshape-drg-_6A5A2558-0282-4789-957A-ADE53EAD4CC6\" dmnElementRef=\"_6A5A2558-0282-4789-957A-ADE53EAD4CC6\" isCollapsed=\"false\">\n <dmndi:DMNStyle>\n <dmndi:FillColor red=\"255\" green=\"255\" blue=\"255\"/>\n <dmndi:StrokeColor red=\"0\" green=\"0\" blue=\"0\"/>\n <dmndi:FontColor red=\"0\" green=\"0\" blue=\"0\"/>\n </dmndi:DMNStyle>\n <dc:Bounds x=\"419\" y=\"76\" width=\"100\" height=\"50\"/>\n <dmndi:DMNLabel/>\n </dmndi:DMNShape>\n <dmndi:DMNEdge id=\"dmnedge-drg-_1998B89E-BE3D-468F-841A-4558E131866B-AUTO-TARGET\" dmnElementRef=\"_1998B89E-BE3D-468F-841A-4558E131866B\">\n <di:waypoint x=\"461\" y=\"212\"/>\n <di:waypoint x=\"469\" y=\"126\"/>\n </dmndi:DMNEdge>\n </dmndi:DMNDiagram>\n </dmndi:DMNDI>\n</dmn:definitions>",
35+
"description": "desc",
36+
"_id": "P-4ylHvnZVDmLTU08beOGfZa0ml8nV-testchecks-test-2.0.0"
37+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"id": "06NGTnKHQBSQP8Z9JEZl",
3+
"ownerId": "4ylHvnZVDmLTU08beOGfZa0ml8nV",
4+
"screenerName": "Example - Philly Property Tax Relief",
5+
"benefits": [
6+
{
7+
"id": "34d0cd24-d5c1-4073-9f95-f56454855dc3",
8+
"name": "Homestead Exemption",
9+
"description": "If you own your primary residence, you are eligible for the Homestead Exemption on your Real Estate Tax. The Homestead Exemption reduces the taxable portion of your property’s assessed value.\n\nWith this exemption, the property’s assessed value is reduced by $100,000. Most homeowners will save about $1,399 a year on their Real Estate Tax bill starting in 2025.\n\nmore info:\nhttps://www.phila.gov/services/payments-assistance-taxes/taxes/property-and-real-estate-taxes/get-real-estate-tax-relief/get-the-homestead-exemption/"
10+
},
11+
{
12+
"id": "4b1aea4c-9ef7-4074-9a6a-c41149dcb437",
13+
"name": "OOPA",
14+
"description": "desc"
15+
}
16+
],
17+
"_id": "ummKCKkeHAYt5Kc6DAgT"
18+
}

0 commit comments

Comments
 (0)