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 validator support to condition registration and FlagStateForm #61

Merged
merged 6 commits into from
Jun 10, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
41 changes: 40 additions & 1 deletion docs/api/conditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ from flags import conditions

## Registering conditions

### `conditions.register(condition_name, fn=None)`
### `conditions.register(condition_name, fn=None, validator=None)`

Register a new condition, either as a decorator:

Expand All @@ -31,6 +31,45 @@ conditions.register('path', fn=path_condition)

Will raise a `conditions.DuplicateCondition` exception if the condition name is already registered.

A [validator](https://docs.djangoproject.com/en/stable/ref/validators/) can be given to validate the condition's expected value as provided by [the flag sources](../sources/), either as another callable as an argument to the `register` function:


```python
from flags import conditions

def validate_path(value):
if not value.startswith('/'):
raise ValidationError('Enter a valid path')

@conditions.register('path', validator=validate_path)
def path_condition(path, request=None, **kwargs):
return request.path.startswith(path)
```

Or as an attribute on the condition callable:

```python
from flags import conditions

class PathCondition:
def __call__(self, path, request=None, **kwargs):
return request.path.startswith(path)

def validate(self, value):
if not value.startswith('/'):
raise ValidationError('Enter a valid path')

conditions.register('path', fn=path_condition)
```

Validators specified in both ways are available on condition callables as
a `validate` attribute:

```python
condition = get_condition('path')
condition.validate(value)
```

## Exceptions

### `conditions.DuplicateCondition`
Expand Down
16 changes: 13 additions & 3 deletions docs/debugging.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
# Debugging

## Warnings
## System checks

<dl>
<dt>`flags.E001`</dt>
<dd>
<p>Django-Flags includes a <a href="https://docs.djangoproject.com/en/2.2/topics/checks/">Django system check</a> to check for flags that have non-existent conditions on start-up.</p>
<pre><code>?: (flags.E001) Flag FLAG_WITH_ANY_CONDITIONS has non-existent condition "condition name"
<p>Django-Flags includes a <a href="https://docs.djangoproject.com/en/stable/topics/checks/">Django system check</a> to check for flags that have non-existent conditions on start-up.</p>
<pre><code>?: (flags.E001) Flag FLAG_WITH_ANY_CONDITIONS has non-existent condition "condition name".
HINT: Register "condition name" as a Django-Flags condition.</code></pre>
</dd>
</dl>

<dl>
<dt>`flags.E002`</dt>
<dd>
<p>Django-Flags includes a <a href="https://docs.djangoproject.com/en/stable/topics/checks/">Django system check</a> to check to ensure that flag conditions have valid expected values on start-up.</p>
<pre><code>?: (flags.E002) Flag FLAG_WITH_ANY_CONDITIONS's "boolean" condition has an invalid value.
HINT: Enter one of "on", "off", "true", "false".</code></pre>
</dd>
</dl>


## Exceptions

### `RequiredForCondition`
Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Feature flags allow you to toggle functionality in both Django code and the Djan

## Dependencies

- Django 1.8+ (including Django 2.0)
- Python 2.7+, 3.6+
- Django 1.11+ (including Django 2)
- Python 3.6+

## Installation

Expand Down
2 changes: 2 additions & 0 deletions docs/releasenotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### What's new?

- Added Django 3.0 support
- Added validator support to ensure that the values that flag conditions test against are valid.
- Deprecated the optional `flags.middleware.FlagConditionsMiddleware` in favor of always lazily caching flags on the request object.

## 4.2.4
Expand Down
28 changes: 22 additions & 6 deletions flags/checks.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
from django.core.checks import Warning, register
from django.core.exceptions import ValidationError


@register()
def flag_conditions_check(app_configs, **kwargs):
from flags.sources import get_flags

error_str = 'Flag {flag} has non-existent condition "{condition}"'
error_hint = 'Register "{condition}" as a Django-Flags condition.'

errors = []

flags = get_flags(ignore_errors=True)
Expand All @@ -16,12 +14,30 @@ def flag_conditions_check(app_configs, **kwargs):
if condition.fn is None:
errors.append(
Warning(
error_str.format(
flag=name, condition=condition.condition
(
f"Flag {name} has non-existent condition "
f"'{condition.condition}'."
),
hint=(
f"Register '{condition.condition}' as a "
"Django-Flags condition."
),
hint=error_hint.format(condition=condition.condition),
id="flags.E001",
)
)
elif condition.fn.validate is not None:
try:
condition.fn.validate(condition.value)
except ValidationError as e:
errors.append(
Warning(
(
f"Flag {name}'s '{condition.condition}' "
"condition has an invalid value."
),
hint=e.message,
id="flags.E002",
)
)

return errors
25 changes: 25 additions & 0 deletions flags/conditions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# flake8: noqa
from flags.conditions.conditions import (
RequiredForCondition,
after_date_condition,
anonymous_condition,
before_date_condition,
boolean_condition,
date_condition,
parameter_condition,
path_condition,
user_condition,
)
from flags.conditions.registry import (
DuplicateCondition,
get_condition,
get_conditions,
register,
)
from flags.conditions.validators import (
validate_boolean,
validate_date,
validate_parameter,
validate_path,
validate_user,
)
73 changes: 18 additions & 55 deletions flags/conditions.py → flags/conditions/conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,79 +2,42 @@
from distutils.util import strtobool

import django
from django.contrib.auth import get_user_model
from django.utils import dateparse, timezone


# This will be maintained by register() as a global dictionary of
# condition_name: [list of functions], so we can have multiple conditions
# for a given name and they all must pass when checking.
CONDITIONS = {}


class DuplicateCondition(ValueError):
""" Raised when registering a condition that is already registered """
from flags.conditions.registry import register
from flags.conditions.validators import (
validate_boolean,
validate_date,
validate_parameter,
validate_path,
validate_user,
)


class RequiredForCondition(AttributeError):
""" Raised when a kwarg that is required for a condition is not given """


def register(condition_name, fn=None):
""" Register a condition to test for flag state. Can be decorator.
Conditions can be any callable that takes a value and some number of
required arguments (specified in 'requires') that were passed as kwargs
when checking the flag state. """
global CONDITIONS

if fn is None:
# Be a decorator
def decorator(fn):
register(condition_name, fn=fn)
return fn

return decorator

# Don't be a decorator, just register
if condition_name in CONDITIONS:
raise DuplicateCondition(
'Flag condition "{name}" already registered.'.format(
name=condition_name
)
)

CONDITIONS[condition_name] = fn


def get_conditions():
""" Return the names of all available conditions """
return CONDITIONS.keys()


def get_condition(condition_name):
""" Generator to fetch condition checkers from the registry """
if condition_name in CONDITIONS:
return CONDITIONS[condition_name]


@register("boolean")
@register("boolean", validator=validate_boolean)
def boolean_condition(condition, **kwargs):
""" Basic boolean check """
try:
return strtobool(condition.strip().lower())
return strtobool(condition.strip())
except AttributeError:
return bool(condition)


@register("user")
@register("user", validator=validate_user)
def user_condition(username, request=None, **kwargs):
""" Does request.user match the expected username? """
if request is None:
raise RequiredForCondition("request is required for condition 'user'")

return request.user.get_username() == username
return getattr(request.user, get_user_model().USERNAME_FIELD) == username


@register("anonymous")
@register("anonymous", validator=validate_boolean)
def anonymous_condition(boolean_value, request=None, **kwargs):
""" request.user an anonymous user, true or false based on boolean_value
"""
Expand All @@ -94,7 +57,7 @@ def anonymous_condition(boolean_value, request=None, **kwargs):
return bool(boolean_value) == is_anonymous


@register("parameter")
@register("parameter", validator=validate_parameter)
def parameter_condition(param_name, request=None, **kwargs):
""" Is the parameter name part of the GET parameters? """
if request is None:
Expand All @@ -109,7 +72,7 @@ def parameter_condition(param_name, request=None, **kwargs):
return request.GET.get(param_name) == param_value


@register("path matches")
@register("path matches", validator=validate_path)
def path_condition(pattern, request=None, **kwargs):
""" Does the request's path match the given regular expression? """
if request is None:
Expand All @@ -118,7 +81,7 @@ def path_condition(pattern, request=None, **kwargs):
return bool(re.search(pattern, request.path))


@register("after date")
@register("after date", validator=validate_date)
def after_date_condition(date_or_str, **kwargs):
""" Is the the current date after the given date?
date_or_str is either a date object or an ISO 8601 string """
Expand All @@ -142,7 +105,7 @@ def after_date_condition(date_or_str, **kwargs):
date_condition = after_date_condition


@register("before date")
@register("before date", validator=validate_date)
def before_date_condition(date_or_str, **kwargs):
""" Is the current date before the given date?
date_or_str is either a date object or an ISO 8601 string """
Expand Down
59 changes: 59 additions & 0 deletions flags/conditions/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# This will be maintained by register() as the global dictionary of
# condition_name: function
_conditions = {}


class DuplicateCondition(ValueError):
""" Raised when registering a condition that is already registered """


def register(condition_name, fn=None, validator=None):
""" Register a condition to test for flag state.

This function can be used as a decorator or the condition callable can be
passed as `fn`.

Validators can be passed as a separate callable, `validator`, or can be an
attribute of the condition callable, fn.validate. If `validator` is
explicitly given, it will override an existing `validate` attribute of the
condition callable.

Conditions can be any callable that takes a value and some number of
required arguments (specified in 'requires') that were passed as kwargs
when checking the flag state. """
global _conditions, _validators

if fn is None:
# Be a decorator
def decorator(fn):
register(condition_name, fn=fn, validator=validator)
return fn

return decorator

# Don't be a decorator, just register
if condition_name in _conditions:
raise DuplicateCondition(
'Flag condition "{name}" already registered.'.format(
name=condition_name
)
)

# We attach the validator to the callable to allow for both a single source
# of truth for conditions (_conditions) and to allow for validators to be
# defined on a callable class along with their condition.
if validator is not None or not hasattr(fn, "validate"):
fn.validate = validator

_conditions[condition_name] = fn


def get_conditions():
""" Return the names of all available conditions """
return _conditions.keys()


def get_condition(condition_name):
""" Fetch condition checker functions from the registry """
if condition_name in _conditions:
return _conditions[condition_name]