Skip to content
Open
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
6 changes: 2 additions & 4 deletions lumen/sources/duckdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@
from ..transforms.sql import (
SQLCount, SQLFilter, SQLLimit, SQLSelectFrom,
)
from ..util import (
detect_file_encoding, normalize_table_name, try_import_geopandas,
)
from ..util import detect_file_encoding, normalize_table_name, try_import
from .base import BaseSQLSource, Source, cached

if TYPE_CHECKING:
Expand Down Expand Up @@ -530,7 +528,7 @@ def _fetch_df(
wrapped = f'SELECT {selected} FROM ({sql_expr})'
rel = cursor.execute(wrapped, params) if params else cursor.execute(wrapped)
df = rel.fetch_df(date_as_object=date_as_object)
if gpd := try_import_geopandas():
if gpd := try_import("geopandas"):
for col in geom_cols:
df[col] = gpd.GeoSeries.from_wkb(
df[col].apply(bytes), crs=self.geometry_crs
Expand Down
11 changes: 2 additions & 9 deletions lumen/tests/ai/test_controls/test_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,7 @@
from lumen.ai.controls import SourceResult, UploadedFileRow
from lumen.ai.controls.ingest.utils import read_geo_file
from lumen.sources.duckdb import DuckDBSource

try:
import geopandas as gpd

from shapely.geometry import Polygon
except ImportError:
gpd = None
from lumen.tests.utils import Polygon, gpd, requires_geopandas


@pytest.mark.asyncio
Expand Down Expand Up @@ -373,11 +367,10 @@ def test_upload_button_label_is_explicit(self, upload_controls):
assert upload_controls._add_button.name == "Upload file(s)"


@requires_geopandas
def test_read_geo_file_captures_crs():
"""read_geo_file surfaces the source CRS in source_params so DuckDBSource
can reapply it after the WKB roundtrip (gh-1904)."""
if gpd is None:
pytest.skip("geopandas is not installed")
gdf = gpd.GeoDataFrame(
{"name": ["a"]}, geometry=[Polygon([(0, 0), (1, 0), (1, 1)])], crs="EPSG:4326"
)
Expand Down
8 changes: 1 addition & 7 deletions lumen/tests/sources/test_duckdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pandas as pd
import pytest

from lumen.tests.utils import Polygon, gpd
from lumen.transforms.sql import SQLGroupBy

try:
Expand All @@ -18,13 +19,6 @@
except ImportError:
pytestmark = pytest.mark.skip(reason="Duckdb is not installed")

try:
import geopandas as gpd

from shapely.geometry import Polygon
except ImportError:
gpd = None


@pytest.fixture
def duckdb_file_source():
Expand Down
32 changes: 27 additions & 5 deletions lumen/tests/sources/test_optional_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@

import builtins
import importlib
import json
import sys

from pathlib import Path
from unittest.mock import patch

import pytest

from lumen.util import try_import_geopandas, try_import_xarray
from lumen.util import try_import, try_import_xarray

# Each entry: (module_path, class_name, guard_package, pip_extra)
OPTIONAL_SOURCES = [
Expand Down Expand Up @@ -88,14 +89,35 @@ def mock_import(name, *args, **kwargs):
sys.modules.update(saved)


def test_try_import_returns_module_when_installed():
"""try_import returns the module object for an importable module."""
assert try_import("json") is json


def test_try_import_returns_none_when_missing():
"""try_import returns None (not raises) for a module that cannot be imported."""
assert try_import("a_module_that_does_not_exist_xyz") is None


def test_try_import_load_false_returns_already_imported_module():
"""try_import(load=False) returns a module that is already imported."""
assert try_import("json", load=False) is json


def test_try_import_load_false_never_imports():
"""try_import(load=False) must not trigger an import, only consult sys.modules."""
with patch("lumen.util.importlib.import_module", side_effect=AssertionError("imported")):
assert try_import("a_module_that_does_not_exist_xyz", load=False) is None


def test_try_import_geopandas_returns_module_when_installed():
"""try_import_geopandas returns the geopandas module when importable."""
"""try_import returns the geopandas module when it is importable."""
gpd = pytest.importorskip("geopandas")
assert try_import_geopandas() is gpd
assert try_import("geopandas") is gpd


def test_try_import_geopandas_none_when_missing():
"""try_import_geopandas returns None (not raises) when geopandas is absent."""
"""try_import returns None (not raises) when geopandas is absent."""
real_import = builtins.__import__

def mock_import(name, *args, **kwargs):
Expand All @@ -109,7 +131,7 @@ def mock_import(name, *args, **kwargs):
}
try:
with patch("builtins.__import__", side_effect=mock_import):
assert try_import_geopandas() is None
assert try_import("geopandas") is None
finally:
sys.modules.update(saved)

Expand Down
13 changes: 4 additions & 9 deletions lumen/tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,13 @@
except Exception:
duckdb = None

try:
import geopandas as gpd

from shapely.geometry import Polygon
except Exception:
gpd = None

from panel.widgets import Select

from lumen.filters.base import ConstantFilter
from lumen.pipeline import Pipeline
from lumen.sources.intake_sql import IntakeSQLSource
from lumen.state import state
from lumen.tests.utils import Polygon, gpd, requires_geopandas
from lumen.transforms.base import Columns, Transform
from lumen.transforms.sql import SQLColumns, SQLGroupBy
from lumen.validation import ValidationError
Expand Down Expand Up @@ -400,10 +394,11 @@ def apply(self, table):
assert not pipeline._update_widget.loading


@requires_geopandas
def test_pipeline_preserves_geodataframe():
"""A GeoDataFrame survives source -> filter -> Pipeline.data without downcast."""
if gpd is None or duckdb is None:
pytest.skip("geopandas or duckdb is not installed")
if duckdb is None:
pytest.skip("duckdb is not installed")
try:
source = DuckDBSource(
uri=':memory:',
Expand Down
15 changes: 3 additions & 12 deletions lumen/tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,13 @@

import pandas as pd
import panel as pn
import pytest

from panel_material_ui import Checkbox, FloatSlider, Select

from lumen.schema import JSONSchema
from lumen.tests.utils import Polygon, gpd, requires_geopandas
from lumen.util import get_dataframe_schema

try:
import geopandas as gpd

from shapely.geometry import Polygon
except ImportError:
gpd = None


def test_boolean_schema():
json_schema = JSONSchema(schema={'bool': {'type': 'boolean'}}, multi=False)
Expand All @@ -41,10 +34,9 @@ def test_enum_schema():
assert widget.options == ['A', 'B', 'C']


@requires_geopandas
def test_get_dataframe_schema_geometry():
"""A geometry column is emitted as a compact, JSON-serializable marker."""
if gpd is None:
pytest.skip("geopandas is not installed")
gdf = gpd.GeoDataFrame(
{
"name": ["a", "b"],
Expand All @@ -70,10 +62,9 @@ def test_get_dataframe_schema_geometry():
json.dumps(schema)


@requires_geopandas
def test_get_dataframe_schema_geometry_empty():
"""An empty GeoDataFrame yields geometry_type 'unknown' without raising."""
if gpd is None:
pytest.skip("geopandas is not installed")
gdf = gpd.GeoDataFrame({"name": [], "geometry": []})
schema = get_dataframe_schema(gdf)
assert schema["items"]["properties"]["geometry"]["format"] == "geometry"
Expand Down
20 changes: 20 additions & 0 deletions lumen/tests/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Shared test helpers for optional geospatial dependencies.

Importing geopandas at module top would break collection where it is not
installed, so guard it once here and let tests import ``gpd``/``Polygon`` and
skip via ``requires_geopandas`` instead of repeating the guard per file.
"""
import pytest

try:
import geopandas as gpd

from shapely.geometry import Polygon
except ImportError:
gpd = None
Polygon = None

# geopandas depends on shapely, so a single geopandas check covers both.
requires_geopandas = pytest.mark.skipif(
gpd is None, reason="geopandas is not installed"
)
22 changes: 5 additions & 17 deletions lumen/tests/views/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,14 @@
from lumen.panel import DownloadButton
from lumen.pipeline import Pipeline
from lumen.sources.base import FileSource
from lumen.sources.duckdb import DuckDBSource
from lumen.state import state
from lumen.tests.utils import Polygon, gpd, requires_geopandas
from lumen.variables.base import Variables
from lumen.views.base import (
DeckGLView, Panel, Table, VegaLiteView, View, hvOverlayView, hvPlotView,
)

try:
import geopandas as gpd

from shapely.geometry import Polygon

from lumen.sources.duckdb import DuckDBSource
_GEO_DEPS = True
except ImportError:
_GEO_DEPS = False

requires_geo = pytest.mark.skipif(
not _GEO_DEPS, reason="geopandas, shapely or duckdb not installed"
)

# geoviews is checked without importing it: importing geoviews (-> matplotlib)
# at collection under xdist can deadlock the font cache on macOS. hvplot imports
# it lazily when the geometry plot is actually rendered.
Expand Down Expand Up @@ -390,7 +378,7 @@ def test_vega_datasets(set_root):
pd.testing.assert_frame_equal(final_spec["datasets"]["test"], pipeline.data)


@requires_geo
@requires_geopandas
@requires_geoviews
def test_view_hvplot_geometry_auto_kind():
"""A GeoDataFrame view renders its geometry with an auto-selected kind."""
Expand Down Expand Up @@ -427,7 +415,7 @@ def test_view_hvplot_geometry_auto_kind():
assert type(plot).__name__ == 'Polygons'


@requires_geo
@requires_geopandas
def test_table_view_geometry_rendered_as_wkt():
"""Table view converts geometry columns to WKT so Bokeh can serialize them."""
try:
Expand Down Expand Up @@ -458,7 +446,7 @@ def test_table_view_geometry_rendered_as_wkt():
view.get_panel()


@requires_geo
@requires_geopandas
def test_deckgl_view_geometry_as_geojson():
"""DeckGLView emits a GeoDataFrame as a GeoJSON FeatureCollection for layers."""
try:
Expand Down
31 changes: 19 additions & 12 deletions lumen/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,21 +510,28 @@ def normalize_table_name(name: str) -> str:
return re.sub(r'\W+', '_', name).strip('_').lower()


def try_import_xarray():
"""Import and return xarray, or None if xarray or xarray-sql is unavailable."""
def try_import(module_name, load=True):
"""Import and return a module, or None if it is unavailable.

With load=False the module is returned only if it has already been
imported, a cheap check that never triggers an import; this lets a hot path
ask whether an optional dependency is in play without paying to import it.
With load=True (the default) the module is imported on demand.
"""
if not load:
return sys.modules.get(module_name)
try:
import xarray
import xarray_sql # noqa
return xarray
return importlib.import_module(module_name)
except ImportError:
return None


def try_import_geopandas():
"""Import and return geopandas, or None if it is not installed."""
def try_import_xarray():
"""Import and return xarray, or None if xarray or xarray-sql is unavailable."""
try:
import geopandas
return geopandas
import xarray
import xarray_sql # noqa
return xarray
except ImportError:
return None

Expand All @@ -536,7 +543,7 @@ def is_geodataframe(df):
a GeoDataFrame unless geopandas is loaded, so this stays cheap on the hot
path and never speculatively imports geopandas.
"""
gpd = sys.modules.get("geopandas")
gpd = try_import("geopandas", load=False)
return gpd is not None and isinstance(df, gpd.GeoDataFrame)


Expand All @@ -546,7 +553,7 @@ def geometry_columns(df):
Empty when geopandas is not imported (no geometry column can exist without
it) or df has no geometry columns; never speculatively imports geopandas.
"""
gpd = sys.modules.get("geopandas")
gpd = try_import("geopandas", load=False)
if gpd is None:
return []
return [c for c in df.columns if isinstance(df[c].dtype, gpd.array.GeometryDtype)]
Expand All @@ -563,7 +570,7 @@ def geometry_to_wkt(df):
geom_cols = geometry_columns(df)
if not geom_cols:
return df
gpd = sys.modules["geopandas"]
gpd = try_import("geopandas", load=False)
df = pd.DataFrame(df).copy()
for col in geom_cols:
df[col] = gpd.GeoSeries(df[col]).to_wkt()
Expand Down
Loading