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
38 changes: 28 additions & 10 deletions docs/howto/basics/command_line.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,39 @@ In both case, the app you specify must have an asynchronous connector.

## Logging

Three different options allow you to control how the command-line tool should log
events:
Several options allow you to control how the command-line tool should log events:

- Verbosity controls the log level (you'll see message of this level and above):
- **Verbosity** controls the log level (you'll see messages of this level and above):

| Flags | Environment equivalent | Log level |
| ----- | ------------------------- | --------- |
| | PROCRASTINATE_VERBOSITY=0 | `warning` |
| -v | PROCRASTINATE_VERBOSITY=1 | `info` |
| -vv | PROCRASTINATE_VERBOSITY=2 | `debug` |
| Flags | Environment equivalent | Log level |
| -------------- | ----------------------- | --------- |
| | PROCRASTINATE_VERBOSE=0 | `info` |
| -v (or higher) | PROCRASTINATE_VERBOSE=1 | `debug` |

- Log format: `--log-format=` / `PROCRASTINATE_LOG_FORMAT=` lets you control how
Note: Values beyond 1 have no additional effect. When both environment variable
and CLI flag are used, the flag value is added to the environment variable value
(e.g., `PROCRASTINATE_VERBOSE=1` with `-v` results in verbosity=2, which gives `debug`).

- **Log level** allows explicit control over the logging level (mutually exclusive with `-v`):

`--log-level=LEVEL` / `PROCRASTINATE_LOG_LEVEL=LEVEL` where `LEVEL` is one of:
`debug`, `info`, `warning`, `error`, or `critical`.

This option provides access to log levels not available through `-v` flags,
such as `warning`, `error`, and `critical`. You cannot use `--log-level` and
`-v` together.

Examples:

```console
$ procrastinate --log-level=warning worker
$ PROCRASTINATE_LOG_LEVEL=error procrastinate worker
```
Comment on lines +83 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid $ prompts without output (MD014).

Markdownlint flags console blocks that show a $ prompt without output. Consider removing the prompts or adding output.

Suggested tweak
-  $ procrastinate --log-level=warning worker
-  $ PROCRASTINATE_LOG_LEVEL=error procrastinate worker
+  procrastinate --log-level=warning worker
+  PROCRASTINATE_LOG_LEVEL=error procrastinate worker
🧰 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
In `@docs/howto/basics/command_line.md` around lines 83 - 86, The console code
block shows commands with leading "$" prompts which triggers MD014; update the
block in docs/howto/basics/command_line.md by either removing the leading "$"
characters from the two lines ("$ procrastinate --log-level=warning worker" and
"$ PROCRASTINATE_LOG_LEVEL=error procrastinate worker") or replace each command
with the command plus an example output line after it so the fenced block
contains both a prompt and output; ensure the final change removes the bare "$"
prompt-only lines to satisfy markdownlint.


- **Log format**: `--log-format=` / `PROCRASTINATE_LOG_FORMAT=` lets you control how
the log line will be formatted. It uses `%`-style placeholders by default.

- Log format style: `--log-format-style=` / `PROCRASTINATE_LOG_FORMAT_STYLE=`
- **Log format style**: `--log-format-style=` / `PROCRASTINATE_LOG_FORMAT_STYLE=`
lets you choose different styles for the log-format, such as `{` or `$`.

For more information on log formats, refer to the [Python documentation](https://docs.python.org/3/library/logging.html?highlight=logging#logrecord-attributes)
91 changes: 80 additions & 11 deletions procrastinate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
import shlex
import sys
import warnings
from collections.abc import Awaitable, Callable
from typing import Any, Literal

Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's see the overall structure and find the add_argument helper
find procrastinate -name "*.py" -type f | head -20

Repository: 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 -100

Repository: procrastinate-org/procrastinate

Length of output: 3533


🏁 Script executed:

# Search for the add_argument function definition
rg "def add_argument" procrastinate/cli.py -A 20

Repository: 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 30

Repository: procrastinate-org/procrastinate

Length of output: 1657


🌐 Web query:

Python argparse environment variables bypass choices constraint - how does argparse handle env var defaults with choices

💡 Result:

In stdlib argparse, environment variables are not a built-in input sourceparse_args() only parses the command line. If you want env vars, you typically do something like:

parser.add_argument("--mode", choices=["dev","prod"], default=os.getenv("MODE"))

Why an env-var “default” can bypass choices

choices=... validation is applied when an argument value is parsed from the command line and the action runs. A value supplied via default= (including one read from an env var) is treated as “already the value to use if the option is absent”, and argparse does not validate that default against choices (so an invalid default can pass through). This behavior is commonly observed/confirmed in practice (e.g., default="invalid" with choices=["a","b","c"] produces no error unless the user supplies --choice invalid). [2]

argparse docs do note that if the default is a string, it will be processed as if it came from the command line for type conversion—but that still doesn’t imply choices is enforced for defaults. [1]

How to enforce choices when using env vars

Common patterns:

  1. Validate after parsing:
    args = parser.parse_args()
    if args.mode not in {"dev","prod"}:
        parser.error("argument --mode: invalid choice")
  2. Inject the env value as if it were CLI input (so normal choices checking runs):
    argv = []
    if (v := os.getenv("MODE")) is not None:
        argv += ["--mode", v]
    args = parser.parse_args(argv + sys.argv[1:])
  3. Use a library that explicitly supports env vars as inputs (and validates accordingly), e.g. jsonargparse (it documents env parsing as an added feature; stdlib argparse doesn’t do it). [3]

Sources: Python argparse docs on defaults [1]; discussion showing defaults aren’t validated against choices [2]; jsonargparse docs noting env parsing is not part of stdlib argparse [3].

[1] (docs.python.org)
[2] (stackoverflow.com)
[3] (jsonargparse.readthedocs.io)

Citations:


🏁 Script executed:

# Get lines around 210-218 with more context
sed -n '200,230p' procrastinate/cli.py | cat -n

Repository: procrastinate-org/procrastinate

Length of output: 1199


🏁 Script executed:

# Check if there are any type or envvar_type already being used in the file
rg "envvar_type|type=" procrastinate/cli.py | head -20

Repository: procrastinate-org/procrastinate

Length of output: 732


🏁 Script executed:

# Look for tests related to log_level validation
rg "log.level|log_level" procrastinate/tests -i --include="*.py"

Repository: procrastinate-org/procrastinate

Length of output: 516


🏁 Script executed:

# Search for tests related to log_level
find procrastinate/tests -name "*.py" -type f -exec grep -l "log_level\|log-level" {} \;

Repository: procrastinate-org/procrastinate

Length of output: 129


🏁 Script executed:

# Look for test_cli.py
find procrastinate/tests -name "*cli*" -type f

Repository: procrastinate-org/procrastinate

Length of output: 129


🏁 Script executed:

# Find tests directory
find . -type d -name "tests" 2>/dev/null

Repository: procrastinate-org/procrastinate

Length of output: 82


🏁 Script executed:

# Look for test files related to CLI
find . -name "*test*cli*.py" -o -name "*cli*test*.py" 2>/dev/null | head -20

Repository: procrastinate-org/procrastinate

Length of output: 171


🏁 Script executed:

# Check the repository structure more broadly
ls -la | head -20

Repository: procrastinate-org/procrastinate

Length of output: 1298


🏁 Script executed:

# Search for log_level tests
rg "log_level\|log-level" tests/unit/test_cli.py -A 5 -B 5

Repository: procrastinate-org/procrastinate

Length of output: 57


🏁 Script executed:

# Check if there are tests for configure_logging
rg "configure_logging" tests/unit/test_cli.py -A 10 -B 2

Repository: procrastinate-org/procrastinate

Length of output: 3545


🏁 Script executed:

# Look for any validation tests or env var tests
rg "PROCRASTINATE_LOG_LEVEL" tests/ -A 3 -B 3

Repository: procrastinate-org/procrastinate

Length of output: 2452


🏁 Script executed:

# Check the actual implementation of add_argument more closely to understand the flow
sed -n '130,180p' procrastinate/cli.py | cat -n

Repository: procrastinate-org/procrastinate

Length of output: 2145


🏁 Script executed:

# Let's see the complete add_argument implementation to understand how it handles the flow
sed -n '31,70p' procrastinate/cli.py | cat -n

Repository: procrastinate-org/procrastinate

Length of output: 1442


🏁 Script executed:

# Check how configure_logging is called in the cli() function
rg "configure_logging" procrastinate/cli.py -B 5 -A 5

Repository: procrastinate-org/procrastinate

Length of output: 666


🏁 Script executed:

# Verify the complete add_argument implementation, especially the envvar handling
sed -n '31,62p' procrastinate/cli.py | cat -n

Repository: procrastinate-org/procrastinate

Length of output: 1242


🏁 Script executed:

# Search for the full add_argument function
rg "def add_argument" procrastinate/cli.py -A 40

Repository: procrastinate-org/procrastinate

Length of output: 2938


🏁 Script executed:

# Check if there's any additional validation after parsing args and before configure_logging
rg "log_level" procrastinate/cli.py -B 3 -A 3 | head -80

Repository: procrastinate-org/procrastinate

Length of output: 1166


🏁 Script executed:

# Look at the test for invalid log level to see if it exists
rg "invalid.*log" tests/unit/test_cli.py -i -B 5 -A 10

Repository: procrastinate-org/procrastinate

Length of output: 57


🏁 Script executed:

# Check if there's a test for invalid log_level that should fail
rg "test.*log.*level" tests/unit/test_cli.py -i

Repository: procrastinate-org/procrastinate

Length of output: 680


🏁 Script executed:

# Get the parametrized test values for log_level to see what's being tested
rg "@pytest.mark.parametrize" tests/unit/test_cli.py -A 10 | grep -A 10 "log_level"

Repository: procrastinate-org/procrastinate

Length of output: 1520


Add log level validation to prevent crashes from invalid PROCRASTINATE_LOG_LEVEL.

The --log-level argument uses choices constraint, but this only validates command-line input. Environment variables bypass choices validation, so an invalid PROCRASTINATE_LOG_LEVEL reaches configure_logging and crashes on getattr(logging, ...) with an AttributeError. Add a validator function using the envvar_type parameter (already supported by the custom add_argument helper) to validate both CLI and environment variable inputs.

Suggested fix
+    def _validate_log_level(value: str) -> str:
+        value = value.lower()
+        if value not in {"debug", "info", "warning", "error", "critical"}:
+            raise argparse.ArgumentTypeError(
+                f"Invalid log level: {value!r}. "
+                "Expected one of debug/info/warning/error/critical."
+            )
+        return value
+
     add_argument(
         verbosity_group,
         "--log-level",
         choices=["debug", "info", "warning", "error", "critical"],
+        type=_validate_log_level,
+        envvar_type=_validate_log_level,
         help="Set log level explicitly",
         envvar="LOG_LEVEL",
         envvar_help="set to desired log level",
     )
🤖 Prompt for AI Agents
In `@procrastinate/cli.py` around lines 210 - 218, The --log-level argument
currently uses choices which only validates CLI input; add a validator via the
add_argument helper's envvar_type parameter to validate both CLI and
PROCRASTINATE_LOG_LEVEL env var values: implement a small validator function
(e.g., validate_log_level) that normalizes the input (lowercase) and checks it
against the allowed set ["debug","info","warning","error","critical"], raising
argparse.ArgumentTypeError on invalid values, then pass this validator as
envvar_type (and type if needed) when calling add_argument for "--log-level"
(referencing verbosity_group and configure_logging to ensure downstream code
receives a validated string and no getattr(logging, ...) AttributeError occurs).


add_argument(
log_group,
"--log-format",
Expand Down Expand Up @@ -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"),
)
Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ async def test_cli(entrypoint):
assert result.stderr.startswith("usage:")


@pytest.mark.filterwarnings("ignore::PendingDeprecationWarning")
async def test_cli_logging_configuration(entrypoint, cli_app):
result = await entrypoint(
"--verbose --log-format {message},yay! --log-format-style { healthchecks"
Expand Down
160 changes: 156 additions & 4 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import io
import json
import logging
import warnings

import pytest

Expand All @@ -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="{"
Expand All @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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(
Copy link
Copy Markdown
Member

@ewjoachim ewjoachim Jan 29, 2026

Choose a reason for hiding this comment

The 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 test_configure_logging_with_log_level

The idea should be:

  • Put the logic that you want to test in pure (as in function programming) functions
  • Test those extensively in small tests that don't need mocks. Each test should be very scoped. Parametrize is your friend.
  • The caller calls the function and uses the result
  • The caller only needs a small amount of integration tests, possibly with mocks. It's ok that a single tests ends up testing multiple of moving parts. All corner cases have already been tested by unit tests, so we just check that the pipes are plugged.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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()
Expand Down
Loading