Skip to content

Commit

Permalink
Merge 9a351cf into 720ba98
Browse files Browse the repository at this point in the history
  • Loading branch information
willbarton committed May 6, 2020
2 parents 720ba98 + 9a351cf commit 148ff78
Show file tree
Hide file tree
Showing 21 changed files with 413 additions and 122 deletions.
8 changes: 5 additions & 3 deletions docs/api/conditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ 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:

```python
from flags import conditions

@conditions.register('path')
@conditions.register('path', validator=conditions.validate_path)
def path_condition(path, request=None, **kwargs):
return request.path.startswith(path)
```
Expand All @@ -26,11 +26,13 @@ Or as a function call:
def path_condition(path, request=None, **kwargs):
return request.path.startswith(path)

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

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/).

## 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
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.validator is not None:
try:
condition.validator(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
26 changes: 26 additions & 0 deletions flags/conditions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 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_condition_validator,
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
52 changes: 52 additions & 0 deletions flags/conditions/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# These will be maintained by register() as a global dictionary of
# condition_name: function/validator_function
_conditions = {}
_validators = {}


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. 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, _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
)
)

_conditions[condition_name] = fn
_validators[condition_name] = validator


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]


def get_condition_validator(condition_name):
""" Fetch condition validators from the registry """
if condition_name in _validators:
return _validators[condition_name]
51 changes: 51 additions & 0 deletions flags/conditions/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import re
from distutils.util import strtobool

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils import dateparse


validate_path = RegexValidator(
re.compile(r"^[^\s:?#]+$", re.UNICODE),
message=(
"Enter a valid path without a URL scheme, query string, or fragment."
),
code="invalid",
)


validate_parameter = RegexValidator(
re.compile(r"^[-_\w=]+$", re.UNICODE),
message="Enter a valid HTTP parameter name.",
code="invalid",
)


def validate_boolean(value):
message = "Enter one of 'on', 'off', 'true', 'false', etc."
try:
strtobool(value)
except ValueError:
# This was a string with an invalid boolean value
raise ValidationError(message)
except AttributeError:
# This was not a string
if not isinstance(value, (int, bool)):
raise ValidationError(message)


def validate_user(value):
UserModel = get_user_model()

try:
UserModel.objects.get(**{UserModel.USERNAME_FIELD: value})
except UserModel.DoesNotExist:
raise ValidationError("Enter the username of a valid user.")


def validate_date(value):
datetime = dateparse.parse_datetime(value)
if datetime is None:
raise ValidationError("Enter an ISO 8601 date representation.")

0 comments on commit 148ff78

Please sign in to comment.