-
-
Notifications
You must be signed in to change notification settings - Fork 94
Fixes PROCRASTINATE_VERBOSITY to PROCRASTINATE_VERBOSE and adds a new --log-level option for granular log level control. #1501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2bf6584
fb912a2
77c12cf
50a66b9
3bc52d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import os | ||
| import shlex | ||
| import sys | ||
| import warnings | ||
| from collections.abc import Awaitable, Callable | ||
| from typing import Any, Literal | ||
|
|
||
|
|
@@ -20,19 +21,54 @@ | |
| ENV_PREFIX = PROGRAM_NAME.upper() | ||
|
|
||
|
|
||
| def get_log_level(verbosity: int) -> int: | ||
| def get_log_level( | ||
| verbosity: int | None = None, | ||
| log_level: str | None = None, | ||
| ) -> int: | ||
| """ | ||
| Given the number of repetitions of the flag -v, | ||
| returns the desired log level | ||
| Determine the appropriate logging level. | ||
|
|
||
| Args: | ||
| verbosity: Number of -v flags (0=INFO, 1+=DEBUG) | ||
| log_level: Explicit log level string (debug, info, warning, error, critical) | ||
|
|
||
| Returns: | ||
| Logging level constant (e.g., logging.INFO, logging.DEBUG) | ||
|
|
||
| Precedence: log_level > verbosity > default INFO | ||
| """ | ||
| return {0: logging.INFO, 1: logging.DEBUG}.get(min((1, verbosity)), 0) | ||
| if log_level is not None: | ||
| return getattr(logging, log_level.upper()) | ||
|
|
||
| if verbosity is not None: | ||
| return {0: logging.INFO, 1: logging.DEBUG}.get( | ||
| min((1, verbosity)), logging.DEBUG | ||
| ) | ||
|
|
||
| return logging.INFO | ||
|
|
||
|
|
||
| Style = Literal["%", "{", "$"] | ||
|
|
||
|
|
||
| def configure_logging(verbosity: int, format: str, style: Style) -> None: | ||
| level = get_log_level(verbosity=verbosity) | ||
| def configure_logging( | ||
| verbosity: int | None = None, | ||
| log_level: str | None = None, | ||
| format: str = logging.BASIC_FORMAT, | ||
| style: Style = "%", | ||
| ) -> None: | ||
| """Configure the Python logging system.""" | ||
| # Issue deprecation warning when -v/--verbose is actively used | ||
| if verbosity is not None and verbosity > 0: | ||
| warnings.warn( | ||
| "The -v/--verbose flag and PROCRASTINATE_VERBOSE environment variable " | ||
| "are deprecated and will be removed in a future version. " | ||
| "Use --log-level or PROCRASTINATE_LOG_LEVEL instead.", | ||
| PendingDeprecationWarning, | ||
| stacklevel=2, | ||
| ) | ||
|
|
||
| level = get_log_level(verbosity=verbosity, log_level=log_level) | ||
| logging.basicConfig(level=level, format=format, style=style) | ||
| level_name = logging.getLevelName(level) | ||
| logger.debug( | ||
|
|
@@ -176,8 +212,14 @@ def add_cli_features(parser: argparse.ArgumentParser): | |
| Add features to the parser to make it more CLI-friendly. | ||
| This is not necessary when the parser is used as a subparser. | ||
| """ | ||
| log_group = parser.add_argument_group("Logging") | ||
|
|
||
| # Create mutually exclusive group for verbosity control | ||
| verbosity_group = log_group.add_mutually_exclusive_group() | ||
|
|
||
| # Use add_argument helper for consistent env var handling | ||
| add_argument( | ||
| parser, | ||
| verbosity_group, | ||
| "-v", | ||
| "--verbose", | ||
| default=0, | ||
|
|
@@ -187,7 +229,17 @@ def add_cli_features(parser: argparse.ArgumentParser): | |
| envvar_help="set to desired verbosity level", | ||
| envvar_type=int, | ||
| ) | ||
| log_group = parser.add_argument_group("Logging") | ||
|
|
||
| # Use add_argument helper for --log-level too | ||
| add_argument( | ||
| verbosity_group, | ||
| "--log-level", | ||
| choices=["debug", "info", "warning", "error", "critical"], | ||
| help="Set log level explicitly", | ||
| envvar="LOG_LEVEL", | ||
| envvar_help="set to desired log level", | ||
| ) | ||
|
Comment on lines
+233
to
+241
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's see the overall structure and find the add_argument helper
find procrastinate -name "*.py" -type f | head -20Repository: procrastinate-org/procrastinate Length of output: 863 🏁 Script executed: # Look at the cli.py file to understand the add_argument helper
cat -n procrastinate/cli.py | head -100Repository: procrastinate-org/procrastinate Length of output: 3533 🏁 Script executed: # Search for the add_argument function definition
rg "def add_argument" procrastinate/cli.py -A 20Repository: procrastinate-org/procrastinate Length of output: 1478 🏁 Script executed: # Look for configure_logging to see how it processes log_level
rg "def configure_logging" procrastinate -A 30Repository: procrastinate-org/procrastinate Length of output: 1657 🌐 Web query:
💡 Result: In stdlib parser.add_argument("--mode", choices=["dev","prod"], default=os.getenv("MODE"))Why an env-var “default” can bypass
|
||
|
|
||
| add_argument( | ||
| log_group, | ||
| "--log-format", | ||
|
|
@@ -511,14 +563,31 @@ def configure_shell_parser(subparsers: argparse._SubParsersAction[Any]): # pyri | |
| shell_parser.set_defaults(func=shell_) | ||
|
|
||
|
|
||
| async def cli(args: list[str]): | ||
| def parse_args(args: list[str]) -> dict[str, Any]: | ||
| """ | ||
| Create parser, configure it, and parse arguments. | ||
|
|
||
| Pure function that transforms CLI arguments into a parsed dictionary, | ||
| making it testable without mocking execute_command or configure_logging. | ||
|
|
||
| Args: | ||
| args: Command-line arguments (e.g., sys.argv[1:]) | ||
|
|
||
| Returns: | ||
| Dictionary of parsed arguments | ||
| """ | ||
| parser = create_parser() | ||
| add_arguments(parser) | ||
| add_cli_features(parser) | ||
| parsed = vars(parser.parse_args(args)) | ||
| return vars(parser.parse_args(args)) | ||
|
|
||
|
|
||
| async def cli(args: list[str]): | ||
| parsed = parse_args(args) | ||
|
|
||
| configure_logging( | ||
| verbosity=parsed.pop("verbose"), | ||
| verbosity=parsed.pop("verbose", None), | ||
| log_level=parsed.pop("log_level", None), | ||
| format=parsed.pop("log_format"), | ||
| style=parsed.pop("log_format_style"), | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| import io | ||
| import json | ||
| import logging | ||
| import warnings | ||
|
|
||
| import pytest | ||
|
|
||
|
|
@@ -13,18 +14,38 @@ | |
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "verbosity, log_level", [(0, "INFO"), (1, "DEBUG"), (2, "DEBUG")] | ||
| "verbosity, log_level, expected", | ||
| [ | ||
| # Test verbosity alone | ||
| (0, None, logging.INFO), | ||
| (1, None, logging.DEBUG), | ||
| (2, None, logging.DEBUG), | ||
| # Test log_level alone | ||
| (None, "debug", logging.DEBUG), | ||
| (None, "info", logging.INFO), | ||
| (None, "warning", logging.WARNING), | ||
| (None, "error", logging.ERROR), | ||
| (None, "critical", logging.CRITICAL), | ||
| # Test precedence: log_level wins | ||
| (1, "warning", logging.WARNING), | ||
| (0, "debug", logging.DEBUG), | ||
| # Test default | ||
| (None, None, logging.INFO), | ||
| ], | ||
| ) | ||
| def test_get_log_level(verbosity, log_level): | ||
| assert cli.get_log_level(verbosity=verbosity) == getattr(logging, log_level) | ||
| def test_get_log_level(verbosity, log_level, expected): | ||
| """Test get_log_level with various combinations.""" | ||
| assert cli.get_log_level(verbosity=verbosity, log_level=log_level) == expected | ||
|
|
||
|
|
||
| def test_configure_logging(mocker, caplog): | ||
| config = mocker.patch("logging.basicConfig") | ||
|
|
||
| caplog.set_level("DEBUG") | ||
|
|
||
| cli.configure_logging(verbosity=1, format="{message}, yay!", style="{") | ||
| # Expect deprecation warning when using verbosity | ||
| with pytest.warns(PendingDeprecationWarning): | ||
| cli.configure_logging(verbosity=1, format="{message}, yay!", style="{") | ||
|
|
||
| config.assert_called_once_with( | ||
| level=logging.DEBUG, format="{message}, yay!", style="{" | ||
|
|
@@ -34,6 +55,137 @@ def test_configure_logging(mocker, caplog): | |
| assert records[0].value == "DEBUG" | ||
|
|
||
|
|
||
| def test_configure_logging_deprecation_warning(mocker): | ||
| """Test that using -v/--verbose triggers a deprecation warning.""" | ||
| mocker.patch("logging.basicConfig") | ||
|
|
||
| # verbosity > 0 should trigger warning | ||
| with pytest.warns(PendingDeprecationWarning, match="verbose.*deprecated"): | ||
| cli.configure_logging(verbosity=1) | ||
|
|
||
| # verbosity=0 should NOT trigger warning (default level) | ||
| with warnings.catch_warnings(): | ||
| warnings.simplefilter("error") # Turn warnings into errors | ||
| cli.configure_logging(verbosity=0) # Should not raise | ||
|
|
||
| # log_level should NOT trigger warning (new way) | ||
| with warnings.catch_warnings(): | ||
| warnings.simplefilter("error") | ||
| cli.configure_logging(log_level="debug") # Should not raise | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "verbose_env, cli_flags, expected", | ||
| [ | ||
| (None, [], 0), # Default is 0 | ||
| ("1", [], 1), # PROCRASTINATE_VERBOSE works | ||
| ("0", ["-v"], 1), # CLI flag adds to env=0: 0+1=1 | ||
| ("1", ["-v"], 2), # CLI flag adds to env=1: 1+1=2 | ||
| (None, ["-v"], 1), # -v gives verbosity 1 | ||
| (None, ["-vv"], 2), # -vv gives verbosity 2 | ||
| ], | ||
| ) | ||
| async def test_verbose_env_vars(monkeypatch, mocker, verbose_env, cli_flags, expected): | ||
| """Test that PROCRASTINATE_VERBOSE works and CLI flags add to env var value.""" | ||
| # Clear any existing values | ||
| monkeypatch.delenv("PROCRASTINATE_VERBOSE", raising=False) | ||
| monkeypatch.delenv("PROCRASTINATE_LOG_LEVEL", raising=False) | ||
|
|
||
| # Set the env var if provided | ||
| if verbose_env is not None: | ||
| monkeypatch.setenv("PROCRASTINATE_VERBOSE", verbose_env) | ||
|
|
||
| # Mock execute_command to capture the verbosity value | ||
| captured_verbosity = None | ||
|
|
||
| async def mock_execute_command(parsed): | ||
| # Don't actually execute, just return | ||
| pass | ||
|
|
||
| original_configure_logging = cli.configure_logging | ||
|
|
||
| def mock_configure_logging(**kwargs): | ||
| nonlocal captured_verbosity | ||
| captured_verbosity = kwargs.get("verbosity") | ||
| # Call original to ensure it works, suppressing deprecation warnings | ||
| with warnings.catch_warnings(): | ||
| warnings.simplefilter("ignore", PendingDeprecationWarning) | ||
| original_configure_logging(**kwargs) | ||
|
|
||
| mocker.patch("procrastinate.cli.execute_command", side_effect=mock_execute_command) | ||
| mocker.patch( | ||
| "procrastinate.cli.configure_logging", side_effect=mock_configure_logging | ||
| ) | ||
|
Comment on lines
+115
to
+118
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similarly, I believe we could just reorganize the functions so that one function instantiates the parser and parses the arguments, and then the caller does what it needs with the parsed arguments. This would let us test the parser without mocks.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea. I'll extract the parser instantiation and argument parsing into a separate function that returns parsed values, making it testable without mocks. |
||
|
|
||
| # Run cli with the provided flags | ||
| args = [*cli_flags, "--app=", "defer", "test"] | ||
| await cli.cli(args) | ||
|
|
||
| assert captured_verbosity == expected | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "log_level_env, cli_flags, expected_level", | ||
| [ | ||
| (None, ["--log-level", "warning"], "warning"), # CLI --log-level works | ||
| (None, ["--log-level", "error"], "error"), # CLI --log-level=error works | ||
| ( | ||
| None, | ||
| ["--log-level", "critical"], | ||
| "critical", | ||
| ), # CLI --log-level=critical works | ||
| ("warning", [], "warning"), # PROCRASTINATE_LOG_LEVEL works | ||
| ("error", [], "error"), # PROCRASTINATE_LOG_LEVEL=error works | ||
| ("warning", ["--log-level", "error"], "error"), # CLI overrides env | ||
| ], | ||
| ) | ||
| async def test_log_level_option( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm guessing a LLM got overly excited at the idea of writing tests here :D (I hope it's the case, otherwise sorry for my comment, but I can't imagine a human writing 200 lines of test code to validate a logging level selection logic at 3 different levels) I'm ok with having a high coverage, but here, we test things that have already been tested elsewhere, especially in The idea should be:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, you got me, the LLM got excited! I'll reduce the test |
||
| monkeypatch, mocker, log_level_env, cli_flags, expected_level | ||
| ): | ||
| """Test that --log-level and PROCRASTINATE_LOG_LEVEL work correctly.""" | ||
| # Clear any existing values | ||
| monkeypatch.delenv("PROCRASTINATE_LOG_LEVEL", raising=False) | ||
| monkeypatch.delenv("PROCRASTINATE_VERBOSE", raising=False) | ||
|
|
||
| # Set the env var if provided | ||
| if log_level_env is not None: | ||
| monkeypatch.setenv("PROCRASTINATE_LOG_LEVEL", log_level_env) | ||
|
|
||
| # Mock execute_command to capture the log level | ||
| captured_log_level = None | ||
|
|
||
| async def mock_execute_command(parsed): | ||
| pass | ||
|
|
||
| original_configure_logging = cli.configure_logging | ||
|
|
||
| def mock_configure_logging(**kwargs): | ||
| nonlocal captured_log_level | ||
| captured_log_level = kwargs.get("log_level") | ||
| original_configure_logging(**kwargs) | ||
|
|
||
| mocker.patch("procrastinate.cli.execute_command", side_effect=mock_execute_command) | ||
| mocker.patch( | ||
| "procrastinate.cli.configure_logging", side_effect=mock_configure_logging | ||
| ) | ||
|
|
||
| # Run cli with the provided flags | ||
| args = [*cli_flags, "--app=", "defer", "test"] | ||
| await cli.cli(args) | ||
|
|
||
| assert captured_log_level == expected_level | ||
|
|
||
|
|
||
| async def test_log_level_and_verbose_mutually_exclusive(monkeypatch): | ||
| """Test that --log-level and --verbose are mutually exclusive.""" | ||
| monkeypatch.delenv("PROCRASTINATE_LOG_LEVEL", raising=False) | ||
| monkeypatch.delenv("PROCRASTINATE_VERBOSE", raising=False) | ||
|
|
||
| # This should raise an error because they're mutually exclusive | ||
| with pytest.raises(SystemExit): | ||
| await cli.cli(["--app=", "-v", "--log-level", "warning", "defer", "test"]) | ||
|
|
||
|
|
||
| def test_main(mocker): | ||
| mock = mocker.patch("procrastinate.cli.cli", new=mocker.AsyncMock()) | ||
| cli.main() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid
$prompts without output (MD014).Markdownlint flags console blocks that show a
$prompt without output. Consider removing the prompts or adding output.Suggested tweak
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)
84-84: Dollar signs used before commands without showing output
(MD014, commands-show-output)
85-85: Dollar signs used before commands without showing output
(MD014, commands-show-output)
🤖 Prompt for AI Agents