Skip to content

Commit ed0d1e4

Browse files
committed
feat: add try_import helper and adopt it for optional deps
1 parent 59695a6 commit ed0d1e4

9 files changed

Lines changed: 83 additions & 75 deletions

File tree

lumen/sources/duckdb.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@
1919
from ..transforms.sql import (
2020
SQLCount, SQLFilter, SQLLimit, SQLSelectFrom,
2121
)
22-
from ..util import (
23-
detect_file_encoding, normalize_table_name, try_import_geopandas,
24-
)
22+
from ..util import detect_file_encoding, normalize_table_name, try_import
2523
from .base import BaseSQLSource, Source, cached
2624

2725
if TYPE_CHECKING:
@@ -530,7 +528,7 @@ def _fetch_df(
530528
wrapped = f'SELECT {selected} FROM ({sql_expr})'
531529
rel = cursor.execute(wrapped, params) if params else cursor.execute(wrapped)
532530
df = rel.fetch_df(date_as_object=date_as_object)
533-
if gpd := try_import_geopandas():
531+
if gpd := try_import("geopandas"):
534532
for col in geom_cols:
535533
df[col] = gpd.GeoSeries.from_wkb(
536534
df[col].apply(bytes), crs=self.geometry_crs

lumen/tests/ai/test_controls/test_upload.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,7 @@
99
from lumen.ai.controls import SourceResult, UploadedFileRow
1010
from lumen.ai.controls.ingest.utils import read_geo_file
1111
from lumen.sources.duckdb import DuckDBSource
12-
13-
try:
14-
import geopandas as gpd
15-
16-
from shapely.geometry import Polygon
17-
except ImportError:
18-
gpd = None
12+
from lumen.tests.utils import Polygon, gpd, requires_geopandas
1913

2014

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

375369

370+
@requires_geopandas
376371
def test_read_geo_file_captures_crs():
377372
"""read_geo_file surfaces the source CRS in source_params so DuckDBSource
378373
can reapply it after the WKB roundtrip (gh-1904)."""
379-
if gpd is None:
380-
pytest.skip("geopandas is not installed")
381374
gdf = gpd.GeoDataFrame(
382375
{"name": ["a"]}, geometry=[Polygon([(0, 0), (1, 0), (1, 1)])], crs="EPSG:4326"
383376
)

lumen/tests/sources/test_duckdb.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pandas as pd
99
import pytest
1010

11+
from lumen.tests.utils import Polygon, gpd
1112
from lumen.transforms.sql import SQLGroupBy
1213

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

21-
try:
22-
import geopandas as gpd
23-
24-
from shapely.geometry import Polygon
25-
except ImportError:
26-
gpd = None
27-
2822

2923
@pytest.fixture
3024
def duckdb_file_source():

lumen/tests/sources/test_optional_imports.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77

88
import builtins
99
import importlib
10+
import json
1011
import sys
1112

1213
from pathlib import Path
1314
from unittest.mock import patch
1415

1516
import pytest
1617

17-
from lumen.util import try_import_geopandas, try_import_xarray
18+
from lumen.util import try_import, try_import_xarray
1819

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

9091

92+
def test_try_import_returns_module_when_installed():
93+
"""try_import returns the module object for an importable module."""
94+
assert try_import("json") is json
95+
96+
97+
def test_try_import_returns_none_when_missing():
98+
"""try_import returns None (not raises) for a module that cannot be imported."""
99+
assert try_import("a_module_that_does_not_exist_xyz") is None
100+
101+
102+
def test_try_import_load_false_returns_already_imported_module():
103+
"""try_import(load=False) returns a module that is already imported."""
104+
assert try_import("json", load=False) is json
105+
106+
107+
def test_try_import_load_false_never_imports():
108+
"""try_import(load=False) must not trigger an import, only consult sys.modules."""
109+
with patch("lumen.util.importlib.import_module", side_effect=AssertionError("imported")):
110+
assert try_import("a_module_that_does_not_exist_xyz", load=False) is None
111+
112+
91113
def test_try_import_geopandas_returns_module_when_installed():
92-
"""try_import_geopandas returns the geopandas module when importable."""
114+
"""try_import returns the geopandas module when it is importable."""
93115
gpd = pytest.importorskip("geopandas")
94-
assert try_import_geopandas() is gpd
116+
assert try_import("geopandas") is gpd
95117

96118

97119
def test_try_import_geopandas_none_when_missing():
98-
"""try_import_geopandas returns None (not raises) when geopandas is absent."""
120+
"""try_import returns None (not raises) when geopandas is absent."""
99121
real_import = builtins.__import__
100122

101123
def mock_import(name, *args, **kwargs):
@@ -109,7 +131,7 @@ def mock_import(name, *args, **kwargs):
109131
}
110132
try:
111133
with patch("builtins.__import__", side_effect=mock_import):
112-
assert try_import_geopandas() is None
134+
assert try_import("geopandas") is None
113135
finally:
114136
sys.modules.update(saved)
115137

lumen/tests/test_pipeline.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,13 @@
1515
except Exception:
1616
duckdb = None
1717

18-
try:
19-
import geopandas as gpd
20-
21-
from shapely.geometry import Polygon
22-
except Exception:
23-
gpd = None
24-
2518
from panel.widgets import Select
2619

2720
from lumen.filters.base import ConstantFilter
2821
from lumen.pipeline import Pipeline
2922
from lumen.sources.intake_sql import IntakeSQLSource
3023
from lumen.state import state
24+
from lumen.tests.utils import Polygon, gpd, requires_geopandas
3125
from lumen.transforms.base import Columns, Transform
3226
from lumen.transforms.sql import SQLColumns, SQLGroupBy
3327
from lumen.validation import ValidationError
@@ -400,10 +394,11 @@ def apply(self, table):
400394
assert not pipeline._update_widget.loading
401395

402396

397+
@requires_geopandas
403398
def test_pipeline_preserves_geodataframe():
404399
"""A GeoDataFrame survives source -> filter -> Pipeline.data without downcast."""
405-
if gpd is None or duckdb is None:
406-
pytest.skip("geopandas or duckdb is not installed")
400+
if duckdb is None:
401+
pytest.skip("duckdb is not installed")
407402
try:
408403
source = DuckDBSource(
409404
uri=':memory:',

lumen/tests/test_schema.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,13 @@
44

55
import pandas as pd
66
import panel as pn
7-
import pytest
87

98
from panel_material_ui import Checkbox, FloatSlider, Select
109

1110
from lumen.schema import JSONSchema
11+
from lumen.tests.utils import Polygon, gpd, requires_geopandas
1212
from lumen.util import get_dataframe_schema
1313

14-
try:
15-
import geopandas as gpd
16-
17-
from shapely.geometry import Polygon
18-
except ImportError:
19-
gpd = None
20-
2114

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

4336

37+
@requires_geopandas
4438
def test_get_dataframe_schema_geometry():
4539
"""A geometry column is emitted as a compact, JSON-serializable marker."""
46-
if gpd is None:
47-
pytest.skip("geopandas is not installed")
4840
gdf = gpd.GeoDataFrame(
4941
{
5042
"name": ["a", "b"],
@@ -70,10 +62,9 @@ def test_get_dataframe_schema_geometry():
7062
json.dumps(schema)
7163

7264

65+
@requires_geopandas
7366
def test_get_dataframe_schema_geometry_empty():
7467
"""An empty GeoDataFrame yields geometry_type 'unknown' without raising."""
75-
if gpd is None:
76-
pytest.skip("geopandas is not installed")
7768
gdf = gpd.GeoDataFrame({"name": [], "geometry": []})
7869
schema = get_dataframe_schema(gdf)
7970
assert schema["items"]["properties"]["geometry"]["format"] == "geometry"

lumen/tests/utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Shared test helpers for optional geospatial dependencies.
2+
3+
Importing geopandas at module top would break collection where it is not
4+
installed, so guard it once here and let tests import ``gpd``/``Polygon`` and
5+
skip via ``requires_geopandas`` instead of repeating the guard per file.
6+
"""
7+
import pytest
8+
9+
try:
10+
import geopandas as gpd
11+
12+
from shapely.geometry import Polygon
13+
except ImportError:
14+
gpd = None
15+
Polygon = None
16+
17+
# geopandas depends on shapely, so a single geopandas check covers both.
18+
requires_geopandas = pytest.mark.skipif(
19+
gpd is None, reason="geopandas is not installed"
20+
)

lumen/tests/views/test_base.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,14 @@
1010
from lumen.panel import DownloadButton
1111
from lumen.pipeline import Pipeline
1212
from lumen.sources.base import FileSource
13+
from lumen.sources.duckdb import DuckDBSource
1314
from lumen.state import state
15+
from lumen.tests.utils import Polygon, gpd, requires_geopandas
1416
from lumen.variables.base import Variables
1517
from lumen.views.base import (
1618
DeckGLView, Panel, Table, VegaLiteView, View, hvOverlayView, hvPlotView,
1719
)
1820

19-
try:
20-
import geopandas as gpd
21-
22-
from shapely.geometry import Polygon
23-
24-
from lumen.sources.duckdb import DuckDBSource
25-
_GEO_DEPS = True
26-
except ImportError:
27-
_GEO_DEPS = False
28-
29-
requires_geo = pytest.mark.skipif(
30-
not _GEO_DEPS, reason="geopandas, shapely or duckdb not installed"
31-
)
32-
3321
# geoviews is checked without importing it: importing geoviews (-> matplotlib)
3422
# at collection under xdist can deadlock the font cache on macOS. hvplot imports
3523
# it lazily when the geometry plot is actually rendered.
@@ -390,7 +378,7 @@ def test_vega_datasets(set_root):
390378
pd.testing.assert_frame_equal(final_spec["datasets"]["test"], pipeline.data)
391379

392380

393-
@requires_geo
381+
@requires_geopandas
394382
@requires_geoviews
395383
def test_view_hvplot_geometry_auto_kind():
396384
"""A GeoDataFrame view renders its geometry with an auto-selected kind."""
@@ -427,7 +415,7 @@ def test_view_hvplot_geometry_auto_kind():
427415
assert type(plot).__name__ == 'Polygons'
428416

429417

430-
@requires_geo
418+
@requires_geopandas
431419
def test_table_view_geometry_rendered_as_wkt():
432420
"""Table view converts geometry columns to WKT so Bokeh can serialize them."""
433421
try:
@@ -458,7 +446,7 @@ def test_table_view_geometry_rendered_as_wkt():
458446
view.get_panel()
459447

460448

461-
@requires_geo
449+
@requires_geopandas
462450
def test_deckgl_view_geometry_as_geojson():
463451
"""DeckGLView emits a GeoDataFrame as a GeoJSON FeatureCollection for layers."""
464452
try:

lumen/util.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -510,21 +510,28 @@ def normalize_table_name(name: str) -> str:
510510
return re.sub(r'\W+', '_', name).strip('_').lower()
511511

512512

513-
def try_import_xarray():
514-
"""Import and return xarray, or None if xarray or xarray-sql is unavailable."""
513+
def try_import(module_name, load=True):
514+
"""Import and return a module, or None if it is unavailable.
515+
516+
With load=False the module is returned only if it has already been
517+
imported, a cheap check that never triggers an import; this lets a hot path
518+
ask whether an optional dependency is in play without paying to import it.
519+
With load=True (the default) the module is imported on demand.
520+
"""
521+
if not load:
522+
return sys.modules.get(module_name)
515523
try:
516-
import xarray
517-
import xarray_sql # noqa
518-
return xarray
524+
return importlib.import_module(module_name)
519525
except ImportError:
520526
return None
521527

522528

523-
def try_import_geopandas():
524-
"""Import and return geopandas, or None if it is not installed."""
529+
def try_import_xarray():
530+
"""Import and return xarray, or None if xarray or xarray-sql is unavailable."""
525531
try:
526-
import geopandas
527-
return geopandas
532+
import xarray
533+
import xarray_sql # noqa
534+
return xarray
528535
except ImportError:
529536
return None
530537

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

542549

@@ -546,7 +553,7 @@ def geometry_columns(df):
546553
Empty when geopandas is not imported (no geometry column can exist without
547554
it) or df has no geometry columns; never speculatively imports geopandas.
548555
"""
549-
gpd = sys.modules.get("geopandas")
556+
gpd = try_import("geopandas", load=False)
550557
if gpd is None:
551558
return []
552559
return [c for c in df.columns if isinstance(df[c].dtype, gpd.array.GeometryDtype)]
@@ -563,7 +570,7 @@ def geometry_to_wkt(df):
563570
geom_cols = geometry_columns(df)
564571
if not geom_cols:
565572
return df
566-
gpd = sys.modules["geopandas"]
573+
gpd = try_import("geopandas", load=False)
567574
df = pd.DataFrame(df).copy()
568575
for col in geom_cols:
569576
df[col] = gpd.GeoSeries(df[col]).to_wkt()

0 commit comments

Comments
 (0)