forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpublish.py
More file actions
223 lines (186 loc) · 9.03 KB
/
Copy pathpublish.py
File metadata and controls
223 lines (186 loc) · 9.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""Change-aware sync of Export's artifacts to Cloudflare R2 (S3-compatible).
See TECHNICAL_ARCHITECTURE.md's "Publish, change-aware end to end" section
and pipeline/README.md. The chunking/hashing-granularity question that
section flags is already resolved (ROADMAP.md Phase 2: "whole corridor, one
package") - this module doesn't need to revisit it, just diff whatever
per-artifact manifests Export already produces (export_trails.py's
trails_manifest.json, export_poi.py's poi/manifest.json, export_elevation.py's
elevation_manifest.json) plus export_pmtiles.py's per-tier background
archives (see BACKGROUND_ARCHIVES), which don't get their own manifest - this
module hashes those directly rather than silently skipping the two largest
artifacts in the whole pipeline.
Core rule: only upload an artifact whose hash actually changed, and never
write a new `latest.json` version if nothing changed - not even a no-op
bump. One SHA256 per artifact, never one combined hash for everything (per
TECHNICAL_ARCHITECTURE.md's explicit "per-artifact, not one hash for
everything" - the same reasoning `export_pmtiles.py`/`export_trails.py`/
`export_poi.py` already apply per-artifact rather than per-run).
R2 credentials/endpoint are read from the environment only (R2_ENDPOINT_URL,
R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY) - never hardcoded, same
discipline backend/app/config.py already applies to Supabase credentials.
Writes are disabled by default. A trusted environment must explicitly opt in by
setting R2_WRITE_ENABLED=true before publish.py is allowed to upload anything.
"""
from __future__ import annotations
import hashlib
import json
import os
import uuid
from pathlib import Path
import boto3
ROOT = Path(__file__).parent
PROCESSED_DIR = ROOT / "data" / "processed"
MANIFEST_KEY = "latest.json"
WRITE_ENABLED_ENV_VAR = "R2_WRITE_ENABLED"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
# One background raster archive per download tier the client offers.
#
# The Downloads screen (client/src/lib/downloadDetail.ts) lets a hiker pick
# Light / Standard / Fine, and each is a separate PMTiles archive built at a
# different max zoom by export_pmtiles.py:
#
# light z6-11 ~64 MB export_pmtiles.py --max-zoom 11 --out ...
# standard z6-12 ~314 MB export_pmtiles.py (default)
# fine z6-13 ~1.18 GB export_pmtiles.py --max-zoom 13 --out ...
#
# Written as a named mapping rather than a hardcoded tuple of filenames so
# that a tier the app offers but the pipeline cannot produce is a failing
# test rather than a download that 404s on a mountain. That was a real gap:
# background_z11.pmtiles did not exist while the app was already offering
# Light.
BACKGROUND_ARCHIVES = {
"light": "background_z11.pmtiles",
"standard": "background.pmtiles",
"fine": "background_z13.pmtiles",
}
def collect_artifacts() -> dict[str, dict]:
"""Gather every publishable artifact into one flat {name: {path, sha256}}
dict, reading whichever of Export's manifests actually exist (a fresh
checkout that's only run some export scripts still publishes what it
has) plus the raster background, hashed directly since export_pmtiles.py
doesn't write its own manifest."""
artifacts: dict[str, dict] = {}
trails_manifest = PROCESSED_DIR / "trails_manifest.json"
if trails_manifest.exists():
manifest = json.loads(trails_manifest.read_text())
for kind in ("geojson", "fgb"):
if kind in manifest:
artifacts[f"trails.{kind}"] = {"path": manifest[kind]["path"], "sha256": manifest[kind]["sha256"]}
poi_manifest = PROCESSED_DIR / "poi" / "manifest.json"
if poi_manifest.exists():
manifest = json.loads(poi_manifest.read_text())
for poi_type, entry in manifest.items():
for kind in ("geojson", "fgb"):
if kind in entry:
artifacts[f"poi_{poi_type}.{kind}"] = {
"path": entry[kind]["path"],
"sha256": entry[kind]["sha256"],
}
elevation_manifest = PROCESSED_DIR / "elevation_manifest.json"
if elevation_manifest.exists():
manifest = json.loads(elevation_manifest.read_text())
artifacts["elevation_profile.json"] = {"path": manifest["path"], "sha256": manifest["sha256"]}
for name in BACKGROUND_ARCHIVES.values():
path = PROCESSED_DIR / name
if path.exists():
artifacts[name] = {"path": str(path), "sha256": sha256_file(path)}
return artifacts
def _load_remote_manifest(s3_client, bucket: str) -> dict | None:
try:
body = s3_client.get_object(Bucket=bucket, Key=MANIFEST_KEY)["Body"].read()
except s3_client.exceptions.NoSuchKey:
return None
except Exception as exc:
# boto3/moto raise a botocore ClientError (not NoSuchKey) for a
# missing key in some code paths - treat "not found" the same way
# regardless of which exception type carried it, re-raise anything
# else rather than masking a real failure.
if "NoSuchKey" not in str(exc) and "404" not in str(exc):
raise
return None
return json.loads(body)
def writes_enabled() -> bool:
"""Whether this environment is explicitly allowed to publish to R2."""
return os.environ.get(WRITE_ENABLED_ENV_VAR, "").strip().lower() in {"1", "true", "yes", "on"}
def publish(artifacts: dict[str, dict] | None = None, *, s3_client=None, bucket: str | None = None) -> dict:
"""Diff `artifacts` (defaults to collect_artifacts()'s real output)
against the bucket's current latest.json, upload only what changed, and
write a new manifest version only if at least one artifact actually
changed. Returns a summary dict - uploaded/skipped artifact names,
whether a new version was written, and the resulting version id."""
if not writes_enabled():
raise PermissionError(f"R2 writes are disabled. Set {WRITE_ENABLED_ENV_VAR}=true before publishing.")
if artifacts is None:
artifacts = collect_artifacts()
if s3_client is None:
s3_client = boto3.client(
"s3",
endpoint_url=os.environ["R2_ENDPOINT_URL"],
aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"],
)
if bucket is None:
bucket = os.environ["R2_BUCKET"]
remote_manifest = _load_remote_manifest(s3_client, bucket)
remote_artifacts = remote_manifest["artifacts"] if remote_manifest else {}
uploaded: list[str] = []
skipped: list[str] = []
for name, entry in artifacts.items():
remote_entry = remote_artifacts.get(name)
if remote_entry is not None and remote_entry["sha256"] == entry["sha256"]:
skipped.append(name)
continue
s3_client.upload_file(entry["path"], bucket, name)
uploaded.append(name)
if not uploaded:
return {
"uploaded": [],
"skipped": sorted(skipped),
"version_written": False,
"version": remote_manifest["version"] if remote_manifest else None,
}
new_version = str(uuid.uuid4())
# Merge, don't replace: an artifact that's live in remote_artifacts but
# wasn't produced by this run's collect_artifacts() (e.g. a checkout that
# only re-ran export_trails.py, with no local elevation_manifest.json or
# background pmtiles tier) must survive into the new manifest untouched -
# the R2 object is still there, only the local checkout is partial. Local
# entries win by name where both exist, since a freshly-collected entry
# for a name that changed is the new source of truth; any remote name
# with no local counterpart this run is preserved as-is.
new_manifest = {
"version": new_version,
"artifacts": {
**remote_artifacts,
**{name: {"sha256": entry["sha256"]} for name, entry in artifacts.items()},
},
}
s3_client.put_object(Bucket=bucket, Key=MANIFEST_KEY, Body=json.dumps(new_manifest, indent=2).encode("utf-8"))
return {
"uploaded": sorted(uploaded),
"skipped": sorted(skipped),
"version_written": True,
"version": new_version,
}
def main() -> dict:
artifacts = collect_artifacts()
if not artifacts:
print("No exported artifacts found under data/processed/ - run the export scripts first.")
return {"uploaded": [], "skipped": [], "version_written": False, "version": None}
result = publish(artifacts)
if result["version_written"]:
print(f"Published version {result['version']}: uploaded {result['uploaded']}, skipped {result['skipped']}.")
else:
print(f"Nothing changed - all {len(result['skipped'])} artifacts already up to date. No new version written.")
return result
if __name__ == "__main__":
try:
main()
except PermissionError as exc:
print(exc)
raise SystemExit(1)