Skip to content

Commit

Permalink
feat: custom field name converters
Browse files Browse the repository at this point in the history
  • Loading branch information
hodossy committed Dec 18, 2020
1 parent ee81059 commit 71359fd
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 39 deletions.
93 changes: 57 additions & 36 deletions django_nlf/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,75 +3,96 @@
"""
import warnings

try:
from django.conf import settings as dj_settings
from django.core.signals import setting_changed
except ImportError:
dj_settings = object()
setting_changed = None
from django.conf import settings as dj_settings
from django.core.signals import setting_changed
from django.utils.module_loading import import_string


DEFAULTS = {
"EMPTY_VALUE": "EMPTY",
"FALSE_VALUES": ("0", "f"),
"FIELD_SHORTCUTS": {},
"PATH_SEPARATOR": ".",
"QUERY_PARAM": "q",
}
def perform_import(val, setting_name):
"""
If the given setting is a string import notation,
then perform the necessary import or imports.
"""
if val is None:
return None

if isinstance(val, str):
return import_from_string(val, setting_name)

DEPRECATED_SETTINGS = []
if isinstance(val, (list, tuple)):
return [import_from_string(item, setting_name) for item in val]

return val


def import_from_string(val, setting_name):
"""
Attempt to import a class from a string representation.
"""
try:
return import_string(val)
except ImportError as e:
msg = "Could not import '%s' for NLF setting '%s'. %s: %s." % (
val,
setting_name,
e.__class__.__name__,
e,
)
raise ImportError(msg) from e


def deprecate(msg, level_modifier=0):
warnings.warn(msg, DeprecationWarning, stacklevel=3 + level_modifier)


def is_callable(value):
# check for callables, except types
return callable(value) and not isinstance(value, type)
class NLFSettings:
DEFAULTS = {
"EMPTY_VALUE": "EMPTY",
"FALSE_VALUES": ("0", "f"),
"FIELD_NAME_CONVERTER": None,
"FIELD_SHORTCUTS": {},
"PATH_SEPARATOR": ".",
"QUERY_PARAM": "q",
}

DEPRECATED_SETTINGS = []

# List of settings that may be in string import notation.
IMPORT_STRINGS = ["FIELD_NAME_CONVERTER"]

class Settings:
def __getattr__(self, name):
if name not in DEFAULTS:
if name not in self.DEFAULTS:
msg = "'%s' object has no attribute '%s'"
raise AttributeError(msg % (self.__class__.__name__, name))

value = self.get_setting(name)

if is_callable(value):
value = value()

# Cache the result
setattr(self, name, value)
return value

def get_setting(self, setting):
django_setting = f"NLF_{setting}"

if setting in DEPRECATED_SETTINGS and hasattr(dj_settings, django_setting):
if setting in self.DEPRECATED_SETTINGS:
deprecate(f"The '{django_setting}' setting has been deprecated.")

return getattr(dj_settings, django_setting, DEFAULTS[setting])
val = getattr(dj_settings, django_setting, self.DEFAULTS[setting])

if setting in self.IMPORT_STRINGS:
val = perform_import(val, setting)

return val

def change_setting(self, setting, value, enter, **kwargs):
if not setting.startswith("NLF_"):
return
setting = setting[4:] # strip 'NLF_'

# ensure a valid app setting is being overridden
if setting not in DEFAULTS:
return

# if exiting, delete value to repopulate
if enter:
setattr(self, setting, value)
else:
# if existing, delete value to repopulate
if hasattr(self, setting):
delattr(self, setting)


nlf_settings = Settings()

if setting_changed is not None:
setting_changed.connect(nlf_settings.change_setting)
nlf_settings = NLFSettings()
setting_changed.connect(nlf_settings.change_setting)
8 changes: 7 additions & 1 deletion django_nlf/filters/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,20 @@ def resolve_shortcut(self, field_name: str) -> str:
return field_name

def normalize_field_name(self, field_name: str) -> str:
"""Normalizes field name. By default it replaces PATH_SEPARATOR characters with Django's LOOKUP_SEP.
"""Normalizes field name. First it resolves and shortcuts and then applies the conversion,
then it replaces PATH_SEPARATOR characters with Django's LOOKUP_SEP.
:param str field_name: The name of the field.
:return: Normalized field name.
:rtype: str
"""
self.orig_field_name = field_name
field_name = self.resolve_shortcut(field_name)

converter = nlf_settings.FIELD_NAME_CONVERTER
if converter and callable(converter):
field_name = converter(field_name)

return field_name.replace(self.path_sep, LOOKUP_SEP)

def normalize_value(
Expand Down
14 changes: 12 additions & 2 deletions django_nlf/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ def coerce_bool(value):
"""Coerces any value to a boolean. String values are checked for the first letter
and matched against the `FALSE_VALUES` setting.
"""
if isinstance(value, str):
if isinstance(value, str) and len(value) > 0:
return value[0].lower() not in nlf_settings.FALSE_VALUES
return value
return bool(value)


def camel_to_snake_case(value: str) -> str:
"""Converts strings in camelCase to snake_case.
:param str value: camalCase value.
:return: snake_case value.
:rtype: str
"""
return "".join(["_" + char.lower() if char.isupper() else char for char in value]).lstrip("_")
9 changes: 9 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ Default: ``("0", "f")``

Used in boolean coercion to determine the boolean value of a string. If the first character of the value coerced to boolean matches any listed character, the value is considered ``False``, otherwise ``True``.

NLF_FIELD_NAME_CONVERTER
************************

Default: ``None``

A function or an import path to a function that applies a conversion to the field name. Can be used to automatically convert between cases, e.g. *camelCase* to *snake_case*.

One such converter function is readily available as ``django_nlf.utils.camel_to_snake_case``.

NLF_FIELD_SHORTCUTS
*******************

Expand Down
17 changes: 17 additions & 0 deletions docs/customizations/field_name_conversion.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Converting field names
======================

To support automatic case conversion, a custom implementation can be provided.

.. code-block:: python
# app/utils.py
def my_converter(field_name: str) -> str:
# do something with field_name
return field_name
and in ``settings.py``:

.. code-block:: python
NLF_FIELD_NAME_CONVERTER = "app.utils.my_converter"
1 change: 1 addition & 0 deletions docs/customizations/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Customization
:maxdepth: 1

custom_functions.rst
field_name_conversion.rst
58 changes: 58 additions & 0 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from unittest import TestCase

from django.test import override_settings

from django_nlf.conf import NLFSettings


class NLFSettingsTestCase(TestCase):
def setUp(self):
self.nlf_settings = NLFSettings()

def test_retrieve_default(self):
self.assertEqual(self.nlf_settings.EMPTY_VALUE, self.nlf_settings.DEFAULTS["EMPTY_VALUE"])

@override_settings(NLF_EMPTY_VALUE="FOO")
def test_retrieve_custom(self):
self.assertEqual(self.nlf_settings.EMPTY_VALUE, "FOO")

def test_invalid_setting(self):
with self.assertRaises(AttributeError):
self.nlf_settings.NON_EXISTING_SETTING # pylint: disable=pointless-statement

@override_settings(NLF_EMPTY_VALUE="FOO")
def test_deprecated_setting(self):
self.nlf_settings.DEPRECATED_SETTINGS = ["EMPTY_VALUE"]
with self.assertWarns(DeprecationWarning):
self.nlf_settings.EMPTY_VALUE # pylint: disable=pointless-statement

@override_settings(NLF_EMPTY_VALUE="django_nlf.utils.coerce_bool")
def test_import_string(self):
self.nlf_settings.IMPORT_STRINGS = ("EMPTY_VALUE")
self.assertTrue(callable(self.nlf_settings.EMPTY_VALUE))

@override_settings(NLF_EMPTY_VALUE=("django_nlf.utils.coerce_bool", ))
def test_import_list(self):
self.nlf_settings.IMPORT_STRINGS = ["EMPTY_VALUE"]
self.assertEqual(len(self.nlf_settings.EMPTY_VALUE), 1)
self.assertTrue(callable(self.nlf_settings.EMPTY_VALUE[0]))

@override_settings(NLF_EMPTY_VALUE=True)
def test_import_other(self):
self.nlf_settings.IMPORT_STRINGS = ["EMPTY_VALUE"]
self.assertEqual(self.nlf_settings.EMPTY_VALUE, True)

@override_settings(NLF_EMPTY_VALUE="my.custom.module.function")
def test_import_fail(self):
self.nlf_settings.IMPORT_STRINGS = ["EMPTY_VALUE"]
with self.assertRaises(ImportError):
self.nlf_settings.EMPTY_VALUE # pylint: disable=pointless-statement

def test_settings_changed(self):
self.nlf_settings.EMPTY_VALUE # pylint: disable=pointless-statement
self.assertTrue("EMPTY_VALUE" in self.nlf_settings.__dict__)
self.nlf_settings.change_setting("RANDOM_SETTING", "FOO", True)
self.nlf_settings.change_setting("NLF_RANDOM_SETTING", "FOO", True)
self.assertTrue("EMPTY_VALUE" in self.nlf_settings.__dict__)
self.nlf_settings.change_setting("NLF_EMPTY_VALUE", "FOO", True)
self.assertFalse("EMPTY_VALUE" in self.nlf_settings.__dict__)
11 changes: 11 additions & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from django.test import override_settings

from .models import Article, Publication
from .utils import BaseTestCase

Expand Down Expand Up @@ -156,3 +158,12 @@ def test_generic_shortcuts(self):
qs = self.nl_filter.filter(Publication.objects.all(), filter_expr)
self.assertEqual(qs.count(), 2)
self.assertListEqual(list(qs.all()), [self.p2, self.p1])


class DjangoNLFilterConverterTestCase(BaseTestCase):
@override_settings(NLF_FIELD_NAME_CONVERTER="django_nlf.utils.camel_to_snake_case")
def test_camel_case_fields(self):
filter_expr = "createdAt > 2016-05-01"
qs = self.nl_filter.filter(Article.objects.all(), filter_expr)
self.assertEqual(qs.count(), 1)
self.assertEqual(qs.first(), self.a4)
5 changes: 5 additions & 0 deletions tests/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ def test_function_as_field_on_wrong_model(self):
with self.assertRaises(ValueError):
self.nl_filter.filter(Article.objects.all(), filter_expr)

def test_function_wrong_role(self):
filter_expr = "market_share < totalViews()"
with self.assertRaises(ValueError):
self.nl_filter.filter(Publication.objects.all(), filter_expr)

def test_function_as_expression_no_params(self):
filter_expr = "hasBeenPublished()"
qs = self.nl_filter.filter(Article.objects.all(), filter_expr)
Expand Down
35 changes: 35 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from unittest import TestCase

from django.test import override_settings

from django_nlf.utils import camel_to_snake_case, coerce_bool


class CamelToSnakeCaseTestCase(TestCase):
def test_conversion(self):
self.assertEqual(camel_to_snake_case("fooBar"), "foo_bar")
self.assertEqual(camel_to_snake_case("_fooBar_"), "foo_bar_")
self.assertEqual(camel_to_snake_case("HTTPRequest"), "h_t_t_p_request")


class CoerceBoolTestCase(TestCase):
def test_with_default_settings(self):
self.assertTrue(coerce_bool("true"))
self.assertTrue(coerce_bool("a"))
self.assertTrue(coerce_bool(True))
self.assertTrue(coerce_bool(1))

self.assertFalse(coerce_bool("False"))
self.assertFalse(coerce_bool("f"))
self.assertFalse(coerce_bool("0"))
self.assertFalse(coerce_bool(""))
self.assertFalse(coerce_bool(0))
self.assertFalse(coerce_bool({}))

@override_settings(NLF_FALSE_VALUES=("a", "b", "c"))
def test_with_other_settings(self):
self.assertTrue(coerce_bool("f"))

self.assertFalse(coerce_bool("aasd"))
self.assertFalse(coerce_bool("bar"))
self.assertFalse(coerce_bool("co2"))

0 comments on commit 71359fd

Please sign in to comment.