diff --git a/mkdocs/docs/contributing.md b/mkdocs/docs/contributing.md index aaecab2cb0..4f13570904 100644 --- a/mkdocs/docs/contributing.md +++ b/mkdocs/docs/contributing.md @@ -258,6 +258,27 @@ Which will warn: Deprecated in 0.1.0, will be removed in 0.2.0. The old_property is deprecated. Please use the something_else property instead. ``` +### Logging + +PyIceberg uses Python's standard logging module. You can control the logging level using either: + +**CLI option:** + +```bash +pyiceberg --log-level DEBUG describe my_table +``` + +**Environment variable:** + +```bash +export PYICEBERG_LOG_LEVEL=DEBUG +pyiceberg describe my_table +``` + +Valid log levels are: `DEBUG`, `INFO`, `WARNING` (default), `ERROR`, `CRITICAL`. + +Debug logging is particularly useful for troubleshooting issues with FileIO implementations, catalog connections, and other integration points. + ### Type annotations For the type annotation the types from the `Typing` package are used. diff --git a/pyiceberg/cli/console.py b/pyiceberg/cli/console.py index 9baa813eff..6c14eea062 100644 --- a/pyiceberg/cli/console.py +++ b/pyiceberg/cli/console.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. # pylint: disable=broad-except,redefined-builtin,redefined-outer-name +import logging from collections.abc import Callable from functools import wraps from typing import ( @@ -55,6 +56,13 @@ def wrapper(*args: Any, **kwargs: Any): # type: ignore @click.option("--catalog") @click.option("--verbose", type=click.BOOL) @click.option("--output", type=click.Choice(["text", "json"]), default="text") +@click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False), + default="WARNING", + envvar="PYICEBERG_LOG_LEVEL", + help="Set the logging level", +) @click.option("--ugi") @click.option("--uri") @click.option("--credential") @@ -64,10 +72,16 @@ def run( catalog: str | None, verbose: bool, output: str, + log_level: str, ugi: str | None, uri: str | None, credential: str | None, ) -> None: + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s:%(levelname)s:%(name)s:%(message)s", + ) + properties = {} if ugi: properties["ugi"] = ugi diff --git a/tests/cli/test_console.py b/tests/cli/test_console.py index a0e9552236..a713975ec9 100644 --- a/tests/cli/test_console.py +++ b/tests/cli/test_console.py @@ -967,3 +967,65 @@ def test_json_properties_remove_table_does_not_exist(catalog: InMemoryCatalog) - result = runner.invoke(run, ["--output=json", "properties", "remove", "table", "default.doesnotexist", "location"]) assert result.exit_code == 1 assert result.output == """{"type": "NoSuchTableError", "message": "Table does not exist: default.doesnotexist"}\n""" + + +def test_log_level_cli_option(mocker: MockFixture) -> None: + mock_basicConfig = mocker.patch("logging.basicConfig") + + runner = CliRunner() + runner.invoke(run, ["--log-level", "DEBUG", "list"]) + + # Verify logging.basicConfig was called with DEBUG level + import logging + + mock_basicConfig.assert_called_once() + call_kwargs = mock_basicConfig.call_args[1] + assert call_kwargs["level"] == logging.DEBUG + + +def test_log_level_env_variable(mocker: MockFixture) -> None: + mock_basicConfig = mocker.patch("logging.basicConfig") + mocker.patch.dict(os.environ, {"PYICEBERG_LOG_LEVEL": "INFO"}) + + runner = CliRunner() + runner.invoke(run, ["list"]) + + # Verify logging.basicConfig was called with INFO level + import logging + + mock_basicConfig.assert_called_once() + call_kwargs = mock_basicConfig.call_args[1] + assert call_kwargs["level"] == logging.INFO + + +def test_log_level_default_warning(mocker: MockFixture) -> None: + mock_basicConfig = mocker.patch("logging.basicConfig") + # Ensure PYICEBERG_LOG_LEVEL is not set + mocker.patch.dict(os.environ, {}, clear=False) + if "PYICEBERG_LOG_LEVEL" in os.environ: + del os.environ["PYICEBERG_LOG_LEVEL"] + + runner = CliRunner() + runner.invoke(run, ["list"]) + + # Verify logging.basicConfig was called with WARNING level (default) + import logging + + mock_basicConfig.assert_called_once() + call_kwargs = mock_basicConfig.call_args[1] + assert call_kwargs["level"] == logging.WARNING + + +def test_log_level_cli_overrides_env(mocker: MockFixture) -> None: + mock_basicConfig = mocker.patch("logging.basicConfig") + mocker.patch.dict(os.environ, {"PYICEBERG_LOG_LEVEL": "INFO"}) + + runner = CliRunner() + runner.invoke(run, ["--log-level", "ERROR", "list"]) + + # Verify CLI option overrides environment variable + import logging + + mock_basicConfig.assert_called_once() + call_kwargs = mock_basicConfig.call_args[1] + assert call_kwargs["level"] == logging.ERROR