Skip to content

Commit

Permalink
Add CLI (#379)
Browse files Browse the repository at this point in the history
Depends on #372

Needs docs; hasn't been tested yet
  • Loading branch information
kylebarron authored Feb 26, 2024
1 parent f83268f commit 8c8a213
Show file tree
Hide file tree
Showing 8 changed files with 670 additions and 448 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# lonboard
# Lonboard

[![PyPI][pypi_badge]][pypi_link]
[![Binder][binder_badge]][binder_jupyterlab_url]
Expand Down
29 changes: 29 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Command-line Interface

Lonboard includes a command-line interface for quickly viewing local data files.

The CLI is accessible either through the `lonboard` entry point or via `python -m lonboard`.

```
> lonboard --help
Usage: lonboard [OPTIONS] [FILES]...
Interactively visualize geospatial data using Lonboard.
This CLI can be used either to quickly view local files or to create static
HTML files.
Options:
-o, --output PATH The output path for the generated HTML file. If not
provided, will save to a temporary file.
--open / --no-open Whether to open a web browser tab with the generated
map. By default, the web browser is not opened when
--output is provided, but is in other cases.
--help Show this message and exit.
```

For example:

```
lonboard data.geojson
```
3 changes: 3 additions & 0 deletions lonboard/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from lonboard._cli import main

main()
144 changes: 144 additions & 0 deletions lonboard/_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import json
import webbrowser
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Dict, List, Optional

import click
import pyarrow as pa
import pyarrow.parquet as pq
from pyproj import CRS

from lonboard import viz

# path = Path("/Users/kyle/github/developmentseed/lonboard/DC_Wetlands.parquet")
# path = Path("/Users/kyle/Downloads/UScounties.geojson")


def read_pyogrio(path: Path) -> pa.Table:
"""Read path using pyogrio and convert field metadata to geoarrow
Args:
path: Path to file readable by pyogrio
"""
try:
from pyogrio.raw import read_arrow
except ImportError as e:
raise ImportError(
"pyogrio is a required dependency for the CLI. "
"Install with `pip install pyogrio`."
) from e

meta, table = read_arrow(path)
# TODO: assert there are not two column names of wkb_geometry

# Rename wkb_geometry to geometry
geometry_column_index = [
i for (i, name) in enumerate(table.column_names) if name == "wkb_geometry"
][0]

schema = table.schema
field = schema.field(geometry_column_index)

metadata: Dict[bytes, bytes] = field.metadata
if metadata.get(b"ARROW:extension:name") == b"ogc.wkb":
# Parse CRS and create PROJJSON
ext_meta = {"crs": CRS.from_user_input(meta["crs"]).to_json_dict()}

# Replace metadata
metadata[b"ARROW:extension:name"] = b"geoarrow.wkb"
metadata[b"ARROW:extension:metadata"] = json.dumps(ext_meta).encode()

new_field = field.with_name("geometry").with_metadata(metadata)
new_schema = schema.set(geometry_column_index, new_field)
return pa.Table.from_arrays(table.columns, schema=new_schema)


def read_geoparquet(path: Path):
"""Read GeoParquet file at path using pyarrow
Args:
path: Path to GeoParquet file
"""
file = pq.ParquetFile(path)
geo_meta = file.metadata.metadata.get(b"geo")
if not geo_meta:
raise ValueError("Expected geo metadata in Parquet file")

table = file.read()

geo_meta = json.loads(geo_meta)
geometry_column_name = geo_meta["primary_column"]
geometry_column_index = [
i for (i, name) in enumerate(table.schema.names) if name == geometry_column_name
][0]

crs_dict = geo_meta["columns"][geometry_column_name]["crs"]

# Parse CRS and create PROJJSON
ext_meta = {"crs": crs_dict}

metadata = {
b"ARROW:extension:name": b"geoarrow.wkb",
b"ARROW:extension:metadata": json.dumps(ext_meta).encode(),
}

new_field = table.schema.field(geometry_column_index).with_metadata(metadata)
new_schema = table.schema.set(geometry_column_index, new_field)
return pa.Table.from_arrays(table.columns, schema=new_schema)


@click.command()
@click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
help=(
"The output path for the generated HTML file. "
"If not provided, will save to a temporary file."
),
)
@click.option(
"--open/--no-open",
"open_browser",
default=None,
help=(
"Whether to open a web browser tab with the generated map. "
"By default, the web browser is not opened when --output is provided, "
"but is in other cases."
),
)
@click.argument("files", nargs=-1, type=click.Path(path_type=Path))
def main(output: Optional[Path], open_browser: Optional[bool], files: List[Path]):
"""Interactively visualize geospatial data using Lonboard.
This CLI can be used either to quickly view local files or to create static HTML
files.
"""

tables = []
for path in files:
if path.suffix == ".parquet":
table = read_geoparquet(path)
else:
table = read_pyogrio(path)

tables.append(table)

map_ = viz(tables)

# If -o flag passed, write to file; otherwise write to temporary file
if output:
map_.to_html(output)

# Default to not opening browser when None
if open_browser is True:
webbrowser.open_new_tab(f"file://{output.absolute()}")
else:
with NamedTemporaryFile("wt", suffix=".html", delete=False) as f:
map_.to_html(f)

# Default to opening browser when None
if open_browser is not False:
webbrowser.open_new_tab(f"file://{f.name}")
4 changes: 2 additions & 2 deletions lonboard/_map.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Sequence, Union
from typing import IO, Sequence, TextIO, Union

import ipywidgets
import traitlets
Expand Down Expand Up @@ -156,7 +156,7 @@ def __init__(self, layers: Union[BaseLayer, Sequence[BaseLayer]], **kwargs) -> N
[`lonboard.basemap.CartoBasemap.PositronNoLabels`][lonboard.basemap.CartoBasemap.PositronNoLabels]
"""

def to_html(self, filename: Union[str, Path]) -> None:
def to_html(self, filename: Union[str, Path, TextIO, IO[str]]) -> None:
"""Save the current map as a standalone HTML file.
Args:
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ nav:
- Layers:
- api/layers/arc-layer.md
- api/layers/text-layer.md
- cli.md
- Ecosystem:
- ecosystem/index.md
- ecosystem/geoarrow.md
Expand Down
Loading

0 comments on commit 8c8a213

Please sign in to comment.