Skip to content

Commit ffc9725

Browse files
committed
Fix for issue 62001
1 parent 36b8f20 commit ffc9725

File tree

10 files changed

+204
-22
lines changed

10 files changed

+204
-22
lines changed

doc/source/whatsnew/v3.0.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Other enhancements
4848
- Allow dictionaries to be passed to :meth:`pandas.Series.str.replace` via ``pat`` parameter (:issue:`51748`)
4949
- Support passing a :class:`Series` input to :func:`json_normalize` that retains the :class:`Series` :class:`Index` (:issue:`51452`)
5050
- Support reading value labels from Stata 108-format (Stata 6) and earlier files (:issue:`58154`)
51+
- Enhanced :func:`import_optional_dependency` with context-aware error messages that suggest relevant alternatives when dependencies are missing (:issue:`62001`)
5152
- Users can globally disable any ``PerformanceWarning`` by setting the option ``mode.performance_warnings`` to ``False`` (:issue:`56920`)
5253
- :meth:`Styler.format_index_names` can now be used to format the index and column names (:issue:`48936` and :issue:`47489`)
5354
- :class:`.errors.DtypeWarning` improved to include column names when mixed data types are detected (:issue:`58174`)

pandas/compat/_optional.py

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,119 @@
7272
"tables": "pytables",
7373
}
7474

75+
# Mapping of operation contexts to alternative dependencies
76+
OPERATION_CONTEXTS = {
77+
"excel": {
78+
"alternatives": ["openpyxl", "xlsxwriter", "calamine", "xlrd", "pyxlsb", "odfpy"],
79+
"description": "Excel file operations",
80+
},
81+
"plotting": {
82+
"alternatives": ["matplotlib"],
83+
"description": "plotting operations",
84+
"fallback": "Use df.describe() for text-based data summaries",
85+
},
86+
"html": {
87+
"alternatives": ["lxml", "html5lib", "beautifulsoup4"],
88+
"description": "HTML parsing",
89+
},
90+
"xml": {
91+
"alternatives": ["lxml"],
92+
"description": "XML parsing",
93+
},
94+
"sql": {
95+
"alternatives": ["sqlalchemy", "psycopg2", "pymysql"],
96+
"description": "SQL database operations",
97+
},
98+
"performance": {
99+
"alternatives": ["numexpr", "bottleneck", "numba"],
100+
"description": "performance acceleration",
101+
"fallback": "Operations will use standard implementations",
102+
},
103+
"parquet": {
104+
"alternatives": ["pyarrow", "fastparquet"],
105+
"description": "Parquet file operations",
106+
},
107+
"feather": {
108+
"alternatives": ["pyarrow"],
109+
"description": "Feather file operations",
110+
},
111+
"orc": {
112+
"alternatives": ["pyarrow"],
113+
"description": "ORC file operations",
114+
},
115+
"hdf5": {
116+
"alternatives": ["tables"],
117+
"description": "HDF5 file operations",
118+
},
119+
"spss": {
120+
"alternatives": ["pyreadstat"],
121+
"description": "SPSS file operations",
122+
},
123+
"style": {
124+
"alternatives": ["jinja2"],
125+
"description": "DataFrame styling operations",
126+
},
127+
"compression": {
128+
"alternatives": ["zstandard"],
129+
"description": "data compression operations",
130+
},
131+
"clipboard": {
132+
"alternatives": ["pyqt5", "qtpy"],
133+
"description": "clipboard operations",
134+
},
135+
}
136+
137+
138+
def _build_context_message(
139+
name: str, operation_context: str | None, extra: str, install_name: str
140+
) -> str:
141+
"""
142+
Build an enhanced error message with context-aware alternatives.
143+
144+
Parameters
145+
----------
146+
name : str
147+
The module name that failed to import.
148+
operation_context : str or None
149+
The operation context (e.g., 'excel', 'plotting').
150+
extra : str
151+
Additional text to include in the ImportError message.
152+
install_name : str
153+
The package name to install.
154+
155+
Returns
156+
-------
157+
str
158+
The enhanced error message.
159+
"""
160+
base_msg = f"Missing optional dependency '{install_name}'."
161+
if extra:
162+
base_msg += f" {extra}"
163+
164+
if operation_context and operation_context in OPERATION_CONTEXTS:
165+
context_info = OPERATION_CONTEXTS[operation_context]
166+
# Filter out the failed dependency from alternatives
167+
alternatives = [
168+
alt for alt in context_info["alternatives"]
169+
if alt != name and alt != install_name
170+
]
171+
172+
if alternatives:
173+
if len(alternatives) == 1:
174+
alt_msg = f" For {context_info['description']}, try installing {alternatives[0]}."
175+
elif len(alternatives) == 2:
176+
alt_msg = f" For {context_info['description']}, try installing {alternatives[0]} or {alternatives[1]}."
177+
else:
178+
alt_list = ", ".join(alternatives[:-1]) + f", or {alternatives[-1]}"
179+
alt_msg = f" For {context_info['description']}, try installing {alt_list}."
180+
base_msg += alt_msg
181+
182+
if "fallback" in context_info:
183+
base_msg += f" {context_info['fallback']}."
184+
185+
base_msg += f" Use pip or conda to install {install_name}."
186+
return base_msg
187+
75188

76189
def get_version(module: types.ModuleType) -> str:
77190
version = getattr(module, "__version__", None)
@@ -91,6 +204,7 @@ def import_optional_dependency(
91204
min_version: str | None = ...,
92205
*,
93206
errors: Literal["raise"] = ...,
207+
operation_context: str | None = ...,
94208
) -> types.ModuleType: ...
95209

96210

@@ -101,6 +215,7 @@ def import_optional_dependency(
101215
min_version: str | None = ...,
102216
*,
103217
errors: Literal["warn", "ignore"],
218+
operation_context: str | None = ...,
104219
) -> types.ModuleType | None: ...
105220

106221

@@ -110,6 +225,7 @@ def import_optional_dependency(
110225
min_version: str | None = None,
111226
*,
112227
errors: Literal["raise", "warn", "ignore"] = "raise",
228+
operation_context: str | None = None,
113229
) -> types.ModuleType | None:
114230
"""
115231
Import an optional dependency.
@@ -137,6 +253,11 @@ def import_optional_dependency(
137253
min_version : str, default None
138254
Specify a minimum version that is different from the global pandas
139255
minimum version required.
256+
operation_context : str, default None
257+
Provide context about the operation requiring this dependency to show
258+
relevant alternatives in error messages. Supported contexts: 'excel',
259+
'plotting', 'html', 'xml', 'sql', 'performance', 'parquet', 'feather',
260+
'orc', 'hdf5', 'spss', 'style', 'compression', 'clipboard'.
140261
Returns
141262
-------
142263
maybe_module : Optional[ModuleType]
@@ -150,10 +271,7 @@ def import_optional_dependency(
150271
package_name = INSTALL_MAPPING.get(name)
151272
install_name = package_name if package_name is not None else name
152273

153-
msg = (
154-
f"`Import {install_name}` failed. {extra} "
155-
f"Use pip or conda to install the {install_name} package."
156-
)
274+
msg = _build_context_message(name, operation_context, extra, install_name)
157275
try:
158276
module = importlib.import_module(name)
159277
except ImportError as err:

pandas/io/excel/_calamine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def __init__(
5656
engine_kwargs : dict, optional
5757
Arbitrary keyword arguments passed to excel engine.
5858
"""
59-
import_optional_dependency("python_calamine")
59+
import_optional_dependency("python_calamine", operation_context="excel")
6060
super().__init__(
6161
filepath_or_buffer,
6262
storage_options=storage_options,

pandas/io/excel/_odfreader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def __init__(
4646
engine_kwargs : dict, optional
4747
Arbitrary keyword arguments passed to excel engine.
4848
"""
49-
import_optional_dependency("odf")
49+
import_optional_dependency("odf", operation_context="excel")
5050
super().__init__(
5151
filepath_or_buffer,
5252
storage_options=storage_options,

pandas/io/excel/_openpyxl.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ def __init__(
550550
engine_kwargs : dict, optional
551551
Arbitrary keyword arguments passed to excel engine.
552552
"""
553-
import_optional_dependency("openpyxl")
553+
import_optional_dependency("openpyxl", operation_context="excel")
554554
super().__init__(
555555
filepath_or_buffer,
556556
storage_options=storage_options,

pandas/io/excel/_pyxlsb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def __init__(
4040
engine_kwargs : dict, optional
4141
Arbitrary keyword arguments passed to excel engine.
4242
"""
43-
import_optional_dependency("pyxlsb")
43+
import_optional_dependency("pyxlsb", operation_context="excel")
4444
# This will call load_workbook on the filepath or buffer
4545
# And set the result to the book-attribute
4646
super().__init__(

pandas/io/excel/_xlrd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def __init__(
4242
Arbitrary keyword arguments passed to excel engine.
4343
"""
4444
err_msg = "Install xlrd >= 2.0.1 for xls Excel support"
45-
import_optional_dependency("xlrd", extra=err_msg)
45+
import_optional_dependency("xlrd", extra=err_msg, operation_context="excel")
4646
super().__init__(
4747
filepath_or_buffer,
4848
storage_options=storage_options,

pandas/io/html.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,10 +913,10 @@ def _parser_dispatch(flavor: HTMLFlavors | None) -> type[_HtmlFrameParser]:
913913
)
914914

915915
if flavor in ("bs4", "html5lib"):
916-
import_optional_dependency("html5lib")
917-
import_optional_dependency("bs4")
916+
import_optional_dependency("html5lib", operation_context="html")
917+
import_optional_dependency("bs4", operation_context="html")
918918
else:
919-
import_optional_dependency("lxml.etree")
919+
import_optional_dependency("lxml.etree", operation_context="html")
920920
return _valid_parsers[flavor]
921921

922922

pandas/plotting/_core.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
Substitution,
1414
)
1515

16+
from pandas.compat._optional import import_optional_dependency
17+
1618
from pandas.core.dtypes.common import (
1719
is_integer,
1820
is_list_like,
@@ -1947,15 +1949,13 @@ def _load_backend(backend: str) -> types.ModuleType:
19471949
from importlib.metadata import entry_points
19481950

19491951
if backend == "matplotlib":
1950-
# Because matplotlib is an optional dependency and first-party backend,
1951-
# we need to attempt an import here to raise an ImportError if needed.
1952-
try:
1953-
module = importlib.import_module("pandas.plotting._matplotlib")
1954-
except ImportError:
1955-
raise ImportError(
1956-
"matplotlib is required for plotting when the "
1957-
'default backend "matplotlib" is selected.'
1958-
) from None
1952+
# Check for matplotlib dependency with enhanced error message
1953+
import_optional_dependency(
1954+
"matplotlib",
1955+
extra="Required for plotting when the default backend 'matplotlib' is selected.",
1956+
operation_context="plotting"
1957+
)
1958+
module = importlib.import_module("pandas.plotting._matplotlib")
19591959
return module
19601960

19611961
found_backend = False

pandas/tests/test_optional_dependency.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313

1414
def test_import_optional():
15-
match = "Import .*notapackage.* pip .* conda .* notapackage"
15+
match = r"Missing optional dependency 'notapackage'.*Use pip or conda to install notapackage"
1616
with pytest.raises(ImportError, match=match) as exc_info:
1717
import_optional_dependency("notapackage")
1818
# The original exception should be there as context:
@@ -65,6 +65,69 @@ def test_bad_version(monkeypatch):
6565
assert result is None
6666

6767

68+
def test_operation_context_excel():
69+
match = (
70+
r"Missing optional dependency 'notapackage'.*"
71+
r"For Excel file operations, try installing openpyxl, xlsxwriter, calamine.*"
72+
r".*Use pip or conda to install notapackage"
73+
)
74+
with pytest.raises(ImportError, match=match):
75+
import_optional_dependency("notapackage", operation_context="excel")
76+
77+
78+
def test_operation_context_plotting():
79+
match = (
80+
r"Missing optional dependency 'notapackage'.*"
81+
r"For plotting operations, try installing matplotlib.*"
82+
r"Use df\.describe\(\) for text-based data summaries.*"
83+
r"Use pip or conda to install notapackage"
84+
)
85+
with pytest.raises(ImportError, match=match):
86+
import_optional_dependency("notapackage", operation_context="plotting")
87+
88+
89+
def test_operation_context_with_extra():
90+
match = (
91+
r"Missing optional dependency 'notapackage'.*Additional context.*"
92+
r"For Excel file operations, try installing openpyxl, xlsxwriter, calamine.*"
93+
r".*Use pip or conda to install notapackage"
94+
)
95+
with pytest.raises(ImportError, match=match):
96+
import_optional_dependency(
97+
"notapackage",
98+
extra="Additional context.",
99+
operation_context="excel"
100+
)
101+
102+
103+
def test_operation_context_unknown():
104+
# Unknown context should fall back to standard behavior
105+
match = r"Missing optional dependency 'notapackage'.*Use pip or conda to install notapackage"
106+
with pytest.raises(ImportError, match=match):
107+
import_optional_dependency("notapackage", operation_context="unknown_context")
108+
109+
110+
def test_operation_context_filtering():
111+
# The failed dependency should be filtered out from alternatives
112+
match = (
113+
r"Missing optional dependency 'openpyxl'.*"
114+
r"For Excel file operations, try installing xlsxwriter, calamine.*"
115+
r".*Use pip or conda to install openpyxl"
116+
)
117+
with pytest.raises(ImportError, match=match):
118+
import_optional_dependency("openpyxl", operation_context="excel")
119+
120+
121+
def test_operation_context_ignore_errors():
122+
# operation_context should not affect ignore behavior
123+
result = import_optional_dependency(
124+
"notapackage",
125+
operation_context="excel",
126+
errors="ignore"
127+
)
128+
assert result is None
129+
130+
68131
def test_submodule(monkeypatch):
69132
# Create a fake module with a submodule
70133
name = "fakemodule"

0 commit comments

Comments
 (0)