Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --extra streamlit
- name: Check formatting
run: uv run black --check prettymapp streamlit-prettymapp
- name: Run tests (offline)
run: uv run pytest prettymapp/tests --pylint --pylint-rcfile=pylintrc --mypy --mypy-ignore-missing-imports
- name: Build package
run: uv build
10 changes: 7 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*

COPY streamlit-prettymapp /app/streamlit-prettymapp/

WORKDIR /app

RUN pip3 install -r streamlit-prettymapp/requirements.txt
# Install the prettymapp package from the local source (not PyPI) so the app
# and the library can never drift apart, plus the webapp-only dependencies.
COPY pyproject.toml README.md ./
COPY prettymapp ./prettymapp/
COPY streamlit-prettymapp ./streamlit-prettymapp/

RUN pip3 install --no-cache-dir . streamlit==1.52.2 streamlit-image-select==0.6.0 pyogrio

EXPOSE 8501

Expand Down
27 changes: 11 additions & 16 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
test:
-rm -r .pytest_cache
black .
python -m pytest --pylint --pylint-rcfile=../../pylintrc --mypy --mypy-ignore-missing-imports --durations=3
uv run black .
uv run pytest prettymapp/tests --pylint --pylint-rcfile=pylintrc --mypy --mypy-ignore-missing-imports --durations=3

test[live]:
-rm -r .pytest_cache
black .
python -m pytest --pylint --pylint-rcfile=../../pylintrc --mypy --mypy-ignore-missing-imports --runlive --durations=5
uv run black .
uv run pytest prettymapp/tests --pylint --pylint-rcfile=pylintrc --mypy --mypy-ignore-missing-imports --runlive --durations=5

setup:
pip install -r requirements.txt
pip install -r streamlit-prettymapp/requirements.txt
uv sync --extra streamlit

setup-dev:
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install -e .
pip install streamlit
app:
uv run streamlit run streamlit-prettymapp/app.py

package:
python setup.py sdist bdist_wheel
twine check dist/*
rm -rf dist
uv build
uvx twine check dist/*

upload:
twine upload --skip-existing dist/*
uvx twine upload --skip-existing dist/*

clean:
find . -name "__pycache__" -exec rm -rf {} +
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,11 @@ default settings or create your own dictionary! See [settings.py](prettymapp/set
from prettymapp.settings import LANDCOVER_CLASSES

custom_lc_classes = LANDCOVER_CLASSES.copy()
custom_lc_classes["urban"]["building"] = False # drops all building subclasses
custom_lc_classes["grassland"]["leisure"] = True # Include all leisure subclasses
custom_lc_classes["grassland"]["natural"] = ["island"] # Selects only specific natural subclasses
custom_lc_classes["urban"] = {"building": False} # drops all building subclasses
custom_lc_classes["grassland"] = {
"leisure": True, # Include all leisure subclasses
"natural": ["island"], # Selects only specific natural subclasses
}

df = get_osm_geometries(aoi=aoi, landcover_classes=custom_lc_classes)
```
4 changes: 4 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import matplotlib
import pytest

# Non-interactive backend so plotting tests run headless (e.g. in CI)
matplotlib.use("Agg")


def pytest_addoption(parser):
parser.addoption(
Expand Down
10 changes: 6 additions & 4 deletions prettymapp/example_notebook.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
" df=df,\n",
" aoi_bounds=aoi.bounds,\n",
" draw_settings=STYLES[\"Peach\"],\n",
").plot_all()\n"
").plot_all()"
]
},
{
Expand Down Expand Up @@ -101,9 +101,11 @@
"# 1. Overwrite specific classes\n",
"from prettymapp.settings import LANDCOVER_CLASSES\n",
"custom_lc_classes = LANDCOVER_CLASSES.copy()\n",
"custom_lc_classes[\"urban\"][\"building\"] = False # drops all building subclasses\n",
"custom_lc_classes[\"grassland\"][\"leisure\"] = True # Include all leisure subclasses\n",
"custom_lc_classes[\"grassland\"][\"natural\"] = [\"island\"] # Selects only specific natural subclasses\n",
"custom_lc_classes[\"urban\"] = {\"building\": False} # drops all building subclasses\n",
"custom_lc_classes[\"grassland\"] = {\n",
" \"leisure\": True, # Include all leisure subclasses\n",
" \"natural\": [\"island\"], # Selects only specific natural subclasses\n",
"}\n",
"\n",
"# 2. Or completely custom\n",
"# custom_lc_classes ={\n",
Expand Down
26 changes: 6 additions & 20 deletions prettymapp/geo.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from osmnx.geocoder import geocode
import osmnx as ox
from geopandas import GeoDataFrame
import pandas as pd
from pandas import DataFrame
from shapely.geometry import Polygon, Point, box

Expand Down Expand Up @@ -44,7 +43,7 @@ def get_aoi(
if not address.strip():
raise GeoCodingError("No address provided, please enter a location.")
try:
lat, lon = geocode(address)
lat, lon = ox.geocode(address)
except ValueError as e:
raise GeoCodingError(f"Could not geocode address '{address}'") from e
except Exception as e: # pylint: disable=broad-except
Expand Down Expand Up @@ -76,23 +75,10 @@ def get_aoi(

def explode_multigeometries(df: GeoDataFrame) -> GeoDataFrame:
"""
Explode all multi geometries in a geodataframe into individual polygon geometries.
Adds exploded polygons as rows at the end of the geodataframe and resets its index.
Explode all multi-part geometries in a geodataframe into individual
single-part geometries, one row each, and reset the index.

Args:
df: Input GeoDataFrame
"""
mask = df.geom_type.isin(["MultiPolygon", "MultiLineString", "MultiPoint"])
outdf = df[~mask]
df_multi = df[mask]
for _, row in df_multi.iterrows():
df_temp = GeoDataFrame(
pd.DataFrame.from_records([row.to_dict()] * len(row.geometry.geoms)),
crs="EPSG:4326",
)
df_temp.geometry = list(row.geometry.geoms)
outdf = GeoDataFrame(
pd.concat([outdf, df_temp], ignore_index=True), crs="EPSG:4326"
)

outdf = outdf.reset_index(drop=True)
return outdf
return df.explode(index_parts=False).reset_index(drop=True)
46 changes: 35 additions & 11 deletions prettymapp/osm.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
from pathlib import Path

from osmnx.features import features_from_polygon, features_from_xml
from osmnx import settings
import osmnx as ox
from geopandas import clip, GeoDataFrame
from shapely.geometry import Polygon

from prettymapp.geo import explode_multigeometries
from prettymapp.settings import LANDCOVER_CLASSES

settings.use_cache = True
settings.log_console = False
try:
from osmnx._errors import InsufficientResponseError
except ImportError: # pragma: no cover - osmnx internals moved, fall back gracefully

class InsufficientResponseError(Exception): # type: ignore[no-redef]
"""Placeholder that never matches if osmnx renamed its exception."""


ox.settings.use_cache = True
ox.settings.log_console = False


class OsmDataError(Exception):
"""Raised when no usable OSM features are available for the query."""


def get_osm_tags(landcover_classes: dict = LANDCOVER_CLASSES):
def get_osm_tags(landcover_classes: dict | None = None) -> dict:
"""
Get relevant OSM tags for use with prettymapp

Expand All @@ -28,6 +35,8 @@ def get_osm_tags(landcover_classes: dict = LANDCOVER_CLASSES):
"water": {"natural": ["water", "bay"]},
}
"""
if landcover_classes is None:
landcover_classes = LANDCOVER_CLASSES
tags: dict = {}
for sub_classes_dict in landcover_classes.values():
for sub_class_name, subsub_classes in sub_classes_dict.items():
Expand All @@ -42,7 +51,7 @@ def get_osm_tags(landcover_classes: dict = LANDCOVER_CLASSES):
def cleanup_osm_df(
df: GeoDataFrame,
aoi: Polygon | None = None,
landcover_classes: dict = LANDCOVER_CLASSES,
landcover_classes: dict | None = None,
) -> GeoDataFrame:
"""
Cleanup of queried osm geometries to relevant level for use with prettymapp
Expand All @@ -55,6 +64,8 @@ def cleanup_osm_df(
Raises:
OsmDataError: If no usable OSM features remain after cleanup.
"""
if landcover_classes is None:
landcover_classes = LANDCOVER_CLASSES
if df.empty:
raise OsmDataError("No OSM features found for this area.")
# osmnx returns a (element_type, osmid) MultiIndex; drop the element_type level.
Expand All @@ -79,7 +90,8 @@ def cleanup_osm_df(
mask_from_different_subtag = ~df[tag].isin(subtags) & df[tag].notna()
mask_lc_class[mask_from_different_subtag] = False
df.loc[mask_lc_class, "landcover_class"] = lc_class
# Drop not assigned elements (part of multiple classes)
# Drop elements not assigned to any class. Elements matching multiple
# classes keep the last matching class (landcover_classes order).
df = df[~df["landcover_class"].isnull()]
df = df.drop(
df.columns.difference(["geometry", "landcover_class", "highway"]), axis=1
Expand All @@ -95,25 +107,31 @@ def cleanup_osm_df(


def get_osm_geometries(
aoi: Polygon, landcover_classes: dict = LANDCOVER_CLASSES
aoi: Polygon, landcover_classes: dict | None = None
) -> GeoDataFrame:
"""
Query OSM features within a polygon geometry.

Args:
aoi: Polygon geometry query boundary.
landcover_classes: Landcover selection settings, defaults to prettymapp.settings.LANDCOVER_CLASSES

Raises:
OsmDataError: If no usable OSM features are found for the query.
"""
tags = get_osm_tags(landcover_classes=landcover_classes)
df = features_from_polygon(polygon=aoi, tags=tags)
try:
df = ox.features_from_polygon(polygon=aoi, tags=tags)
except InsufficientResponseError as e:
raise OsmDataError("No OSM features found for this area.") from e
df = cleanup_osm_df(df, aoi, landcover_classes=landcover_classes)
return df


def get_osm_geometries_from_xml(
filepath: str | Path,
aoi: Polygon | None = None,
landcover_classes: dict = LANDCOVER_CLASSES,
landcover_classes: dict | None = None,
) -> GeoDataFrame:
"""
Query OSM features in an OSM-formatted XML file.
Expand All @@ -122,8 +140,14 @@ def get_osm_geometries_from_xml(
filepath: path to file containing OSM XML data
aoi: Optional geographic boundary to filter elements
landcover_classes: Landcover selection settings, defaults to prettymapp.settings.LANDCOVER_CLASSES

Raises:
OsmDataError: If no usable OSM features are found in the file.
"""
tags = get_osm_tags(landcover_classes=landcover_classes)
df = features_from_xml(filepath, polygon=aoi, tags=tags)
try:
df = ox.features_from_xml(filepath, polygon=aoi, tags=tags)
except InsufficientResponseError as e:
raise OsmDataError("No OSM features found in the provided file.") from e
df = cleanup_osm_df(df, aoi, landcover_classes=landcover_classes)
return df
Loading
Loading