Skip to content

Commit

Permalink
Add CLI command to list all codemodders
Browse files Browse the repository at this point in the history
Codemodders are listed as a table using the rich package
  • Loading branch information
browniebroke committed Jul 26, 2020
1 parent 93e6e57 commit 8b36238
Show file tree
Hide file tree
Showing 22 changed files with 226 additions and 94 deletions.
1 change: 1 addition & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
jobs:
lint:
strategy:
fail-fast: false
matrix:
linter:
- name: Flake8
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ Let's say you just updated to Django 3.0, and suddenly you're flooded with depre
You want to resolve them to avoid missing another important warning. You can do so by running the following command from the root of your repo:

```bash
djcodemod --deprecated-in 3.0 .
djcodemod run --deprecated-in 3.0 .
```

**2. Removals**

This is more a just in time operation, assuming you haven't kept up to date with deprecation warnings, and right before upgrading to a given version (let's assume Django 4.0). In this case, you should be running:

```bash
djcodemod --removed-in 4.0 .
djcodemod run --removed-in 4.0 .
```

### What happens
Expand Down
110 changes: 79 additions & 31 deletions django_codemod/cli.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,55 @@
import inspect
from collections import defaultdict
from operator import attrgetter
from typing import Callable, Dict, List, Tuple
from typing import Callable, Dict, Generator, List, Tuple, Type

import click
from libcst.codemod import (
CodemodContext,
ContextAwareTransformer,
gather_files,
parallel_exec_transform_with_prettyprint,
)
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table

from django_codemod import visitors
from django_codemod.commands import BaseCodemodCommand
from django_codemod.visitors.base import BaseDjCodemodTransformer


def find_codemoders(version_getter: Callable) -> Dict[Tuple[int, int], List]:
def index_codemoders(version_getter: Callable) -> Dict[Tuple[int, int], List]:
"""
Find codemodders and index them by version.
Index codemodders by version.
The returned entity is a dictionary which keys are 2-tuples
for each major versions of Django with codemodders mapping
to a list of codemodders which are flagged as either `removed_in`
or `deprecated_in` that version.
Build a map of Django version to list of codemodders.
"""
codemodders_index = defaultdict(list)
for obj in iter_codemodders():
django_version = version_getter(obj)
codemodders_index[django_version].append(obj)
return dict(codemodders_index)


def iter_codemodders() -> Generator[BaseDjCodemodTransformer, None, None]:
"""Iterator of all the codemodders classes."""
for object_name in dir(visitors):
try:
obj = getattr(visitors, object_name)
if (
obj is ContextAwareTransformer
or not issubclass(obj, ContextAwareTransformer)
or inspect.isabstract(obj)
):
if not issubclass(obj, BaseDjCodemodTransformer) or inspect.isabstract(obj):
continue
# Looks like this one is good to go
django_version = version_getter(obj)
codemodders_index[django_version].append(obj)
yield obj
except TypeError:
continue
return dict(codemodders_index)


DEPRECATED_IN = find_codemoders(version_getter=attrgetter("deprecated_in"))
REMOVED_IN = find_codemoders(version_getter=attrgetter("removed_in"))
DEPRECATED_IN = index_codemoders(version_getter=attrgetter("deprecated_in"))
REMOVED_IN = index_codemoders(version_getter=attrgetter("removed_in"))


class VersionParamType(click.ParamType):
"""A type of parameter to parse Versions as arguments."""
"""A type of parameter to parse version as arguments."""

name = "version"
example = (
Expand Down Expand Up @@ -92,7 +94,12 @@ def _split_digits(self, value, param, ctx):
return (major, minor)


@click.command()
@click.group()
def djcodemod():
"""CLI entry point."""


@djcodemod.command()
@click.argument("path")
@click.option(
"--removed-in",
Expand All @@ -106,7 +113,7 @@ def _split_digits(self, value, param, ctx):
help="The version of Django where deprecations started.",
type=VersionParamType(DEPRECATED_IN),
)
def djcodemod(removed_in, deprecated_in, path):
def run(removed_in: Tuple[int, int], deprecated_in: Tuple[int, int], path: str) -> None:
"""
Automatically fixes deprecations removed Django deprecations.
Expand All @@ -121,19 +128,10 @@ def djcodemod(removed_in, deprecated_in, path):
codemodders_list = REMOVED_IN[removed_in]
else:
codemodders_list = DEPRECATED_IN[deprecated_in]
command_instance = build_command(codemodders_list)
command_instance = BaseCodemodCommand(codemodders_list, CodemodContext())
call_command(command_instance, path)


def build_command(codemodders_list: List) -> BaseCodemodCommand:
"""Build a custom command with the list of visitors."""

class CustomCommand(BaseCodemodCommand):
transformers = codemodders_list

return CustomCommand(CodemodContext())


def call_command(command_instance: BaseCodemodCommand, path: str):
"""Call libCST with our customized command."""
files = gather_files([path])
Expand All @@ -157,3 +155,53 @@ def call_command(command_instance: BaseCodemodCommand, path: str):
click.echo(f" - {result.warnings} warnings were generated.")
if result.failures > 0:
raise click.exceptions.Exit(1)


@djcodemod.command()
def list() -> None:
"""Print all available codemodders as a table."""
console = Console()
table = Table(show_header=True, header_style="bold")
# Headers
table.add_column("Codemodder")
table.add_column("Deprecated in", justify="right")
table.add_column("Removed in", justify="right")
table.add_column("Description")
# Content
prev_version = None
for name, deprecated_in, removed_in, description in generate_rows():
if prev_version and prev_version != (deprecated_in, removed_in):
table.add_row()
table.add_row(name, deprecated_in, removed_in, Markdown(description))
prev_version = (deprecated_in, removed_in)
# Print it out
console.print(table)


def generate_rows() -> Generator[Tuple[str, str, str, str], None, None]:
"""Build up the rows for the table of codemodders."""
codemodders_list = sorted(
iter_codemodders(), key=lambda obj: (obj.deprecated_in, obj.removed_in)
)
for codemodder in codemodders_list:
yield (
codemodder.__name__,
version_str(codemodder.deprecated_in),
version_str(codemodder.removed_in),
get_short_description(codemodder),
)


def get_short_description(codemodder: Type) -> str:
"""Get a one line description of the codemodder from its docstring."""
if codemodder.__doc__ is None:
return ""
for line in codemodder.__doc__.split("\n"):
description = line.strip()
if description:
return description


def version_str(version_parts: Tuple[int, int]) -> str:
"""Format the version tuple as string."""
return ".".join(str(d) for d in version_parts)
10 changes: 9 additions & 1 deletion django_codemod/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@
from typing import List, Type

import libcst as cst
from libcst.codemod import ContextAwareTransformer, VisitorBasedCodemodCommand
from libcst.codemod import (
CodemodContext,
ContextAwareTransformer,
VisitorBasedCodemodCommand,
)


class BaseCodemodCommand(VisitorBasedCodemodCommand, ABC):
"""Base class for our commands."""

transformers: List[Type[ContextAwareTransformer]]

def __init__(self, transformers, context: CodemodContext) -> None:
self.transformers = transformers
super().__init__(context)

def transform_module_impl(self, tree: cst.Module) -> cst.Module:
for transform in self.transformers:
inst = transform(self.context)
Expand Down
7 changes: 3 additions & 4 deletions django_codemod/visitors/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,13 @@
RemovalSentinel,
)
from libcst import matchers as m
from libcst.codemod import ContextAwareTransformer

from django_codemod.constants import DJANGO_2_1, DJANGO_3_0
from django_codemod.visitors.base import module_matcher
from django_codemod.visitors.base import BaseDjCodemodTransformer, module_matcher


class InlineHasAddPermissionsTransformer(ContextAwareTransformer):
"""Add the ``obj`` argument to ``InlineModelAdmin.has_add_permission()``."""
class InlineHasAddPermissionsTransformer(BaseDjCodemodTransformer):
"""Add the `obj` argument to `InlineModelAdmin.has_add_permission()`."""

deprecated_in = DJANGO_2_1
removed_in = DJANGO_3_0
Expand Down
9 changes: 7 additions & 2 deletions django_codemod/visitors/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Module to implement base functionality."""
from abc import ABC
from typing import Generator, Optional, Sequence, Union
from typing import Generator, Optional, Sequence, Tuple, Union

from libcst import (
Arg,
Expand All @@ -18,6 +18,11 @@
from libcst.codemod.visitors import AddImportsVisitor


class BaseDjCodemodTransformer(ContextAwareTransformer, ABC):
deprecated_in: Tuple[int, int]
removed_in: Tuple[int, int]


def module_matcher(import_parts):
*values, attr = import_parts
if len(values) > 1:
Expand All @@ -33,7 +38,7 @@ def import_from_matches(node, module_parts):
return m.matches(node, m.ImportFrom(module=module_matcher(module_parts)))


class BaseRenameTransformer(ContextAwareTransformer, ABC):
class BaseRenameTransformer(BaseDjCodemodTransformer, ABC):
"""Base class to help rename or move a declaration."""

rename_from: str
Expand Down
2 changes: 1 addition & 1 deletion django_codemod/visitors/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


class URLResolversTransformer(BaseModuleRenameTransformer):
"""Resolve deprecation of ``django.core.urlresolvers``."""
"""Replace `django.core.urlresolvers` by `django.urls`."""

deprecated_in = DJANGO_1_10
removed_in = DJANGO_2_0
Expand Down
4 changes: 2 additions & 2 deletions django_codemod/visitors/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


class ContextDecoratorTransformer(BaseRenameTransformer):
"""Resolve deprecation of ``django.utils.decorators.ContextDecorator``."""
"""Replace Django's `ContextDecorator` decorator by the `contextlib`'s one."""

deprecated_in = DJANGO_2_0
removed_in = DJANGO_3_0
Expand All @@ -15,7 +15,7 @@ class ContextDecoratorTransformer(BaseRenameTransformer):


class AvailableAttrsTransformer(BaseRenameTransformer):
"""Resolve deprecation of ``django.utils.decorators.available_attrs``."""
"""Replace `django.utils.decorators.available_attrs` by `WRAPPER_ASSIGNMENTS`."""

deprecated_in = DJANGO_2_0
removed_in = DJANGO_3_0
Expand Down
6 changes: 3 additions & 3 deletions django_codemod/visitors/encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


class ForceTextTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.encoding.force_text``."""
"""Replace `django.utils.encoding.force_text` by `force_str`."""

deprecated_in = DJANGO_3_0
removed_in = DJANGO_4_0
Expand All @@ -12,7 +12,7 @@ class ForceTextTransformer(BaseFuncRenameTransformer):


class SmartTextTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.encoding.smart_text``."""
"""Replace `django.utils.encoding.smart_text` by `smart_str`."""

deprecated_in = DJANGO_3_0
removed_in = DJANGO_4_0
Expand All @@ -21,7 +21,7 @@ class SmartTextTransformer(BaseFuncRenameTransformer):


class UnicodeCompatibleTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.encoding.python_2_unicode_compatible``."""
"""Replace Django's `python_2_unicode_compatible` by the one from `six`."""

deprecated_in = DJANGO_2_0
removed_in = DJANGO_3_0
Expand Down
2 changes: 1 addition & 1 deletion django_codemod/visitors/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


class UnescapeEntitiesTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.text.unescape_entities``."""
"""Replace `django.utils.text.unescape_entities` by `html.unescape`."""

deprecated_in = DJANGO_3_0
removed_in = DJANGO_4_0
Expand Down
10 changes: 5 additions & 5 deletions django_codemod/visitors/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


class HttpUrlQuoteTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.http.urlquote``."""
"""Replace `django.utils.http.urlquote` by `urllib.parse.quote`."""

deprecated_in = DJANGO_3_0
removed_in = DJANGO_4_0
Expand All @@ -12,7 +12,7 @@ class HttpUrlQuoteTransformer(BaseFuncRenameTransformer):


class HttpUrlQuotePlusTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.http.urlquote_plus``."""
"""Replace `django.utils.http.urlquote_plus` by `urllib.parse.quote_plus`."""

deprecated_in = DJANGO_3_0
removed_in = DJANGO_4_0
Expand All @@ -21,7 +21,7 @@ class HttpUrlQuotePlusTransformer(BaseFuncRenameTransformer):


class HttpUrlUnQuoteTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.http.urlunquote``."""
"""Replace `django.utils.http.urlunquote` by `urllib.parse.unquote`."""

deprecated_in = DJANGO_3_0
removed_in = DJANGO_4_0
Expand All @@ -30,7 +30,7 @@ class HttpUrlUnQuoteTransformer(BaseFuncRenameTransformer):


class HttpUrlUnQuotePlusTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.http.urlunquote_plus``."""
"""Replace `django.utils.http.urlunquote_plus` by `urllib.parse.unquote_plus`."""

deprecated_in = DJANGO_3_0
removed_in = DJANGO_4_0
Expand All @@ -39,7 +39,7 @@ class HttpUrlUnQuotePlusTransformer(BaseFuncRenameTransformer):


class IsSafeUrlTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.http.is_safe_url``."""
"""Rename `django.utils.http.is_safe_url` to `url_has_allowed_host_and_scheme`."""

deprecated_in = DJANGO_3_0
removed_in = DJANGO_4_0
Expand Down
2 changes: 1 addition & 1 deletion django_codemod/visitors/lru_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


class LRUCacheTransformer(BaseFuncRenameTransformer):
"""Resolve deprecation of ``django.utils.lru_cache.lru_cache``."""
"""Replace `django.utils.lru_cache.lru_cache` by `functools.lru_cache`."""

deprecated_in = DJANGO_2_0
removed_in = DJANGO_3_0
Expand Down
Loading

0 comments on commit 8b36238

Please sign in to comment.