-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathgeeView.py
More file actions
3859 lines (3317 loc) · 187 KB
/
Copy pathgeeView.py
File metadata and controls
3859 lines (3317 loc) · 187 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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
View GEE objects using Python
geeViz.geeView is the core module for managing GEE objects on the geeViz mapper object. geeViz instantiates an instance of the `mapper` class as `Map` by default. Layers can be added to the map using `Map.addLayer` or `Map.addTimeLapse` and then viewed using the `Map.view` method.
"""
"""
Copyright 2026 Ian Housman
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# Script to allow GEE objects to be viewed in a web viewer
# Intended to work within the geeViz package
######################################################################
# Import modules
import ee, sys, os, webbrowser, json, socket, subprocess, site, datetime, requests, google, tempfile, signal, time
from google.auth.transport import requests as gReq
from google.oauth2 import service_account
from threading import Thread
from urllib.parse import urlparse
from IPython.display import IFrame, display, HTML
if sys.version_info[0] < 3:
import SimpleHTTPServer, SocketServer
else:
import http.server, socketserver
IS_COLAB = ee.oauth.in_colab_shell() # "google.colab" in sys.modules
IS_WORKBENCH = os.getenv("DL_ANACONDA_HOME") != None
if IS_COLAB:
from google.colab.output import eval_js
######################################################################
# Functions to handle various initialization/authentication workflows to try to get a user an initialized instance of ee
# Function to have user input a project id if one is still needed
def setProject(id):
"""
Sets the project id of an instance of ee
Args:
id (str): Google Cloud Platform project id to use
"""
ee.data.setCloudApiUserProject(id)
def simpleSetProject(overwrite=False,verbose=False):
"""
Tries to find the current Google Cloud Platform project id and set it
Args:
overwrite (bool, optional): Whether or not to overwrite a cached project ID file
"""
creds_path = ee.oauth.get_credentials_path()
creds_dir = os.path.dirname(creds_path)
if not os.path.exists(creds_dir):os.makedirs(creds_dir)
provided_project = "{}.proj_id".format(creds_path)
provided_project = os.path.normpath(provided_project)
if not os.path.exists(provided_project) or overwrite:
project_id = input("Please enter GEE project ID: ")
print("You entered: {}".format(project_id))
o = open(provided_project, "w")
o.write(project_id)
o.close()
else:
o = open(provided_project, "r")
project_id = o.read()
if verbose:
print("Cached project id file path: {}".format(provided_project))
print("Cached project id: {}".format(project_id))
o.close()
setProject(project_id)
def robustInitializer(verbose: bool = False):
"""Thin pointer to ``geeViz.eeAuth.robust_init`` — kept here for
backwards compatibility with scripts that imported it from
``geeViz.geeView`` directly.
The full decision tree (eeAuth proxy → EE refresh token → ADC fallback
with explicit warning → interactive ``ee.Authenticate(force=True)``)
lives in ``geeViz.eeAuth.eeCreds.EECreds.robust_init`` so it's
usable from any geeViz entry point, not just module import.
"""
from geeViz.eeAuth import robust_init as _robust_init
return _robust_init(verbose=verbose)
robustInitializer()
######################################################################
# Set up GEE and paths
geeVizFolder = "geeViz"
geeViewFolder = "geeView"
# Set up template web viewer
# Do not change
cwd = os.getcwd()
paths = sys.path
py_viz_dir = os.path.dirname(__file__)
# print("geeViz package folder:", py_viz_dir)
# Specify location of files to run
template = os.path.join(py_viz_dir, geeViewFolder, "index.html")
ee_run_dir = os.path.join(py_viz_dir, geeViewFolder, "src/gee/gee-run/")
if os.path.exists(ee_run_dir) == False:
os.makedirs(ee_run_dir)
######################################################################
######################################################################
# Functions
######################################################################
# Linear color gradient functions
##############################################################
##############################################################
def color_dict_maker(gradient: list[list[int]]) -> dict:
"""Takes in a list of RGB sub-lists and returns dictionary of
colors in RGB and hex form for use in a graphing function
defined later on"""
return {
"hex": [RGB_to_hex(RGB) for RGB in gradient],
"r": [RGB[0] for RGB in gradient],
"g": [RGB[1] for RGB in gradient],
"b": [RGB[2] for RGB in gradient],
}
# color functions adapted from bsou.io/posts/color-gradients-with-python
def hex_to_rgb(value: str) -> tuple:
"""Return (red, green, blue) for the color given as #rrggbb."""
value = value.lstrip("#")
lv = len(value)
if lv == 3:
lv = 6
value = f"{value[0]}{value[0]}{value[1]}{value[1]}{value[2]}{value[2]}"
return tuple(int(value[i : i + lv // 3], 16) for i in range(0, lv, lv // 3))
def RGB_to_hex(RGB: list[int]) -> str:
"""[255,255,255] -> "#FFFFFF" """
# Components need to be integers for hex to make sense
RGB = [int(x) for x in RGB]
return "#" + "".join(["0{0:x}".format(v) if v < 16 else "{0:x}".format(v) for v in RGB])
def linear_gradient(start_hex: str, finish_hex: str = "#FFFFFF", n: int = 10) -> dict:
"""returns a gradient list of (n) colors between
two hex colors. start_hex and finish_hex
should be the full six-digit color string,
inlcuding the number sign ("#FFFFFF")"""
# Starting and ending colors in RGB form
s = hex_to_rgb(start_hex)
f = hex_to_rgb(finish_hex)
# Initilize a list of the output colors with the starting color
RGB_list = [s]
# Calcuate a color at each evenly spaced value of t from 1 to n
for t in range(1, n):
# Interpolate RGB vector for color at the current value of t
curr_vector = [int(s[j] + (float(t) / (n - 1)) * (f[j] - s[j])) for j in range(3)]
# Add it to our list of output colors
RGB_list.append(curr_vector)
# print(RGB_list)
return color_dict_maker(RGB_list)
def polylinear_gradient(colors: list[str], n: int):
"""returns a list of colors forming linear gradients between
all sequential pairs of colors. "n" specifies the total
number of desired output colors"""
# The number of colors per individual linear gradient
n_out = int(float(n) / (len(colors) - 1)) + 1
# If we don't have an even number of color values, we will remove equally spaced values at the end.
apply_offset = False
if n % n_out != 0:
apply_offset = True
n_out = n_out + 1
# returns dictionary defined by color_dict()
gradient_dict = linear_gradient(colors[0], colors[1], n_out)
if len(colors) > 1:
for col in range(1, len(colors) - 1):
next = linear_gradient(colors[col], colors[col + 1], n_out)
for k in ("hex", "r", "g", "b"):
# Exclude first point to avoid duplicates
gradient_dict[k] += next[k][1:]
# Remove equally spaced values here.
if apply_offset:
offset = len(gradient_dict["hex"]) - n
sliceval = []
for i in range(1, offset + 1):
sliceval.append(int(len(gradient_dict["hex"]) * i / float(offset + 2)))
for k in ("hex", "r", "g", "b"):
gradient_dict[k] = [i for j, i in enumerate(gradient_dict[k]) if j not in sliceval]
return gradient_dict
def get_poly_gradient_ct(palette: list[str], min: int, max: int) -> list[str]:
"""
Take a palette and a set of min and max stretch values to get a 1:1 value to color hex list
Args:
palette (list): A list of hex code colors that will be interpolated
min (int): The min value for the stretch
max (int): The max value for the stretch
Returns:
list: A list of linearly interpolated hex codes where there is 1:1 color to value from min-max (inclusive)
>>> import geeViz.geeView as gv
>>> viz = {"palette": ["#FFFF00", "00F", "0FF", "FF0000"], "min": 1, "max": 20}
>>> color_ramp = gv.get_poly_gradient_ct(viz["palette"], viz["min"], viz["max"])
>>> print("Color ramp:", color_ramp)
"""
ramp = polylinear_gradient(palette, max - min + 1)
return ramp["hex"]
##############################################################
######################################################################
# Function to check if being run inside a notebook
# Taken from: https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook
def is_notebook():
"""
Check if inside Jupyter shell
Returns:
bool: Whether inside Jupyter shell or not
"""
return ee.oauth._in_jupyter_shell()
######################################################################
# Function for cleaning trailing .... in accessToken
def cleanAccessToken(accessToken):
"""
Remove trailing '....' in generated access token
Args:
accessToken (str): Raw access token
Returns:
str: Given access token without trailing '....'
"""
while accessToken[-1] == ".":
accessToken = accessToken[:-1]
return accessToken
######################################################################
# Function to get domain base without any folders
def baseDomain(url):
"""
Get root domain for a given url
Args:
url (str): URL to find the base domain of
Returns:
str: domain of given URL
"""
url_parts = urlparse(url)
return f"{url_parts.scheme}://{url_parts.netloc}"
######################################################################
# Function for using default GEE refresh token to get an access token for geeView
# Updated 12/23 to reflect updated auth methods for GEE
def refreshToken():
"""
Get a refresh token from currently authenticated ee instance
Returns:
str: temporary access token
"""
credentials = ee.data.get_persistent_credentials()
credentials.refresh(gReq.Request())
accessToken = credentials.token
# print(credentials.to_json())
accessToken = cleanAccessToken(accessToken)
return accessToken
######################################################################
# Function for using a GEE white-listed service account key to get an access token for geeView
def serviceAccountToken(service_key_file_path):
"""
Get a refresh token from service account key file credentials
Returns:
str: temporary access token
"""
try:
credentials = service_account.Credentials.from_service_account_file(service_key_file_path, scopes=ee.oauth.SCOPES)
credentials.refresh(gReq.Request())
accessToken = credentials.token
accessToken = cleanAccessToken(accessToken)
return accessToken
except Exception as e:
print(e)
print("Failed to utilize service account key file.")
return None
######################################################################
# In-process threaded HTTP server backing `Map.view()`.
#
# Historically `run_local_server` spawned a subprocess (`python -m http.server`)
# which required PID-file bookkeeping and regularly left orphans. As of
# geeViz 2026.3.3 the server runs as a daemon thread inside the Python process,
# rooted at the geeViz package dir via `directory=` (no chdir side effects).
#
# The server exists only to provide a real HTTP origin for the rendered
# viewer — this matters because the Google Maps JS API key baked into
# `index.html` has HTTP referrer restrictions that reject `file://` and
# `about:srcdoc` origins. Serving via `http://localhost:<port>/...` gives
# Maps a referrer it accepts.
#
# `Map.view()` writes the per-session runGeeViz.js and opens index.html
# into `geeView/<ee_run_name>.html` and then navigates the browser / IFrame
# to `http://localhost:<port>/geeView/<ee_run_name>.html`. Relative asset
# paths (`./src/...`) resolve through the same server.
_RUNNING_SERVERS = {} # port -> (server, thread)
import threading as _threading
# Reentrant lock so `run_local_server` can call `_kill_server` (which also
# acquires this lock) while holding it — a non-reentrant `Lock()` would
# deadlock and hang `Map.view()` any time a stale state file is found.
_SERVERS_LOCK = _threading.RLock()
# Upstream URL of the live eeAuth proxy. ``Map.view`` sets this when it
# finds an active ``eeCreds`` proxy; the request handler reads it to
# reverse-proxy ``/ee-api/*`` requests so the viewer JS can use the
# same-origin default of ``window.location.origin + "/ee-api"`` without
# the browser ever talking to the proxy's port directly.
_EE_API_UPSTREAM: "str | None" = None
_EE_API_UPSTREAM_LOCK = _threading.Lock()
# Lazily-built connection pool for the reverse-proxy leg
# (viewer-server → uvicorn proxy). Created on first ``_proxy_ee_api``
# call. Without pooling, every value:compute/getMapId fired by the
# viewer opens a fresh TCP connection to the uvicorn proxy, which adds
# kernel-level overhead on every layer query — small per request but
# very visible when the viewer fires N parallel queries per click.
_EE_API_POOL = None
_EE_API_POOL_LOCK = _threading.Lock()
def _get_ee_api_pool():
"""Return a process-wide ``urllib3.PoolManager`` for the
viewer→uvicorn forwarding leg. Built on first use because
``urllib3`` is a transitive dep and we don't want to import it at
geeView load time for users who never call ``Map.view``."""
global _EE_API_POOL
if _EE_API_POOL is None:
with _EE_API_POOL_LOCK:
if _EE_API_POOL is None:
import urllib3
_EE_API_POOL = urllib3.PoolManager(
num_pools=8,
maxsize=32,
block=False,
timeout=urllib3.Timeout(connect=10, read=300),
)
return _EE_API_POOL
def _set_ee_api_upstream(url: "str | None") -> None:
"""Register the upstream eeAuth proxy URL with the local HTTP server.
Idempotent. Pass ``None`` to disable reverse-proxying (the /ee-api
handler will then 503).
"""
global _EE_API_UPSTREAM
with _EE_API_UPSTREAM_LOCK:
_EE_API_UPSTREAM = (url or "").rstrip("/") or None
def _resolve_ee_tenant(request_path: str, referer: str) -> str:
"""Pick the tenant to stamp on a forwarded /ee-api request.
Precedence: ``?tenant=…`` on the incoming request → ``?tenant=…`` on
the Referer URL (so EE calls triggered by a page that pinned itself
to a tenant route through correctly) → ``eeCreds.current()`` as the
process-wide default.
"""
from urllib.parse import urlparse, parse_qs
for src in (request_path, referer):
if not src:
continue
q = parse_qs(urlparse(src).query)
tenants = q.get("tenant") or []
if tenants and tenants[0]:
return tenants[0]
try:
from geeViz.eeAuth.eeCreds import eeCreds as _eeCreds
return _eeCreds.current() or ""
except Exception:
return ""
# Headers that must NOT be copied between client / upstream connections.
# Some are hop-by-hop per RFC 7230 §6.1; ``host`` and ``content-length``
# are rebuilt by the outbound urllib request itself.
_PROXY_HOP_BY_HOP_HEADERS = frozenset({
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
"host", "content-length",
})
class _GeeVizRequestHandler(http.server.SimpleHTTPRequestHandler):
"""SimpleHTTPRequestHandler rooted at the geeViz package dir, with a
/ee-api/* reverse-proxy hook.
File serving uses ``directory=py_viz_dir`` so it works regardless of
the process cwd. Access logs are silenced to avoid notebook stderr spam.
Any request whose path starts with ``/ee-api/`` is forwarded to the
upstream eeAuth proxy registered via ``_set_ee_api_upstream``. The
handler stamps an ``X-geeViz-Creds`` tenant header based on
``_resolve_ee_tenant``, strips hop-by-hop headers, and streams the
response back. Lets the viewer JS use the same-origin
``/ee-api`` default without the URL having to carry the actual
proxy address.
"""
def __init__(self, *args, **kwargs):
kwargs["directory"] = py_viz_dir
super().__init__(*args, **kwargs)
def log_message(self, format, *args): # noqa: A002 - stdlib signature
"""Silence per-request access logs.
stdlib's default handler writes one line per request to stderr,
which pollutes the notebook / terminal when the map fetches
dozens of tiles. Overriding to a no-op keeps ``Map.view()``
output focused on user prints.
"""
return
def end_headers(self): # noqa: D401 - stdlib API
"""Force no-cache on every static response.
Without this, browsers cache the geeView JS bundle indefinitely
(the stdlib server emits no ``Cache-Control``, only ``Last-Modified``,
which browsers freely cache). When we ship a JS update — e.g.
the same-origin ``/ee-api`` default replacing the heroku URL —
users keep hitting the old bundle until they hard-refresh, and
the symptoms (cross-origin requests to a long-dead proxy) are
impossible to diagnose without DevTools. Forcing no-store
eliminates the failure mode entirely; cost is one network
round-trip per asset per page load, which is irrelevant for a
local dev server.
"""
self.send_header("Cache-Control", "no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
# ---- /ee-api reverse-proxy ----
def _is_ee_api(self) -> bool:
# ``self.path`` may include the query string; check just the path.
from urllib.parse import urlparse
return urlparse(self.path).path.startswith("/ee-api/") or \
urlparse(self.path).path == "/ee-api"
def _proxy_ee_api(self) -> None:
upstream = _EE_API_UPSTREAM
if not upstream:
self.send_error(503, "eeAuth proxy not registered")
return
# Both ``self.path`` and ``upstream`` carry the ``/ee-api`` prefix.
# Strip it from the incoming path so we don't double it.
suffix = self.path
if suffix.startswith("/ee-api"):
suffix = suffix[len("/ee-api"):]
# Map.view() bakes the tenant into the JS-side proxy URL as a
# ``/t/<tenant>/`` path prefix (rather than a ``?tenant=`` query
# on the page URL). Strip it here and surface the tenant for
# routing. This keeps the page URL bar clean AND pins every tab
# to its tenant for the lifetime of the page — process-wide
# eeCreds.use() switches can't drift open tabs to other creds.
path_tenant = ""
if suffix.startswith("/t/"):
rest = suffix[len("/t/"):]
slash = rest.find("/")
if slash > 0:
path_tenant = rest[:slash]
suffix = rest[slash:]
else:
# ``/ee-api/t/<tenant>`` with no trailing segment — keep
# the suffix as-is and treat as the tenant ack endpoint.
path_tenant = rest
suffix = "/"
target_url = upstream + suffix # upstream already ends without trailing slash
# Read body (if any). EE often POSTs JSON; for streaming uploads we'd
# need chunked forwarding, but EE doesn't use that path.
try:
content_length = int(self.headers.get("Content-Length", "0") or 0)
except ValueError:
content_length = 0
body = self.rfile.read(content_length) if content_length > 0 else None
# Forward most headers; strip hop-by-hop and overwrite tenant.
out_headers = {}
for h, v in self.headers.items():
if h.lower() in _PROXY_HOP_BY_HOP_HEADERS:
continue
out_headers[h] = v
# Tenant precedence: ``/t/<tenant>/`` path segment (per-tab pin)
# → ``?tenant=`` on request or Referer (legacy) → eeCreds.current()
# process-wide default (only safe in single-tenant setups).
tenant = path_tenant or _resolve_ee_tenant(
self.path, self.headers.get("Referer", ""),
)
if tenant:
out_headers["X-geeViz-Creds"] = tenant
# Use the shared pool so connections to the uvicorn proxy
# stay alive across requests. urllib3 ``preload_content=False``
# streams the body chunk-by-chunk on the way back, matching the
# original ``urlopen``+read-loop behavior without buffering the
# whole response (important for getMapId tile responses).
try:
pool = _get_ee_api_pool()
resp = pool.request(
self.command, target_url,
body=body, headers=out_headers,
preload_content=False,
retries=False,
redirect=False,
)
except Exception as e:
self.send_error(502, f"eeAuth proxy unreachable: {e}")
return
try:
self._relay_response(resp.status, resp.headers, resp)
finally:
resp.release_conn()
def _relay_response(self, status: int, headers, body_stream) -> None:
self.send_response(status)
for h, v in headers.items():
if h.lower() in _PROXY_HOP_BY_HOP_HEADERS:
continue
self.send_header(h, v)
self.end_headers()
# Stream in chunks to avoid loading huge tile/compute responses into
# memory in one go.
while True:
chunk = body_stream.read(64 * 1024)
if not chunk:
break
try:
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
# Browser hung up — common when panning the map fast.
return
# Override each HTTP verb so reverse-proxy fires for /ee-api/*; everything
# else falls through to ``SimpleHTTPRequestHandler``'s static-file behavior.
# All six ``do_*`` methods share the same shape: if the request path is
# ``/ee-api/*`` proxy it upstream, otherwise (GET) serve static or (others)
# 405. Documenting once instead of six identical docstrings.
def do_GET(self): # noqa: N802 - stdlib API
"""Static file for non-``/ee-api/*`` paths, proxy otherwise."""
if self._is_ee_api():
return self._proxy_ee_api()
return super().do_GET()
def do_POST(self): # noqa: N802 - stdlib API
"""Proxy ``/ee-api/*`` POSTs; anything else → 405."""
if self._is_ee_api():
return self._proxy_ee_api()
self.send_error(405, "Method Not Allowed")
def do_PUT(self): # noqa: N802 - stdlib API
"""Proxy ``/ee-api/*`` PUTs; anything else → 405."""
if self._is_ee_api():
return self._proxy_ee_api()
self.send_error(405, "Method Not Allowed")
def do_DELETE(self): # noqa: N802 - stdlib API
"""Proxy ``/ee-api/*`` DELETEs; anything else → 405."""
if self._is_ee_api():
return self._proxy_ee_api()
self.send_error(405, "Method Not Allowed")
def do_PATCH(self): # noqa: N802 - stdlib API
"""Proxy ``/ee-api/*`` PATCHes; anything else → 405."""
if self._is_ee_api():
return self._proxy_ee_api()
self.send_error(405, "Method Not Allowed")
def do_OPTIONS(self): # noqa: N802 - stdlib API
"""Proxy ``/ee-api/*`` OPTIONS preflight; anything else → 405."""
if self._is_ee_api():
return self._proxy_ee_api()
# No CORS preflight needed for static files served same-origin.
self.send_error(405, "Method Not Allowed")
def run_local_server(port: int = 8001):
"""
Start the in-process threaded geeViz web server, rooted at the geeViz
package directory.
The function is idempotent: if a server is already running on `port`, it
returns the existing port number without restarting. If `port` is held by
an unrelated process (or a stale subprocess from an older geeViz version
that we can't kill), we transparently auto-pick a free port and return
the actual port that ended up bound.
Args:
port (int): Preferred port number. If unavailable, a free port is
auto-selected.
Returns:
int: The port number the server is actually bound to. Callers should
use this (not the originally-requested port) when building URLs.
"""
with _SERVERS_LOCK:
if port in _RUNNING_SERVERS:
return port
# If the preferred port is already active, it may be a leftover
# subprocess from an older geeViz version — try to kill it via the
# PID file so we can take over cleanly. Stale state files (PID
# already dead) are also handled here: `_kill_server` just removes
# the file. After this, re-check the port status.
if isPortActive(port):
state = _read_server_state(port)
if state and "pid" in state and state["pid"] != os.getpid():
_kill_server(port)
time.sleep(0.5)
else:
# No state file we can act on — just clean up any stale
# file so it doesn't confuse future runs.
_kill_server(port)
# On Windows, binding to an already-listening port can spuriously
# succeed (SO_REUSEADDR semantics differ from POSIX), leaving us
# with a "server" that can't actually accept connections. So we
# always check `isPortActive` first and fall straight to port 0
# (OS-assigned) if the preferred port is still held — `bind()` is
# not a reliable collision detector on Windows.
if isPortActive(port):
print("Port {} still held after cleanup — auto-picking a free port".format(port))
port = 0
try:
server = socketserver.ThreadingTCPServer(("127.0.0.1", port), _GeeVizRequestHandler)
except OSError as e:
# Preferred port somehow failed even though isPortActive said it
# was free. Fall back once to OS-assigned.
if port != 0:
print("Bind on port {} failed ({}) — auto-picking a free port".format(port, e))
try:
server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), _GeeVizRequestHandler)
except OSError as e2:
print("Failed to bind any local port for geeViz server: {}".format(e2))
return None
else:
print("Failed to bind any local port for geeViz server: {}".format(e))
return None
port = server.server_address[1]
server.daemon_threads = True
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
_RUNNING_SERVERS[port] = (server, thread)
_write_server_state(port, os.getpid(), py_viz_dir)
return port
######################################################################
# Function to see if port is active
def isPortActive(port: int = 8001):
"""
See if a given port number is currently active
Args:
port (int): Port number to check status of
Returns:
bool: Whether or not the port is already active
"""
# The original code creates a socket and may leave it open (orphaned) if not explicitly closed,
# since it does not use a context manager or explicit close. The revised code uses
# a `with` statement to ensure that the socket is properly closed after use,
# preventing orphan sockets and resource leaks.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(2) # 2 Second Timeout
result = sock.connect_ex(("localhost", port))
if result == 0:
return True
else:
return False
######################################################################
# Server state management helpers
def _server_state_path(port):
"""Return path to the server state file for a given port."""
return os.path.join(tempfile.gettempdir(), ".geeViz_server_{}.json".format(port))
def _read_server_state(port):
"""Read server state {pid, root_dir} from the temp file. Returns None if missing."""
path = _server_state_path(port)
if os.path.exists(path):
try:
with open(path, "r") as f:
return json.load(f)
except Exception:
pass
return None
def _write_server_state(port, pid, root_dir):
"""Write server state to a temp file keyed by port."""
path = _server_state_path(port)
with open(path, "w") as f:
json.dump({"pid": pid, "root_dir": root_dir, "port": port}, f)
def _kill_server(port):
"""Shut down an http server tracked for `port`, whether it's in-process
(preferred path) or a legacy subprocess left behind by an older geeViz
version."""
with _SERVERS_LOCK:
entry = _RUNNING_SERVERS.pop(port, None)
if entry is not None:
server, _thread = entry
try:
server.shutdown()
server.server_close()
except Exception:
pass
else:
# Legacy subprocess case — fall back to the old PID-based kill path.
state = _read_server_state(port)
if state and "pid" in state and state["pid"] != os.getpid():
try:
os.kill(state["pid"], signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
pass
path = _server_state_path(port)
if os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
def _detect_proxy_url():
"""Auto-detect the proxy URL for the current environment.
Tries, in order:
1. ``GEEVIZ_PROXY_URL`` environment variable — set this for Cloud Run
or any custom deployment (e.g. ``GEEVIZ_PROXY_URL=https://my-service.run.app``).
2. GCE metadata server — works on Vertex AI Workbench, where the
instance name + region are available at a well-known endpoint and
the proxy URL follows a predictable pattern.
3. Fall back to ``input()`` prompt — same behavior as original geeViz
for environments where auto-detection fails.
Returns:
str: the proxy base URL (e.g. ``https://instance-dot-region.notebooks.googleusercontent.com``).
"""
# 1. Explicit env var — highest priority, works everywhere
env_url = os.getenv("GEEVIZ_PROXY_URL")
if env_url:
print("Using proxy URL from GEEVIZ_PROXY_URL env var:", env_url)
return env_url
# 2. GCE metadata — auto-detect on Vertex AI Workbench
try:
meta_headers = {"Metadata-Flavor": "Google"}
instance = requests.get(
"http://metadata.google.internal/computeMetadata/v1/instance/name",
headers=meta_headers, timeout=2
).text
zone = requests.get(
"http://metadata.google.internal/computeMetadata/v1/instance/zone",
headers=meta_headers, timeout=2
).text.split("/")[-1]
region = "-".join(zone.split("-")[:-1])
proxy_url = "https://{}-dot-{}.notebooks.googleusercontent.com".format(instance, region)
print("Auto-detected Workbench proxy URL:", proxy_url)
return proxy_url
except Exception:
pass
# 3. Fall back to prompt
return input(
"Please enter the URL your notebook/service is running from "
"(e.g. https://code-dot-region.notebooks.googleusercontent.com/): "
)
def _ensure_server(port):
"""Ensure an in-process HTTP server is serving from py_viz_dir. Returns
the port the server is actually bound to — may differ from the requested
port if it was unavailable and we auto-picked a free one. Safe to call
from every `Map.view()`.
"""
with _SERVERS_LOCK:
if port in _RUNNING_SERVERS:
return port
actual = run_local_server(port)
if actual is None:
return None
# Only surface a message when the port had to be reassigned — the
# normal case is silent so the only URL a user sees is the actual
# geeView URL printed later by view(). Two URLs in the output was
# confusing users who thought they should click the server root.
if actual != port:
print("geeViz server bound to http://localhost:{}/{}/ (requested {})".format(actual, geeViewFolder, port))
return actual
######################################################################
######################################################################
######################################################################
# Set up mapper object
class mapper:
"""Primary geeViz map setup and manipulation object.
The `mapper` builds up a list of GEE layers and map commands (`addLayer`,
`addTimeLapse`, `turnOnInspector`, `setCenter`, etc.) and then launches
the interactive geeView web viewer via `view()`.
**Rendering flow (as of geeViz 2026.3.3)**
`Map.view()` writes the per-session `runGeeViz.js` to its canonical
disk location (`geeView/src/gee/gee-run/`) and opens
`geeView/index.html` directly:
- **Plain Python / scripts** — opened via a `file://` URL with the
access token passed as a query string. No HTTP server needed.
- **Notebooks (VS Code, Jupyter)** — displayed inline via an
`IFrame(src="http://localhost:<port>/geeView/...")` backed by an
in-process threaded `http.server` (daemon thread, no subprocess).
VS Code's webview blocks `file://` in iframes, so a real HTTP
origin is required for inline display. The server auto-picks a
free port if the preferred one (default 8001) is held.
- **Colab / Vertex AI Workbench** — uses platform-specific proxy
URLs via `google.colab.kernel.proxyPort()` or `self.proxy_url`.
The `buildgeeViz.py` build script patches `lcms-viewer.min.js` so
the viewer's runtime `loadGEELibraries()` call uses
`document.createElement('script')` instead of `$.getScript()` (which
is jQuery XHR — blocked by Chrome under `file://`). It also strips
the dead `require(...)` fallback from `changeDetectionLib.js`.
**Key methods**
- `view(open_browser=None, open_iframe=None, iframe_height=525)` —
launch the viewer
- `addLayer` / `addTimeLapse` / `addSelectLayer` / `turnOnInspector` /
`turnOnAutoAreaCharting` / `setCenter` / `centerObject` / `clearMap`
- `refresh()` — re-run the last `view()` with a fresh token
Args:
port (int, default 8001): Port for the in-process http.server
used for notebook iframe display. Auto-picks a free port
if unavailable.
Attributes:
port (int, default 8001): Port for the in-process http.server
used for notebook iframe display. Auto-picks a free port
if unavailable.
proxy_url (str, default None): Vertex AI Workbench proxy URL used
when `view()` runs inside a Workbench notebook. Auto-prompted
on first call if unset; set manually in advance (e.g.
`Map.proxy_url = "https://code-dot-region.notebooks.googleusercontent.com/"`)
to skip the prompt. Ignored outside Workbench.
refreshTokenPath (str, default ee.oauth.get_credentials_path()):
Path to the Earth Engine refresh token credentials file used to
mint fresh access tokens on each `view()` call.
serviceKeyPath (str, default None): Path to a service account key
JSON. If provided, it will be used for authentication inside
geeView instead of the refresh token — useful for headless
deployments (Cloud Run, scheduled jobs) where no user refresh
token is available.
project (str, default ee.data._get_state().cloud_api_user_project):
Google Cloud project id used for Earth Engine. `geeViz` tries to
resolve this automatically from `ee.Initialize(project=...)`; set
it manually if `Map.view()` logs `project=None`.
turnOffLayersWhenTimeLapseIsOn (bool, default True): Whether all
other layers should be turned off when a time lapse is turned
on. Default is True to avoid confusing layer-order rendering
when time lapses and non-time lapses are visible at the same
time. Set to False if you want them visible simultaneously.
showToolTipModal (bool, default False): Whether to show the tooltip modal when the map is loaded.
"""
def __call__(self):
"""Allow ``gv.Map()`` to return the singleton instead of raising TypeError."""
return self
@property
def port(self) -> int:
return self._port
@port.setter
def port(self, value: int) -> None:
# No warning here — Map.port IS honored in attached mode.
# If the user is on detached and the port ends up ignored,
# Map.view() prints a runtime hint after it knows the
# resolved mode.
self._port = int(value)
_DEFAULT_PORT = 8001
def __init__(self, port: int = _DEFAULT_PORT):
# Stored on ``_port`` so the deprecated public ``port`` setter
# (see property above) can log a warning without recursing into
# itself and so internal writes (``_ensure_server`` fallback
# port pick) can bypass the warning path.
self._port = int(port)
self.layerNumber = 1
self.idDictList = []
self.mapCommandList = []
self.ee_run_name = "runGeeViz"
self.typeLookup = {
"Image": "geeImage",
"ImageCollection": "geeImageCollection",
"Feature": "geeVectorImage",
"FeatureCollection": "geeVectorImage",
"Geometry": "geeVectorImage",
"dict": "geoJSONVector",
}
try:
self.isNotebook = ee.oauth._in_jupyter_shell()
except:
self.isNotebook = ee.oauth.in_jupyter_shell()
try:
self.isColab = ee.oauth._in_colab_shell()
except:
self.isColab = ee.oauth.in_colab_shell()
self.proxy_url = None
self.refreshTokenPath = ee.oauth.get_credentials_path()
self.serviceKeyPath = None
self.queryWindowMode = "sidePane"
self.project = ee.data._get_state().cloud_api_user_project
self.turnOffLayersWhenTimeLapseIsOn = True
self.showToolTipModal = False
# eeAuth mode override for Map.view() — takes precedence over