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

Improved Django settings handling #287

Merged
merged 8 commits into from
Aug 28, 2020
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ script:

after_success:
- |
if [ -z "$SANITY_CHECK" ]; then
if [ -z "$SANITY_CHECK" ] && [ -z "$TOXENV" ]; then
pip install coveralls
coveralls
fi
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
Changelog
=========

Version 2.3.1 (TBD)
-------------------

- Allowed configuration of the Django settings module to be used via a
commandline argument `#286 <https://github.com/PyCAQ/pylint-django/issues/286>`_
- If Django settings are not specified via a commandline argument or environment
variable, an error is issued but defaults are loaded from Django, removing the
fatal error behaviour. `#277 <https://github.com/PyCAQ/pylint-django/issues/277>`__
and `#243 <https://github.com/PyCAQ/pylint-django/issues/243>`__
- Fixed tests to work with pylint>2.6

Version 2.3.0 (05 Aug 2020)
---------------------------

Expand Down
13 changes: 12 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,21 @@ about missing Django!
Usage
-----

Ensure ``pylint-django`` is installed and on your path and then execute::

Ensure ``pylint-django`` is installed and on your path. In order to access some
of the internal Django features to improve pylint inspections, you should also
provide a Django settings module appropriate to your project. This can be done
either with an environment variable::

DJANGO_SETTINGS_MODULE=your.app.settings pylint --load-plugins pylint_django [..other options..] <path_to_your_sources>

Alternatively, this can be passed in as a commandline flag::

pylint --load-plugins pylint_django --django-settings-module=your.app.settings [..other options..] <path_to_your_sources>

If you do not configure Django, default settings will be used but this will not include, for
example, which applications to include in `INSTALLED_APPS` and so the linting and type inference
will be less accurate. It is recommended to specify a settings module.

Prospector
----------
Expand Down
2 changes: 2 additions & 0 deletions pylint_django/checkers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pylint_django.checkers.json_response import JsonResponseChecker
from pylint_django.checkers.forms import FormChecker
from pylint_django.checkers.auth_user import AuthUserChecker
from pylint_django.checkers.foreign_key_strings import ForeignKeyStringsChecker


def register_checkers(linter):
Expand All @@ -13,3 +14,4 @@ def register_checkers(linter):
linter.register_checker(JsonResponseChecker(linter))
linter.register_checker(FormChecker(linter))
linter.register_checker(AuthUserChecker(linter))
linter.register_checker(ForeignKeyStringsChecker(linter))
7 changes: 5 additions & 2 deletions pylint_django/checkers/django_installed.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ class DjangoInstalledChecker(BaseChecker):
}

@check_messages('django-not-available')
def close(self):
def open(self):
try:
__import__('django')
except ImportError:
self.add_message('F%s01' % BASE_ID)
# mild hack: this error is added before any modules have been inspected
# so we need to set some kind of value to prevent the linter failing to it
self.linter.set_current_module('pylint_django')
self.add_message('django-not-available')
142 changes: 142 additions & 0 deletions pylint_django/checkers/foreign_key_strings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from __future__ import absolute_import

import astroid
from pylint.checkers import BaseChecker
from pylint.checkers.utils import check_messages
from pylint.interfaces import IAstroidChecker

from pylint_django.__pkginfo__ import BASE_ID
from pylint_django.transforms import foreignkey


class ForeignKeyStringsChecker(BaseChecker):
"""
Adds transforms to be able to do type inference for model ForeignKeyField
properties which use a string to name the foreign relationship. This uses
Django's model name resolution and this checker wraps the setup to ensure
Django is able to configure itself before attempting to use the lookups.
"""

_LONG_MESSAGE = """Finding foreign-key relationships from strings in pylint-django requires configuring Django.
This can be done via the DJANGO_SETTINGS_MODULE environment variable or the pylint option django-settings-module, eg:

`pylint --load-plugins=pylint_django --django-settings-module=myproject.settings`

. This can also be set as an option in a .pylintrc configuration file.

Some basic default settings were used, however this will lead to less accurate linting.
Consider passing in an explicit Django configuration file to match your project to improve accuracy."""

__implements__ = (IAstroidChecker,)

name = "Django foreign keys referenced by strings"

options = (
(
"django-settings-module",
{
"default": None,
"type": "string",
"metavar": "<django settings module>",
"help": "A module containing Django settings to be used while linting.",
},
),
)

msgs = {
"E%s10"
% BASE_ID: (
"Django was not configured. For more information run"
"pylint --load-plugins=pylint_django --help-msg=django-not-configured",
"django-not-configured",
_LONG_MESSAGE,
),
"F%s10"
% BASE_ID: (
"Provided Django settings %s could not be loaded",
"django-settings-module-not-found",
"The provided Django settings module %s was not found on the path",
),
}

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._raise_warning = False

def open(self):
# This is a bit of a hacky workaround. pylint-django does not *require* that
# Django is configured explicitly, and will use some basic defaults in that
# case. However, as this is a WARNING not a FATAL, the error must be raised
# with an AST node - only F and R messages are scope exempt (see
# https://github.com/PyCQA/pylint/blob/master/pylint/constants.py#L24)

# However, testing to see if Django is configured happens in `open()`
# before any modules are inspected, as Django needs to be configured with
# defaults before the foreignkey checker can work properly. At this point,
# there are no nodes.

# Therefore, during the initialisation in `open()`, if django was configured
# using defaults by pylint-django, it cannot raise the warning yet and
# must wait until some module is inspected to be able to raise... so that
# state is stashed in this property.

from django.core.exceptions import ( # pylint: disable=import-outside-toplevel
ImproperlyConfigured,
)

try:
import django # pylint: disable=import-outside-toplevel

django.setup()
from django.apps import apps # noqa pylint: disable=import-outside-toplevel,unused-import
except ImproperlyConfigured:
# this means that Django wasn't able to configure itself using some defaults
# provided (likely in a DJANGO_SETTINGS_MODULE environment variable)
# so see if the user has specified a pylint option
if self.config.django_settings_module is None:
# we will warn the user that they haven't actually configured Django themselves
self._raise_warning = True
# but use django defaults then...
from django.conf import ( # pylint: disable=import-outside-toplevel
settings,
)
settings.configure()
django.setup()
else:
# see if we can load the provided settings module
try:
from django.conf import ( # pylint: disable=import-outside-toplevel
settings,
Settings,
)

settings.configure(Settings(self.config.django_settings_module))
django.setup()
except ImportError:
# we could not find the provided settings module...
# at least here it is a fatal error so we can just raise this immediately
self.add_message(
"django-settings-module-not-found",
args=self.config.django_settings_module,
)
# however we'll trundle on with basic settings
from django.conf import ( # pylint: disable=import-outside-toplevel
settings,
)

settings.configure()
django.setup()

# now we can add the trasforms specifc to this checker
foreignkey.add_transform(astroid.MANAGER)

# TODO: this is a bit messy having so many inline imports but in order to avoid
# duplicating the django_installed checker, it'll do for now. In the future, merging
# those two checkers together might make sense.

@check_messages("django-not-configured")
def visit_module(self, node):
if self._raise_warning:
# just add it to the first node we see... which isn't nice but not sure what else to do
self.add_message("django-not-configured", node=node)
self._raise_warning = False # only raise it once...
2 changes: 1 addition & 1 deletion pylint_django/checkers/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def func(apps, schema_editor):
return is_migrations_module(node.parent)


def load_configuration(linter):
def load_configuration(linter): # TODO this is redundant and can be removed
Copy link
Contributor

Choose a reason for hiding this comment

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

+1 for adding the TODOs as separate issues so we don't forget about them. Interestingly enough the pylint test job in Travis should be catching this.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed - I had initially not bothered as I had planned to immediately address them in a new PR but I got distracted / didn't get time. Therefore I've made #289 and #288

# don't blacklist migrations for this checker
new_black_list = list(linter.config.black_list)
if 'migrations' in new_black_list:
Expand Down
15 changes: 11 additions & 4 deletions pylint_django/transforms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
"""Transforms."""
"""
These transforms replace the Django types with adapted versions to provide
additional typing and method inference to pylint. All of these transforms
are considered "global" to pylint-django, in that all checks and improvements
requre them to be loaded. Additional transforms specific to checkers are loaded
by the checker rather than here.

For example, the ForeignKeyStringsChecker loads the foreignkey.py transforms
itself as it may be disabled independently of the rest of pylint-django
"""
import os
import re

import astroid

from pylint_django.transforms import foreignkey, fields
from pylint_django.transforms import fields


foreignkey.add_transform(astroid.MANAGER)
Copy link
Contributor

Choose a reason for hiding this comment

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

Could benefit from a comment either here or on the doc-string text and/or the foreignkey module to explicitly state why foreignkey.add_transform() isn't called like for the other modules.

Copy link
Member Author

Choose a reason for hiding this comment

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

There is a module-level docstring explaining this already which I think is sufficient: https://github.com/PyCQA/pylint-django/blob/feature/improve-django-settings-handling/pylint_django/transforms/__init__.py#L1

Copy link
Member Author

Choose a reason for hiding this comment

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

I also added this issue as a discussion for moving all transforms/augmentations into a checker rather than being loaded immediately by the plugin: #290

fields.add_transforms(astroid.MANAGER)


Expand Down
4 changes: 2 additions & 2 deletions pylint_django/transforms/foreignkey.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@ def infer_key_classes(node, context=None):
# 'auth.models', 'User' which works nicely with the `endswith()`
# comparison below
module_name += '.models'
except ImproperlyConfigured:
except ImproperlyConfigured as exep:
raise RuntimeError("DJANGO_SETTINGS_MODULE required for resolving ForeignKey "
"string references, see Usage section in README at "
"https://pypi.org/project/pylint-django/!")
"https://pypi.org/project/pylint-django/!") from exep
carlio marked this conversation as resolved.
Show resolved Hide resolved

# ensure that module is loaded in astroid_cache, for cases when models is a package
if module_name not in MANAGER.astroid_cache:
Expand Down
2 changes: 1 addition & 1 deletion pylint_django/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from pylint_django.compat import Uninferable

PY3 = sys.version_info >= (3, 0)
PY3 = sys.version_info >= (3, 0) # TODO: pylint_django doesn't support Py2 any more


def node_is_subclass(cls, *subclass_names):
Expand Down
2 changes: 1 addition & 1 deletion scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ echo "..... Trying to install the new tarball inside a virtualenv"
# note: installs with the optional dependency to verify this is still working
virtualenv .venv/test-tarball
source .venv/test-tarball/bin/activate
pip install --no-binary :all: -f dist/ pylint_django[with_django]
pip install -f dist/ pylint_django[with_django]
Copy link
Contributor

Choose a reason for hiding this comment

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

The reason for this line & the reason why it is failing is the same - it tries installing pylint-django and all of its dependencies from tartballs. One of them is either new or stopped shipping tarballs, and only has wheel packages. See 10 lines below for how we deal with another package which doesn't ship wheels.

My preferred way for dealing with this is to find which package is the one not providing tarballs and

  1. Open an issue against it
  2. Install it explicitly by itself.

Copy link
Member Author

Choose a reason for hiding this comment

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

For now I'll accept this in our own CI while I track down which dependency is the guilty party - I am fairly sure it's clikit and I'll raise a PR/issue there if that's true or as you suggest, explicitly install it ourselves.

Copy link
Member Author

Choose a reason for hiding this comment

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

I created a new issue to separate this out: #291

pip freeze | grep Django
deactivate
rm -rf .venv/
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ envlist =
[testenv]
commands =
django_not_installed: bash -c \'pylint --load-plugins=pylint_django setup.py | grep django-not-available\'
django_is_installed: pylint --load-plugins=pylint_django setup.py
django_is_installed: pylint --load-plugins=pylint_django --disable=E5110 setup.py
flake8: flake8
pylint: pylint --rcfile=tox.ini -d missing-docstring,too-many-branches,too-many-return-statements,too-many-ancestors,fixme --ignore=tests pylint_django setup
readme: bash -c \'python setup.py -q sdist && twine check dist/*\'
Expand Down