mirror of
https://github.com/opengeos/GeoLibre.git
synced 2026-11-04 10:43:36 +00:00
Address review feedback
- _server.py: gate the Content-Range/X-GeoLibre-Content-Range headers on a newly parsed range rather than on the presence of a Range header, so a non-bytes unit (e.g. "items=0-1") no longer emits a spec-invalid Content-Range alongside a full-file 200. - _server.py: send Access-Control-Expose-Headers for X-GeoLibre-Content-Range, without which a cross-origin fetch cannot read it and the app leaves the response at 200 instead of rebuilding the 206. - _frontend.js: require an empty port on the Colab proxy hostname, so a look-alike host on a foreign network port does not enter the rewrite path. - _frontend.js: extract the transport gate into an exported canProxyLocalFiles(base, port) and cover it in tests, so a future proxyBase/resolveBase refactor cannot silently stop the rewrite. - geolibre.py: resolve the local raster path once and hand the resolved path to both register_raster_tiles and rasterio.open — GDAL does not expand "~", so a "~/dem.tif" source silently lost its computed bounds. - kernel-proxy-range.ts: export KERNEL_CONTENT_RANGE_HEADER and cross-reference the three mirrored literals from _frontend.js and _server.py; a new test asserts both Python sources against the exported constants. - python-api.md: fix the duplicated "JupyterHub when" clause, matching the wording in docs/python.md. - tests: cover the malformed-range 200, the exposed range header, and both the populated and None bounds paths of add_cog's Colab branch.
This commit is contained in:
1 parent
45599eff6a
commit
8af3e91c7d
8 files changed
+226
-11
No files matched your search
@@ -1,8 +1,15 @@
|
||||
// These three strings are the wire contract of the Colab range bridge, and each
|
||||
// is spelled out again on the Python side — the marker in
|
||||
// `python/src/geolibre/_frontend.js`, the query parameter and the header in
|
||||
// `python/src/geolibre/_server.py`. Neither can import from this module, so
|
||||
// `tests/python-widget-frontend.test.ts` asserts those sources against the
|
||||
// constants here; keep all three in step.
|
||||
/** Query marker added by the Python widget to local raster URLs in Colab. */
|
||||
export const KERNEL_RANGE_PROXY_MARKER = "__geolibre_range_proxy";
|
||||
/** Query parameter carrying a Range header through proxies that remove it. */
|
||||
export const KERNEL_RANGE_QUERY = "__geolibre_range";
|
||||
const KERNEL_CONTENT_RANGE_HEADER = "X-GeoLibre-Content-Range";
|
||||
/** Response header carrying the served span past a proxy that flattens 206. */
|
||||
export const KERNEL_CONTENT_RANGE_HEADER = "X-GeoLibre-Content-Range";
|
||||
|
||||
/**
|
||||
* Move a byte-range header into the URL for a marked kernel-local request.
|
||||
|
||||
@@ -66,8 +66,11 @@ function proxiedLocalFileUrl(raw, base, port) {
|
||||
// project synced back by an older view can therefore carry a valid token on
|
||||
// that view's now-foreign origin. Recognize the same-port proxy hostname and
|
||||
// rebase it onto the current view's proxy URL below.
|
||||
// The proxy origin carries no explicit port; requiring an empty one keeps a
|
||||
// look-alike host on some other network port out of the rewrite path.
|
||||
const colabProxy =
|
||||
parsed.protocol === "https:" &&
|
||||
parsed.port === "" &&
|
||||
parsed.hostname.endsWith(`-${port}-colab.googleusercontent.com`);
|
||||
if (
|
||||
((!loopback || parsed.port !== String(port)) && !colabProxy) ||
|
||||
@@ -81,6 +84,9 @@ function proxiedLocalFileUrl(raw, base, port) {
|
||||
// Mark these URLs so the embedded app can carry each requested byte range in
|
||||
// the query string instead; the token-protected Python route translates it
|
||||
// back into a normal 206 response. Other Jupyter proxies keep using headers.
|
||||
// The marker mirrors KERNEL_RANGE_PROXY_MARKER in
|
||||
// apps/geolibre-desktop/src/lib/kernel-proxy-range.ts, which reads it back;
|
||||
// tests/python-widget-frontend.test.ts fails if the two drift.
|
||||
if (
|
||||
rewritten.pathname.startsWith("/_geolibre_local/") &&
|
||||
rewritten.hostname.endsWith(`-${port}-colab.googleusercontent.com`)
|
||||
@@ -92,6 +98,17 @@ function proxiedLocalFileUrl(raw, base, port) {
|
||||
return rewritten.href.replace(/%7B([zxy])%7D/gi, "{$1}");
|
||||
}
|
||||
|
||||
// Whether the browser can reach the kernel's tokenized local-file routes by
|
||||
// rebasing them onto `base`. True in Colab, where the iframe itself is loaded
|
||||
// through the per-port proxy, and wherever the app is already served by
|
||||
// jupyter-server-proxy on that same kernel port. Under the Jupyter Server
|
||||
// extension the app comes from a different route than the kernel's port, so
|
||||
// the loopback URL cannot be rewritten and is left alone.
|
||||
export function canProxyLocalFiles(base, port) {
|
||||
if (!port) return false;
|
||||
return Boolean(colabKernel()) || base === proxyBase(port);
|
||||
}
|
||||
|
||||
export function rewriteProxiedLocalFileUrls(project, base, port) {
|
||||
if (!project || typeof project !== "object" || !Array.isArray(project.layers) || !port) {
|
||||
return project;
|
||||
@@ -271,8 +288,7 @@ async function render({ model, el }) {
|
||||
const pushProject = () => {
|
||||
if (!ready) return;
|
||||
const port = model.get("_app_port");
|
||||
const canProxyLocalFiles = colabKernel() || base === proxyBase(port);
|
||||
const project = canProxyLocalFiles
|
||||
const project = canProxyLocalFiles(base, port)
|
||||
? rewriteProxiedLocalFileUrls(model.get("project"), base, port)
|
||||
: model.get("project");
|
||||
post({
|
||||
|
||||
@@ -89,10 +89,20 @@ class _QuietHandler(SimpleHTTPRequestHandler):
|
||||
start, end, status = 0, size - 1, 200
|
||||
range_header = self.headers.get("Range")
|
||||
query_range = False
|
||||
# Set only once a range actually parses. `status` cannot stand in
|
||||
# for it: the query transport deliberately keeps a matched range at
|
||||
# 200, and an unparsable unit (e.g. "items=0-1") leaves both the
|
||||
# status and the full-file span untouched.
|
||||
range_matched = False
|
||||
# Google Colab's port proxy strips Range request headers. The
|
||||
# embedded app moves the value into this query parameter only for a
|
||||
# marked, tokenized local-file URL, preserving exact COG partial
|
||||
# reads without downloading the full raster through the proxy.
|
||||
# reads without downloading the full raster through the proxy. The
|
||||
# parameter and the response header below mirror KERNEL_RANGE_QUERY
|
||||
# and KERNEL_CONTENT_RANGE_HEADER in
|
||||
# apps/geolibre-desktop/src/lib/kernel-proxy-range.ts, which writes
|
||||
# and reads them; tests/python-widget-frontend.test.ts asserts this
|
||||
# file against those constants.
|
||||
if not range_header:
|
||||
range_header = parse_qs(urlsplit(self.path).query).get("__geolibre_range", [None])[
|
||||
0
|
||||
@@ -106,6 +116,7 @@ class _QuietHandler(SimpleHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
return
|
||||
start, end = parsed
|
||||
range_matched = True
|
||||
# Colab's proxy can normalize an upstream 206 into a full-file
|
||||
# 200 response. For the query transport, send the requested
|
||||
# slice as an ordinary 200 and let the embedded app reconstruct
|
||||
@@ -120,9 +131,13 @@ class _QuietHandler(SimpleHTTPRequestHandler):
|
||||
# working if the app is served from a different origin (the file
|
||||
# server is loopback-only and serves only registered tokens).
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
if range_header:
|
||||
if range_matched:
|
||||
if query_range:
|
||||
self.send_header("X-GeoLibre-Content-Range", f"bytes {start}-{end}/{size}")
|
||||
# A non-safelisted response header is hidden from fetch()
|
||||
# unless it is exposed, and the app needs to read it to
|
||||
# rebuild the 206 the proxy flattened.
|
||||
self.send_header("Access-Control-Expose-Headers", "X-GeoLibre-Content-Range")
|
||||
else:
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self.end_headers()
|
||||
|
||||
@@ -1908,8 +1908,11 @@ class Map(anywidget.AnyWidget):
|
||||
if self._running_on_colab() and not (
|
||||
isinstance(url, str) and url.startswith(("http://", "https://"))
|
||||
):
|
||||
# Resolve once so rasterio sees the same file the tile route
|
||||
# registered; GDAL does not expand "~" on its own.
|
||||
local_path = pathlib.Path(url).expanduser().resolve()
|
||||
tile_url = register_raster_tiles(
|
||||
url,
|
||||
local_path,
|
||||
bands=bands,
|
||||
colormap=colormap,
|
||||
rescale=rescale,
|
||||
@@ -1918,7 +1921,7 @@ class Map(anywidget.AnyWidget):
|
||||
import rasterio
|
||||
from rasterio.warp import transform_bounds
|
||||
|
||||
with rasterio.open(url) as dataset:
|
||||
with rasterio.open(local_path) as dataset:
|
||||
bounds = list(
|
||||
transform_bounds(
|
||||
dataset.crs,
|
||||
|
||||
@@ -717,6 +717,58 @@ def test_add_raster_xarray_uses_xyz_tiles_on_colab(monkeypatch, m):
|
||||
}
|
||||
|
||||
|
||||
def test_add_cog_on_colab_computes_bounds_from_the_local_raster(monkeypatch, m, tmp_path):
|
||||
"""The Colab tile branch reads the raster's own extent, reprojected to WGS84."""
|
||||
rasterio = pytest.importorskip("rasterio")
|
||||
np = pytest.importorskip("numpy")
|
||||
from rasterio.transform import from_bounds
|
||||
|
||||
raster = tmp_path / "dem.tif"
|
||||
with rasterio.open(
|
||||
raster,
|
||||
"w",
|
||||
driver="GTiff",
|
||||
width=4,
|
||||
height=4,
|
||||
count=1,
|
||||
dtype="float32",
|
||||
crs="EPSG:4326",
|
||||
transform=from_bounds(-120, 30, -100, 45, 4, 4),
|
||||
) as dst:
|
||||
dst.write(np.zeros((1, 4, 4), dtype="float32"))
|
||||
|
||||
monkeypatch.setattr(Map, "_running_on_colab", staticmethod(lambda: True))
|
||||
monkeypatch.setattr(
|
||||
gmod,
|
||||
"register_raster_tiles",
|
||||
lambda path, **_options: "http://127.0.0.1:1234/_geolibre_tiles/t/{z}/{x}/{y}.png",
|
||||
)
|
||||
# "~" is what GDAL will not expand on its own, so hand add_cog that form.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
|
||||
m.add_cog("~/dem.tif", name="DEM")
|
||||
|
||||
bounds = _last_layer(m)["source"]["bounds"]
|
||||
assert bounds == pytest.approx([-120, 30, -100, 45], abs=1e-6)
|
||||
|
||||
|
||||
def test_add_cog_on_colab_tolerates_an_unreadable_raster(monkeypatch, m, tmp_path):
|
||||
"""A file rasterio cannot open still yields a layer, just without bounds."""
|
||||
raster = tmp_path / "not-a.tif"
|
||||
raster.write_bytes(b"fake geotiff")
|
||||
monkeypatch.setattr(Map, "_running_on_colab", staticmethod(lambda: True))
|
||||
monkeypatch.setattr(
|
||||
gmod,
|
||||
"register_raster_tiles",
|
||||
lambda path, **_options: "http://127.0.0.1:1234/_geolibre_tiles/t/{z}/{x}/{y}.png",
|
||||
)
|
||||
|
||||
m.add_cog(raster, name="DEM")
|
||||
|
||||
assert _last_layer(m)["source"].get("bounds") is None
|
||||
|
||||
|
||||
def test_add_raster_accepts_deprecated_url_keyword(m):
|
||||
"""The pre-rename `url=` keyword still works, with a DeprecationWarning."""
|
||||
with pytest.warns(DeprecationWarning, match="add_raster\\(url=...\\) is deprecated"):
|
||||
|
||||
@@ -106,6 +106,23 @@ def test_open_ended_range(served):
|
||||
assert body == payload[10200:]
|
||||
|
||||
|
||||
def test_non_bytes_range_unit_serves_the_whole_file(served):
|
||||
"""An unparsable range unit falls back to a plain 200, with no Content-Range."""
|
||||
status, headers, body = _get(served[0], {"Range": "items=0-99"})
|
||||
assert status == 200
|
||||
assert body == served[1]
|
||||
# Content-Range belongs on 206/416 only; emitting it here would contradict
|
||||
# the full-file body.
|
||||
assert "Content-Range" not in headers
|
||||
assert "X-GeoLibre-Content-Range" not in headers
|
||||
|
||||
|
||||
def test_query_range_exposes_its_header_cross_origin(served):
|
||||
"""The custom header is unreadable from fetch() unless it is exposed."""
|
||||
_status, headers, _body = _get(f"{served[0]}?__geolibre_range=bytes%3D0-9")
|
||||
assert headers["Access-Control-Expose-Headers"] == "X-GeoLibre-Content-Range"
|
||||
|
||||
|
||||
def test_unsatisfiable_range_returns_416(served):
|
||||
url, payload = served
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
|
||||
@@ -136,8 +136,8 @@ durable.
|
||||
`add_raster` / `add_cog` accept a **local** GeoTIFF path on the kernel host: the
|
||||
bundled localhost server serves it so the app can read it. This works directly
|
||||
in local Jupyter and VS Code. Colab uses kernel-rendered PNG XYZ tiles instead
|
||||
of browser COG reads. JupyterHub works through the kernel port proxy when
|
||||
JupyterHub when `jupyter-server-proxy` is available. A deployment that can only
|
||||
of browser COG reads. JupyterHub can route the COG through the kernel port
|
||||
when `jupyter-server-proxy` is available. A deployment that can only
|
||||
serve the static app extension cannot expose kernel files, so use a hosted URL.
|
||||
The served URL is session-scoped, so a saved project or exported HTML will not
|
||||
restore a local raster later. **For anything durable or shareable, use a hosted
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
|
||||
import { rewriteProxiedLocalFileUrls } from "../python/src/geolibre/_frontend.js";
|
||||
import {
|
||||
KERNEL_CONTENT_RANGE_HEADER,
|
||||
KERNEL_RANGE_PROXY_MARKER,
|
||||
KERNEL_RANGE_QUERY,
|
||||
} from "../apps/geolibre-desktop/src/lib/kernel-proxy-range";
|
||||
import {
|
||||
canProxyLocalFiles,
|
||||
rewriteProxiedLocalFileUrls,
|
||||
} from "../python/src/geolibre/_frontend.js";
|
||||
|
||||
const readSource = (relative: string) =>
|
||||
readFileSync(fileURLToPath(new URL(`../${relative}`, import.meta.url)), "utf8");
|
||||
|
||||
describe("rewriteProxiedLocalFileUrls", () => {
|
||||
it("routes registered raster files through the Colab proxy without mutating the project", () => {
|
||||
@@ -110,4 +123,96 @@ describe("rewriteProxiedLocalFileUrls", () => {
|
||||
project,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a look-alike Colab host on a foreign port alone", () => {
|
||||
const project = {
|
||||
layers: [
|
||||
{
|
||||
source: {
|
||||
url:
|
||||
"https://old-view-41123-colab.googleusercontent.com:9999/" +
|
||||
"_geolibre_local/token/a.tif",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
rewriteProxiedLocalFileUrls(
|
||||
project,
|
||||
"https://session-41123-colab.googleusercontent.com/",
|
||||
41123,
|
||||
),
|
||||
project,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canProxyLocalFiles", () => {
|
||||
const setWindow = (extras: Record<string, unknown> = {}) => {
|
||||
(globalThis as Record<string, unknown>).window = {
|
||||
location: { href: "https://hub.example/user/alice/lab" },
|
||||
...extras,
|
||||
};
|
||||
(globalThis as Record<string, unknown>).document = {
|
||||
getElementById: () => ({ textContent: JSON.stringify({ baseUrl: "/user/alice/" }) }),
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
delete (globalThis as Record<string, unknown>).document;
|
||||
});
|
||||
|
||||
it("rewrites when the app is itself served by jupyter-server-proxy", () => {
|
||||
setWindow();
|
||||
assert.equal(canProxyLocalFiles("https://hub.example/user/alice/proxy/41123/", 41123), true);
|
||||
});
|
||||
|
||||
it("leaves loopback URLs alone under the Jupyter Server extension", () => {
|
||||
setWindow();
|
||||
assert.equal(canProxyLocalFiles("https://hub.example/user/alice/geolibre/app/", 41123), false);
|
||||
// The proxy route for another port is not this widget's transport either.
|
||||
assert.equal(canProxyLocalFiles("https://hub.example/user/alice/proxy/9999/", 41123), false);
|
||||
});
|
||||
|
||||
it("rewrites on any base once a Colab kernel is present", () => {
|
||||
setWindow({ google: { colab: { kernel: {} } } });
|
||||
assert.equal(
|
||||
canProxyLocalFiles("https://session-41123-colab.googleusercontent.com/", 41123),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("declines before the app port is known", () => {
|
||||
setWindow({ google: { colab: { kernel: {} } } });
|
||||
assert.equal(
|
||||
canProxyLocalFiles("https://session-41123-colab.googleusercontent.com/", 0),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Colab range-bridge wire contract", () => {
|
||||
it("is spelled the same way on the Python side", () => {
|
||||
const frontend = readSource("python/src/geolibre/_frontend.js");
|
||||
const server = readSource("python/src/geolibre/_server.py");
|
||||
|
||||
assert.ok(
|
||||
frontend.includes(`searchParams.set("${KERNEL_RANGE_PROXY_MARKER}", "1")`),
|
||||
"the widget must mark proxied local-file URLs with KERNEL_RANGE_PROXY_MARKER",
|
||||
);
|
||||
assert.ok(
|
||||
server.includes(`.get("${KERNEL_RANGE_QUERY}", [None])`),
|
||||
"the local-file route must read the range from KERNEL_RANGE_QUERY",
|
||||
);
|
||||
assert.ok(
|
||||
server.includes(`self.send_header("${KERNEL_CONTENT_RANGE_HEADER}"`),
|
||||
"the local-file route must report its span in KERNEL_CONTENT_RANGE_HEADER",
|
||||
);
|
||||
assert.ok(
|
||||
server.includes(`"Access-Control-Expose-Headers", "${KERNEL_CONTENT_RANGE_HEADER}"`),
|
||||
"the custom range header must be exposed to cross-origin fetches",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user