Skip to content
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

Add proper django support #149

Merged
merged 5 commits into from
Jan 11, 2025
Merged
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
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ repos:
rev: v1.14.1
hooks:
- id: mypy
additional_dependencies: ["rich"]
additional_dependencies: ["rich", "types-colorama", "django-stubs"]
pass_filenames: false
args: ["rich_argparse"]
9 changes: 6 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
## Unreleased

### Features
- [PR-149](https://github.com/hamdanal/rich-argparse/pull/149)
Add support for django commands in the new `rich_argparse.django` module. Read more at
https://github.com/hamdanal/rich-argparse#django-support
- [GH-140](https://github.com/hamdanal/rich-argparse/issues/140),
[PR-147](https://github.com/hamdanal/rich-argparse/pull/147)
Add `rich_argparse.contrib` module to provide additional non-standard formatters. The new module
currently contains a single formatter `ParagraphRichHelpFormatter` that preserves paragraph breaks
(`\n\n`) in the descriptions, epilog, and help text.
Add `ParagraphRichHelpFormatter`, a formatter that preserves paragraph breaks, in the new
`rich_argparse.contrib` module. Read more at
https://github.com/hamdanal/rich-argparse#additional-formatters

### Fixes
- [GH-141](https://github.com/hamdanal/rich-argparse/issues/141),
Expand Down
75 changes: 29 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ changes to the code.
* [Installation](#installation)
* [Usage](#usage)
* [Output styles](#output-styles)
* [Colors](#customize-the-colors)
* [Group names](#customize-the-group-name-format)
* [Highlighting patterns](#special-text-highlighting)
* ["usage"](#colors-in-the-usage)
* [Customizing colors](#customize-the-colors)
* [Group name formatting](#customize-the-group-name-format)
* [Special text highlighting](#special-text-highlighting)
* [Customizing `usage`](#colors-in-the-usage)
* [Console markup](#disable-console-markup)
* [--version](#colors-in---version)
* [Colors in `--version`](#colors-in---version)
* [Rich renderables](#rich-descriptions-and-epilog)
* [Subparsers](#working-with-subparsers)
* [Working with subparsers](#working-with-subparsers)
* [Documenting your CLI](#generate-help-preview)
* [Additional formatters](#additional-formatters)
* [Third party formatters](#third-party-formatters) (ft. django)
* [Optparse](#optparse-support) (experimental)
* [Django support](#django-support)
* [Optparse support](#optparse-support) (experimental)
* [Legacy Windows](#legacy-windows-support)

## Installation
Expand Down Expand Up @@ -244,9 +244,9 @@ COLUMNS=120 python my_cli.py --generate-help-preview # force the width of the o

## Additional formatters

*rich-argparse* ships with additional non-standard argparse formatters for some common use cases in
*rich-argparse* defines additional non-standard argparse formatters for some common use cases in
the `rich_argparse.contrib` module. They can be imported with the `from rich_argparse.contrib import`
syntax.
syntax. The following formatters are available:

* `ParagraphRichHelpFormatter`: A formatter similar to `RichHelpFormatter` that preserves paragraph
breaks. A paragraph break is defined as two consecutive newlines (`\n\n`) in the help or
Expand All @@ -255,25 +255,14 @@ syntax.

_More formatters will be added in the future._

## Third party formatters
## Django support

*rich-argparse* can be used with other custom formatters through multiple inheritance. For example,
[django](https://pypi.org/project/django) defines a custom help formatter for its built in commands
as well as extension libraries and user defined commands. To use *rich-argparse* in your django
project, change your `manage.py` file as follows:
*rich-argparse* provides support for django's custom help formatter. You can instruct django to use
*rich-argparse* with all built-in, extension libraries, and user defined commands in a django
project by adding these two lines to the `manage.py` file:

```diff
diff --git a/my_project/manage.py b/my_project/manage.py
index 7fb6855..5e5d48a 100755
--- a/my_project/manage.py
+++ b/my_project/manage.py
@@ -1,22 +1,38 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


diff --git a/manage.py b/manage.py
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my_project.settings')
Expand All @@ -285,31 +274,25 @@ index 7fb6855..5e5d48a 100755
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
+
+ from django.core.management.base import BaseCommand, DjangoHelpFormatter
+ from rich_argparse import RichHelpFormatter
+
+ class DjangoRichHelpFormatter(DjangoHelpFormatter, RichHelpFormatter): # django first
+ """A rich-based help formatter for django commands."""
+
+ original_create_parser = BaseCommand.create_parser
+
+ def create_parser(*args, **kwargs):
+ parser = original_create_parser(*args, **kwargs)
+ parser.formatter_class = DjangoRichHelpFormatter # set the formatter_class
+ return parser
+
+ BaseCommand.create_parser = create_parser
+
+ from rich_argparse.django import richify_command_line_help
+ richify_command_line_help()
execute_from_command_line(sys.argv)
```

Alternatively, you can use the `DjangoRichHelpFormatter` class directly in your commands:

if __name__ == '__main__':
main()
```diff
diff --git a/my_app/management/commands/my_command.py b/my_app/management/commands/my_command.py
from django.core.management.base import BaseCommand
+from rich_argparse.django import DjangoRichHelpFormatter

class Command(BaseCommand):
def add_arguments(self, parser):
+ parser.formatter_class = DjangoRichHelpFormatter
parser.add_argument("--option", action="store_true", help="An option")
...
```

Now the output of all `python manage.py <COMMAND> --help` will be colored.

## Optparse support

*rich-argparse* now ships with experimental support for [optparse](
Expand Down
64 changes: 64 additions & 0 deletions rich_argparse/_patching.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>

# for internal use only
from __future__ import annotations

from rich_argparse._argparse import RichHelpFormatter


def patch_default_formatter_class(
cls=None, /, *, formatter_class=RichHelpFormatter, method_name="__init__"
):
"""Patch the default `formatter_class` parameter of an argument parser constructor.

Parameters
----------
cls : (type, optional)
The class to patch. If not provided, a decorator is returned.
formatter_class : (type, optional)
The new formatter class to use. Defaults to ``RichHelpFormatter``.
method_name : (str, optional)
The method name to patch. Defaults to ``__init__``.

Examples
--------
Can be used as a normal function to patch an existing class::

# Patch the default formatter class of `argparse.ArgumentParser`
patch_default_formatter_class(argparse.ArgumentParser)

# Patch the default formatter class of django commands
from django.core.management.base import BaseCommand, DjangoHelpFormatter
class DjangoRichHelpFormatter(DjangoHelpFormatter, RichHelpFormatter): ...
patch_default_formatter_class(
BaseCommand, formatter_class=DjangoRichHelpFormatter, method_name="create_parser"
)
Or as a decorator to patch a new class::

@patch_default_formatter_class
class MyArgumentParser(argparse.ArgumentParser):
pass

@patch_default_formatter_class(formatter_class=RawDescriptionRichHelpFormatter)
class MyOtherArgumentParser(argparse.ArgumentParser):
pass
"""
import functools

def decorator(cls, /):
method = getattr(cls, method_name)
if not callable(method):
raise TypeError(f"'{cls.__name__}.{method_name}' is not callable")

@functools.wraps(method)
def wrapper(*args, **kwargs):
kwargs.setdefault("formatter_class", formatter_class)
return method(*args, **kwargs)

setattr(cls, method_name, wrapper)
return cls

if cls is None:
return decorator
return decorator(cls)
28 changes: 28 additions & 0 deletions rich_argparse/_patching.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>

# for internal use only
from argparse import _FormatterClass
from collections.abc import Callable
from typing import TypeVar, overload

from rich_argparse._argparse import RichHelpFormatter

_T = TypeVar("_T", bound=type)

@overload
def patch_default_formatter_class(
cls: None = None,
/,
*,
formatter_class: _FormatterClass = RichHelpFormatter,
method_name: str = "__init__",
) -> Callable[[_T], _T]: ...
@overload
def patch_default_formatter_class(
cls: _T,
/,
*,
formatter_class: _FormatterClass = RichHelpFormatter,
method_name: str = "__init__",
) -> _T: ...
39 changes: 39 additions & 0 deletions rich_argparse/django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Source code: https://github.com/hamdanal/rich-argparse
# MIT license: Copyright (c) Ali Hamdan <ali.hamdan.dev@gmail.com>
"""Django-specific utilities for rich command line help."""

from __future__ import annotations

try:
from django.core.management.base import DjangoHelpFormatter as _DjangoHelpFormatter
except ImportError as e: # pragma: no cover
raise ImportError("rich_argparse.django requires django to be installed.") from e

from rich_argparse._argparse import RichHelpFormatter as _RichHelpFormatter
from rich_argparse._patching import patch_default_formatter_class as _patch_default_formatter_class

__all__ = [
"DjangoRichHelpFormatter",
"richify_command_line_help",
]


class DjangoRichHelpFormatter(_DjangoHelpFormatter, _RichHelpFormatter):
"""A rich help formatter for django commands."""


def richify_command_line_help(
formatter_class: type[_RichHelpFormatter] = DjangoRichHelpFormatter,
) -> None:
"""Set a rich default formatter class for ``BaseCommand`` project-wide.

Calling this function affects all built-in, third-party, and user defined django commands.

Note that this function only changes the **default** formatter class of commands. User commands
can still override the default by explicitly setting a formatter class.
"""
from django.core.management.base import BaseCommand

_patch_default_formatter_class(
BaseCommand, formatter_class=formatter_class, method_name="create_parser"
)
28 changes: 28 additions & 0 deletions tests/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
RichHelpFormatter,
)
from rich_argparse._common import _fix_legacy_win_text
from rich_argparse._patching import patch_default_formatter_class
from tests.helpers import ArgumentParsers, clean_argparse, get_cmd_output


Expand Down Expand Up @@ -1085,3 +1086,30 @@ def test_metavar_spans():
\x1b[36m--op7\x1b[0m \x1b[38;5;36mMET1\x1b[0m \x1b[38;5;36mMET2\x1b[0m \x1b[38;5;36mMET3\x1b[0m
"""
assert help_text == clean_argparse(expected_help_text)


def test_patching():
class MyArgumentParser(ArgumentParser):
not_callable = None

# Patch existing class
patch_default_formatter_class(MyArgumentParser)
assert MyArgumentParser().formatter_class is RichHelpFormatter

# Override previous patch
patch_default_formatter_class(MyArgumentParser, formatter_class=MetavarTypeRichHelpFormatter)
assert MyArgumentParser().formatter_class is MetavarTypeRichHelpFormatter

# Patch new class
@patch_default_formatter_class(formatter_class=ArgumentDefaultsRichHelpFormatter)
class MyArgumentParser2(ArgumentParser):
pass

assert MyArgumentParser2().formatter_class is ArgumentDefaultsRichHelpFormatter

# Errors
with pytest.raises(AttributeError, match=r"'MyArgumentParser' has no attribute 'missing'"):
patch_default_formatter_class(MyArgumentParser, method_name="missing")

with pytest.raises(TypeError, match=r"'MyArgumentParser\.not_callable' is not callable"):
patch_default_formatter_class(MyArgumentParser, method_name="not_callable")
36 changes: 36 additions & 0 deletions tests/test_django.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from argparse import ArgumentParser, HelpFormatter
from types import ModuleType
from unittest.mock import patch

import pytest


@pytest.fixture(autouse=True)
def patch_django_import():
class DjangoHelpFormatter(HelpFormatter): ...

class BaseCommand:
def create_parser(self, *args, **kwargs):
kwargs.setdefault("formatter_class", DjangoHelpFormatter)
return ArgumentParser(*args, **kwargs)

module = ModuleType("django.core.management.base")
module.DjangoHelpFormatter = DjangoHelpFormatter
module.BaseCommand = BaseCommand
with patch.dict("sys.modules", {"django.core.management.base": module}, clear=False):
yield


def test_richify_command_line_help():
from django.core.management.base import BaseCommand, DjangoHelpFormatter

from rich_argparse.django import DjangoRichHelpFormatter, richify_command_line_help

parser = BaseCommand().create_parser("", "")
assert parser.formatter_class is DjangoHelpFormatter

richify_command_line_help()
parser = BaseCommand().create_parser("", "")
assert parser.formatter_class is DjangoRichHelpFormatter
Loading