Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: jazzband/django-waffle
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v3.0.0
Choose a base ref
...
head repository: jazzband/django-waffle
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref

Commits on Sep 1, 2022

  1. Copy the full SHA
    f863503 View commit details

Commits on Sep 7, 2022

  1. Added python_requires and install_requires to help pip (#466)

    Without `python_requires`, pip will let you install django-waffle 3.0 on python 3.6 and lower, which are now unsupported. And django-waffle should depend on django, because based on the name of the project, it does, so adding `install_requires` to reflect that.
    gopackgo90 authored Sep 7, 2022
    Copy the full SHA
    d1b2a65 View commit details

Commits on Sep 16, 2022

  1. Reduced linting issues (#468)

    These changes reduce the amount of linting issues that are silenced by the CI configuration.
    
    * Remove `waffle/south_migrations/` folder from `flake8` exclude list,
      as it no longer exists.
    * Migrate unused `waffle/.flake` file content to `setup.cfg`.
    * Fix simple linting issues that do not generate side effects.
    adamantike authored Sep 16, 2022
    Copy the full SHA
    1f512f0 View commit details
  2. Use admin action decorator (#467)

    Django 3.2 introduced the `@admin.action` decorator [1], which simplifies adding permissions and description to admin actions.
    
    As the project requires Django 3.2 at least, since version `3.0.0`, this solution does not require an alternative approach for older Django versions.
    
    [1] https://docs.djangoproject.com/en/3.2/ref/contrib/admin/actions/#the-action-decorator
    adamantike authored Sep 16, 2022
    Copy the full SHA
    b035ac6 View commit details

Commits on Sep 17, 2022

  1. misc: Add initial support for Mypy checks (#469)

    This is the initial change to start adding typing hints to the project.
    It includes:
    
    * Adding `mypy` checks to GitHub actions, and Tox.
    * Adding `mypy` command to `run.sh` script.
    * Initial `mypy` configuration options in `setup.cfg`.
    * Small fixes for existing typing issues.
    adamantike authored Sep 17, 2022
    Copy the full SHA
    25dbbe2 View commit details
  2. misc: Add initial type hints (#470)

    Introducing partial type hints to the project, in preparation for
    publishing typing stubs for any applications using `django-waffle`.
    adamantike authored Sep 17, 2022
    Copy the full SHA
    c8c444e View commit details

Commits on Sep 18, 2022

  1. misc: Distribute and package type information (#472)

    According to [PEP 561](https://peps.python.org/pep-0561/), these should
    be the only required changes for this project to start distribution the
    type hints, for dependant applications to benefit from.
    adamantike authored Sep 18, 2022
    Copy the full SHA
    6e84661 View commit details
  2. misc: Add more type hints (#471)

    Adding more missing type hints to the project, mainly for:
    * Decorators,
    * Models and managers,
    * Testutils `override_*` context managers.
    adamantike authored Sep 18, 2022
    Copy the full SHA
    2526331 View commit details

Commits on Oct 1, 2022

  1. user.rst: Fix typos (#473)

    cclauss authored Oct 1, 2022
    Copy the full SHA
    c02ec0b View commit details
  2. Copy the full SHA
    f2378a1 View commit details
  3. Copy the full SHA
    7e45cd0 View commit details
  4. Copy the full SHA
    88ab289 View commit details

Commits on Oct 2, 2022

  1. Copy the full SHA
    1d7a097 View commit details

Commits on Jul 25, 2023

  1. Dropped support for Python 3.7 (#487)

    This reached EOL on 2023-06-27.
    clintonb authored Jul 25, 2023
    Copy the full SHA
    2b78d9c View commit details
  2. Copy the full SHA
    a5fb407 View commit details

Commits on Jul 26, 2023

  1. Releasing 4.0.0

    clintonb committed Jul 26, 2023
    Copy the full SHA
    be05c2c View commit details

Commits on Nov 24, 2023

  1. Copy the full SHA
    21d7fad View commit details

Commits on Dec 11, 2023

  1. Copy the full SHA
    80f5b33 View commit details
  2. Releasing 4.1.0

    clintonb committed Dec 11, 2023
    Copy the full SHA
    63444d6 View commit details

Commits on Apr 23, 2024

  1. Implemented Jazzband guidelines (#501)

    clintonb authored Apr 23, 2024
    Copy the full SHA
    3d44a4b View commit details

Commits on May 6, 2024

  1. Resolved flake8 errors (#503)

    dragon-dxw authored May 6, 2024
    Copy the full SHA
    e63a13f View commit details

Commits on Jun 7, 2024

  1. Add flake8 to the precommit (#504)

    We only check the waffle directory to keep it equivalent to the
    existing linter
    dragon-dxw authored Jun 7, 2024
    Copy the full SHA
    7082362 View commit details

Commits on Jun 11, 2024

  1. Lint end-of-file has newline (#506)

    (We also change the way we exclude files to be more general in our linting)
    dragon-dxw authored Jun 11, 2024
    Copy the full SHA
    44d2c04 View commit details

Commits on Jul 1, 2024

  1. [pre-commit.ci] pre-commit autoupdate (#507)

    updates:
    - [github.com/pycqa/flake8: 7.0.0 → 7.1.0](PyCQA/flake8@7.0.0...7.1.0)
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    pre-commit-ci[bot] authored Jul 1, 2024
    Copy the full SHA
    cd92384 View commit details

Commits on Aug 5, 2024

  1. [pre-commit.ci] pre-commit autoupdate (#508)

    updates:
    - [github.com/pycqa/flake8: 7.1.0 → 7.1.1](PyCQA/flake8@7.1.0...7.1.1)
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    pre-commit-ci[bot] authored Aug 5, 2024
    Copy the full SHA
    0ee724c View commit details

Commits on Oct 8, 2024

  1. [pre-commit.ci] pre-commit autoupdate (#509)

    updates:
    - [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](pre-commit/pre-commit-hooks@v4.6.0...v5.0.0)
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    pre-commit-ci[bot] authored Oct 8, 2024
    Copy the full SHA
    53ac908 View commit details

Commits on Nov 8, 2024

  1. Create dependabot config for github-actions (#512)

    ulgens authored Nov 8, 2024
    Copy the full SHA
    79182bb View commit details
  2. Bump actions/cache from 3 to 4 (#513)

    Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
    - [Release notes](https://github.com/actions/cache/releases)
    - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
    - [Commits](actions/cache@v3...v4)
    
    ---
    updated-dependencies:
    - dependency-name: actions/cache
      dependency-type: direct:production
      update-type: version-update:semver-major
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Nov 8, 2024
    Copy the full SHA
    4244141 View commit details
  3. Bump actions/checkout from 3 to 4 (#515)

    Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
    - [Release notes](https://github.com/actions/checkout/releases)
    - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
    - [Commits](actions/checkout@v3...v4)
    
    ---
    updated-dependencies:
    - dependency-name: actions/checkout
      dependency-type: direct:production
      update-type: version-update:semver-major
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Nov 8, 2024
    Copy the full SHA
    36a8ebc View commit details
  4. Bump actions/setup-python from 4 to 5 (#514)

    Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
    - [Release notes](https://github.com/actions/setup-python/releases)
    - [Commits](actions/setup-python@v4...v5)
    
    ---
    updated-dependencies:
    - dependency-name: actions/setup-python
      dependency-type: direct:production
      update-type: version-update:semver-major
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Nov 8, 2024
    Copy the full SHA
    0470ec2 View commit details
  5. Add Python 3.12 support (#516)

    Also added missing supported Django version 4.2 to trove classifiers
    ulgens authored Nov 8, 2024
    Copy the full SHA
    a0e9f71 View commit details

Commits on Nov 9, 2024

  1. Use ruff to lint Python code (#518)

    cclauss authored Nov 9, 2024
    Copy the full SHA
    29b36b6 View commit details
  2. [improvement] Add Django 5.0 support (#519)

    ulgens authored Nov 9, 2024
    Copy the full SHA
    a53e2cd View commit details

Commits on Nov 10, 2024

  1. [improvement] Add Django 5.1 support (#520)

    ulgens authored Nov 10, 2024
    Copy the full SHA
    a44ad5d View commit details
  2. [improvement] Add support for Python 3.13 (#523)

    ulgens authored Nov 10, 2024
    Copy the full SHA
    380fa48 View commit details

Commits on Nov 14, 2024

  1. Create .readthedocs.yaml for documentation configuration (#525)

    * Create .readthedocs.yaml
    
    to fix broken documentation building pipeline
    
    * [pre-commit.ci] auto fixes from pre-commit.com hooks
    
    for more information, see https://pre-commit.ci
    
    ---------
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    dancergraham and pre-commit-ci[bot] authored Nov 14, 2024
    Copy the full SHA
    e003a08 View commit details

Commits on Nov 15, 2024

  1. Preparing to release 4.2.0 (#527)

    clintonb authored Nov 15, 2024
    Copy the full SHA
    86a1ea5 View commit details

Commits on Nov 16, 2024

  1. Update documentation (#530)

    * build: add [docs] dependencies group
    
    * fix: Gargoyle is no longer supported
    
    * fix: minor updates
    
    * fix: remove deprecated easy install and jingo references
    
    * fix: remove deprecated syncdb ref
    
    * fix: remove ancient upgrading instructions
    
    * Revert "fix: remove ancient upgrading instructions"
    
    This reverts commit c3cafe1.
    
    * fix: update broken link
    
    * fix: remove unmaintained framework
    dancergraham authored Nov 16, 2024
    Copy the full SHA
    b697b31 View commit details

Commits on Nov 19, 2024

  1. Drop support for EOL Django versions (3.2, 4.0, 4.1) (#528)

    ulgens authored Nov 19, 2024
    Copy the full SHA
    2a932a0 View commit details

Commits on Nov 20, 2024

  1. [pre-commit.ci] pre-commit autoupdate (#533)

    updates:
    - [github.com/astral-sh/ruff-pre-commit: v0.7.3 → v0.7.4](astral-sh/ruff-pre-commit@v0.7.3...v0.7.4)
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    pre-commit-ci[bot] authored Nov 20, 2024
    Copy the full SHA
    12ea1c1 View commit details

Commits on Nov 21, 2024

  1. Delete setup.cfg which only contains flake8 config (#522)

    cclauss authored Nov 21, 2024
    Copy the full SHA
    f3af0dd View commit details

Commits on Nov 26, 2024

  1. [pre-commit.ci] pre-commit autoupdate (#534)

    updates:
    - [github.com/astral-sh/ruff-pre-commit: v0.7.4 → v0.8.0](astral-sh/ruff-pre-commit@v0.7.4...v0.8.0)
    pre-commit-ci[bot] authored Nov 26, 2024
    Copy the full SHA
    2440f90 View commit details

Commits on Dec 3, 2024

  1. Add Read the docs status badge (#537)

    The documentation build was broken for many months this year but this was not immediately visible anywhere on the GitHub repo. I propose to add a read the docs status badge rather than an actions status badge as this is a better source of truth about the documentation build status
    dancergraham authored Dec 3, 2024
    Copy the full SHA
    2cb176a View commit details

Commits on Dec 17, 2024

  1. [pre-commit.ci] pre-commit autoupdate (#538)

    updates:
    - [github.com/astral-sh/ruff-pre-commit: v0.8.0 → v0.8.3](astral-sh/ruff-pre-commit@v0.8.0...v0.8.3)
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    pre-commit-ci[bot] authored Dec 17, 2024
    Copy the full SHA
    a85782b View commit details

Commits on Dec 20, 2024

  1. Improved API documentation (#539)

    Added missing attributes and methods.
    dancergraham authored Dec 20, 2024
    Copy the full SHA
    2dd8fac View commit details

Commits on Dec 23, 2024

  1. [pre-commit.ci] pre-commit autoupdate (#540)

    updates:
    - [github.com/astral-sh/ruff-pre-commit: v0.8.3 → v0.8.4](astral-sh/ruff-pre-commit@v0.8.3...v0.8.4)
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    pre-commit-ci[bot] authored Dec 23, 2024
    Copy the full SHA
    8eb58fb View commit details

Commits on Dec 27, 2024

  1. Add WAFFLE_SWITCH_MODEL to Configuring Waffle page (#535)

    * Add `WAFFLE_SWITCH_MODEL` to Configuring Waffle page
    
    Fixes #454
    
    Add `WAFFLE_SWITCH_MODEL` and `WAFFLE_SAMPLE_MODEL` settings to the "Configuring Waffle" documentation page.
    
    * [pre-commit.ci] auto fixes from pre-commit.com hooks
    
    for more information, see https://pre-commit.ci
    
    ---------
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    dancergraham and pre-commit-ci[bot] authored Dec 27, 2024
    Copy the full SHA
    2b81166 View commit details

Commits on Dec 28, 2024

  1. Test all pull requests for docs (#536)

    * WIP - Test the docs
    
    Related to #526
    
    Add a command to build the documentation using Sphinx and update the CI workflow to include a documentation build stage.
    
    * **run.sh**
      - Add a command to build the documentation using Sphinx.
      - Update the usage function to include the new builddocs command.
    
    * **.github/workflows/python-package.yml**
      - Add a new stage to run the documentation building to ensure it works.
      - Update the release-production stage to depend on the new docs stage.
    
    * refactor: simplify changes
    
    * refactor: undo changes to run.sh
    dancergraham authored Dec 28, 2024
    Copy the full SHA
    7f0d532 View commit details

Commits on Dec 30, 2024

  1. Mobile friendly documentation (#543)

    * Add sphinx-rtd-theme to docs
    dancergraham authored Dec 30, 2024
    Copy the full SHA
    393e7db View commit details

Commits on Dec 31, 2024

  1. fix: building sphinx rtd theme (#544)

    * Add sphinx-rtd-theme to docs
    
    First implementation
    
    * docs: mobile friendly documentation
    
    * docs: follow rtd installation instructions
    dancergraham authored Dec 31, 2024
    Copy the full SHA
    2b70e6b View commit details
Showing with 768 additions and 393 deletions.
  1. +9 −0 .github/dependabot.yml
  2. +84 −17 .github/workflows/python-package.yml
  3. +11 −0 .pre-commit-config.yaml
  4. +31 −0 .readthedocs.yaml
  5. +25 −1 CHANGES
  6. +16 −6 CONTRIBUTING.rst
  7. +0 −1 LICENSE
  8. +13 −4 README.rst
  9. +3 −4 RELEASING.rst
  10. +6 −6 docs/about/contributing.rst
  11. +7 −7 docs/about/roadmap.rst
  12. +4 −15 docs/about/why-waffle.rst
  13. +6 −5 docs/conf.py
  14. +2 −3 docs/index.rst
  15. +17 −1 docs/starting/configuring.rst
  16. +4 −21 docs/starting/installation.rst
  17. +2 −4 docs/testing/automated.rst
  18. +4 −4 docs/testing/user.rst
  19. +22 −12 docs/types/flag.rst
  20. +14 −4 docs/types/sample.rst
  21. +13 −4 docs/types/switch.rst
  22. +1 −1 docs/usage/decorators.rst
  23. +1 −1 docs/usage/json.rst
  24. +1 −1 docs/usage/mixins.rst
  25. +2 −2 docs/usage/templates.rst
  26. +1 −1 docs/usage/views.rst
  27. +153 −0 pyproject.toml
  28. +2 −2 requirements.txt
  29. +2 −2 requirements/test.txt
  30. +5 −2 run.sh
  31. +0 −3 setup.cfg
  32. +0 −36 setup.py
  33. +10 −10 test_app/urls.py
  34. +1 −1 test_settings.py
  35. +21 −7 tox.ini
  36. +0 −9 waffle/.flake8
  37. +20 −16 waffle/__init__.py
  38. +27 −18 waffle/admin.py
  39. +1 −1 waffle/apps.py
  40. +15 −6 waffle/decorators.py
  41. +1 −1 waffle/jinja.py
  42. +2 −2 waffle/locale/ru/LC_MESSAGES/django.po
  43. +8 −6 waffle/management/commands/waffle_delete.py
  44. +73 −61 waffle/management/commands/waffle_flag.py
  45. +8 −5 waffle/management/commands/waffle_sample.py
  46. +9 −7 waffle/management/commands/waffle_switch.py
  47. +14 −6 waffle/managers.py
  48. +2 −1 waffle/middleware.py
  49. +5 −3 waffle/mixins.py
  50. +38 −27 waffle/models.py
  51. 0 waffle/py.typed
  52. +4 −5 waffle/templatetags/waffle_tags.py
  53. +2 −1 waffle/tests/test_management.py
  54. +2 −2 waffle/tests/test_middleware.py
  55. +6 −0 waffle/tests/test_models.py
  56. +3 −3 waffle/tests/test_testutils.py
  57. +21 −18 waffle/testutils.py
  58. +7 −4 waffle/utils.py
  59. +7 −3 waffle/views.py
9 changes: 9 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
101 changes: 84 additions & 17 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
@@ -15,16 +15,17 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: [ 3.7, 3.8, 3.9, '3.10' ]
python-version: [ 3.8, 3.9, '3.10', 3.11, 3.12, 3.13 ]

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v3
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ matrix.python-version }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -43,9 +44,9 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: actions/cache@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ matrix.python-version }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -57,19 +58,36 @@ jobs:
python -m pip install tox-gh-actions
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint
run: ruff check --output-format=github .

typecheck:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ matrix.python-version }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ matrix.python-version }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox-gh-actions
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Type-check
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
tox -e typecheck
i18n:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: actions/cache@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ matrix.python-version }}-pip-${{ hashFiles('**/requirements.txt') }}
@@ -87,17 +105,66 @@ jobs:
run: |
tox -e i18n
docs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ matrix.python-version }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ matrix.python-version }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install .[docs]
- name: Build docs
run: |
sphinx-build -b html docs docs/_build
coverage:
runs-on: ubuntu-latest
needs: test

steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: 3.13
- name: Install dependencies
run: |
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install coverage
- name: Run coverage
run: |
source .venv/bin/activate
export DJANGO_SETTINGS_MODULE="test_settings"
export PYTHONPATH=.".:$PYTHONPATH"
python -m coverage run --source=waffle `which django-admin` test waffle
coverage report -m
coverage html
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5

release-production:
# Only upload if a tag is pushed (otherwise just build & check)
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')

runs-on: ubuntu-latest

needs: [test, lint, i18n]
needs: [test, lint, i18n, docs, coverage]

steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: casperdcl/deploy-pypi@v2
with:
password: ${{ secrets.PYPI_API_TOKEN }}
11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 'v5.0.0'
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
31 changes: 31 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details

# Required
version: 2

# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.12"

# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/conf.py

# Optionally build your docs in additional formats such as PDF and ePub
# formats:
# - pdf
# - epub

# Optional but recommended, declare the Python requirements required
# to build your documentation
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
# Here we follow https://docs.readthedocs.io/en/stable/config-file/v2.html#packages
python:
install:
- method: pip
path: .
extra_requirements:
- docs
26 changes: 25 additions & 1 deletion CHANGES
Original file line number Diff line number Diff line change
@@ -2,6 +2,30 @@
Waffle Changelog
================

v4.2.0
======
- Joined Jazzband (https://jazzband.co/)
- Linting improvements and cleanups
- Added support for Django 5.0 and 5.1
- Added support for Python 3.13

v4.1.0
======
- Updated `is_active_for_user` method to account for `everyone` option
- Added `--testing` option to waffle_flag management command

v4.0.0
======
- Added support for Django 4.2 and Python 3.11
- Dropped support for Python 3.7
- Added type hints

v3.0.0
======
- Added support for pluggable Sample and Switch models
- Removed support EOL Python versions
- Removed support for EOL Django versions

v2.7.0
======
- Exposed JSON endpoint for Waffle flag/switch/sample state
@@ -95,7 +119,7 @@ v0.16.0
- Using strings as cache keys instead of bytes
- Passing effects of test decorator to child classes
-- NOTE: This introduced a backwards-incompatible change for the testutils override decorators.
See https://github.com/django-waffle/django-waffle/pull/331 for details.
See https://github.com/jazzband/django-waffle/pull/331 for details.

v0.15.1
=======
22 changes: 16 additions & 6 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
@@ -5,6 +5,14 @@
Contributing to Waffle
======================

.. image:: https://jazzband.co/static/img/jazzband.svg
:target: https://jazzband.co/
:alt: Jazzband

This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree to abide by the
`Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the
`guidelines <https://jazzband.co/about/guidelines>`_.

Waffle is pretty simple to hack, and has a decent test suite! Here's how
to patch Waffle, add tests, run them, and contribute changes.

@@ -52,24 +60,26 @@ and squashed into a minimal set of commits. Each commit should include
the necessary code, test, and documentation changes for a single "piece"
of functionality.

To be mergable, patches must:
To be mergeable, patches must:

- be rebased onto the latest master,
- be automatically mergeable,
- not break existing tests,
- not change existing tests without a *very* good reason,
- add tests for new code (bug fixes should include regression tests, new
features should have relevant tests),
- not introduce any new flake8_ errors (run ``./run.sh lint``),
- not introduce any new ruff_ errors (run ``./run.sh lint``),
- not introduce any new mypy_ errors (run ``./run.sh typecheck``),
- include updated source translations (run ``./run.sh makemessages`` and ``./run.sh compilemessages``),
- document any new features, and
- have a `good commit message`_.

Regressions tests should fail without the rest of the patch and pass
with it.
with it.


.. _open a new issue: https://github.com/django-waffle/django-waffle/issues/new
.. _Fork: https://github.com/django-waffle/django-waffle/fork
.. _flake8: https://pypi.python.org/pypi/flake8
.. _open a new issue: https://github.com/jazzband/django-waffle/issues/new
.. _Fork: https://github.com/jazzband/django-waffle/fork
.. _ruff: https://pypi.python.org/pypi/ruff
.. _mypy: https://www.mypy-lang.org/
.. _good commit message: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
1 change: 0 additions & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -25,4 +25,3 @@ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

17 changes: 13 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
@@ -6,14 +6,23 @@ Django Waffle is (yet another) feature flipper for Django. You can
define the conditions for which a flag should be active, and use it in
a number of ways.

.. image:: https://github.com/django-waffle/django-waffle/workflows/Python%20package/badge.svg?branch=master
:target: https://github.com/django-waffle/django-waffle/actions
.. image:: https://jazzband.co/static/img/badge.svg
:target: https://jazzband.co/
:alt: Jazzband
.. image:: https://github.com/jazzband/django-waffle/workflows/Python%20package/badge.svg?branch=master
:target: https://github.com/jazzband/django-waffle/actions
:alt: Build Status
.. image:: https://badge.fury.io/py/django-waffle.svg
:target: https://badge.fury.io/py/django-waffle
:alt: PyPI status badge
.. image:: https://img.shields.io/readthedocs/waffle
:target: https://app.readthedocs.org/projects/waffle
:alt: Read the Docs
.. image:: https://codecov.io/gh/jazzband/django-waffle/branch/master/graph/badge.svg
:target: https://codecov.io/gh/jazzband/django-waffle
:alt: Codecov

:Code: https://github.com/django-waffle/django-waffle
:Code: https://github.com/jazzband/django-waffle
:License: BSD; see LICENSE file
:Issues: https://github.com/django-waffle/django-waffle/issues
:Issues: https://github.com/jazzband/django-waffle/issues
:Documentation: https://waffle.readthedocs.io/
7 changes: 3 additions & 4 deletions RELEASING.rst
Original file line number Diff line number Diff line change
@@ -5,14 +5,13 @@ These are the steps necessary to release a new version of Django Waffle.

1. Update the version number in the following files:

a. `setup.py`
b. `docs/conf.py`
c. `waffle/__init__.py`
a. `docs/conf.py`
b. `waffle/__init__.py`

2. Update the changelog in `CHANGES`.

3. Merge these changes to the `master` branch.

4. Create a new release on GitHub. This will also create a Git tag, and trigger a push to PyPI.
4. Create a new release on GitHub. This will also create a git tag, and trigger a push to PyPI.

5. Ensure the documentation build passes: https://readthedocs.org/projects/waffle/
12 changes: 6 additions & 6 deletions docs/about/contributing.rst
Original file line number Diff line number Diff line change
@@ -52,23 +52,23 @@ and squashed into a minimal set of commits. Each commit should include
the necessary code, test, and documentation changes for a single "piece"
of functionality.

To be mergable, patches must:
To be mergeable, patches must:

- be rebased onto the latest master,
- be automatically mergeable,
- not break existing tests,
- not change existing tests without a *very* good reason,
- add tests for new code (bug fixes should include regression tests, new
features should have relevant tests),
- not introduce any new flake8_ errors (run ``./run.sh lint``),
- not introduce any new ruff_ errors (run ``./run.sh lint``),
- document any new features, and
- have a `good commit message`_.

Regressions tests should fail without the rest of the patch and pass
with it.
with it.


.. _open a new issue: https://github.com/django-waffle/django-waffle/issues/new
.. _Fork: https://github.com/django-waffle/django-waffle/fork
.. _flake8: https://pypi.python.org/pypi/flake8
.. _open a new issue: https://github.com/jazzband/django-waffle/issues/new
.. _Fork: https://github.com/jazzband/django-waffle/fork
.. _ruff: https://pypi.python.org/pypi/ruff
.. _good commit message: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
14 changes: 7 additions & 7 deletions docs/about/roadmap.rst
Original file line number Diff line number Diff line change
@@ -119,11 +119,11 @@ scratch today with slightly different goals, like extensibility. Beyond
kind of overhaul.


.. _milestones: https://github.com/django-waffle/django-waffle/milestones
.. _0.10.2: https://github.com/django-waffle/django-waffle/milestones/0.10.2
.. _0.11: https://github.com/django-waffle/django-waffle/milestones/0.11
.. _0.11.x: https://github.com/django-waffle/django-waffle/milestones/0.11.x
.. _0.12: https://github.com/django-waffle/django-waffle/milestones/0.12
.. _0.13: https://github.com/django-waffle/django-waffle/milestones/0.13
.. _milestones: https://github.com/jazzband/django-waffle/milestones
.. _0.10.2: https://github.com/jazzband/django-waffle/milestones/0.10.2
.. _0.11: https://github.com/jazzband/django-waffle/milestones/0.11
.. _0.11.x: https://github.com/jazzband/django-waffle/milestones/0.11.x
.. _0.12: https://github.com/jazzband/django-waffle/milestones/0.12
.. _0.13: https://github.com/jazzband/django-waffle/milestones/0.13
.. _Gargoyle: https://github.com/disqus/gargoyle
.. _django-jinja: https://niwinz.github.io/django-jinja/latest/
.. _django-jinja: https://niwinz.github.io/django-jinja/latest/
19 changes: 4 additions & 15 deletions docs/about/why-waffle.rst
Original file line number Diff line number Diff line change
@@ -19,31 +19,20 @@ Waffle :ref:`aims to <about-goals>`
Waffle has an `active community`_ and gets `fairly steady updates`_.


vs Gargoyle
===========

The other major, active feature flag tool for Django is Disqus's
Gargoyle_. Both support similar features, though Gargoyle offers more
options for building custom segments in exchange for some more
complexity and requirements.


Waffle in Production
====================

Despite its pre-1.0 version number, Waffle has been used in production
for years at places like Mozilla, Yipit and TodaysMeet.
Waffle has been used in production for years at places like Mozilla, Yipit and TodaysMeet.

- Mozilla (Support, MDN, Addons, etc)
- TodaysMeet
- Yipit

(If you're using Waffle in production and don't mind being included
here, let me know or add yourself in a pull request!)
here, let us know or add yourself in a pull request!)


.. _Feature flags: http://code.flickr.net/2009/12/02/flipping-out/
.. _several options: https://www.djangopackages.com/grids/g/feature-flip/
.. _active community: https://github.com/django-waffle/django-waffle/graphs/contributors
.. _fairly steady updates: https://github.com/django-waffle/django-waffle/pulse/monthly
.. _Gargoyle: https://github.com/disqus/gargoyle
.. _active community: https://github.com/jazzband/django-waffle/graphs/contributors
.. _fairly steady updates: https://github.com/jazzband/django-waffle/pulse/monthly
11 changes: 6 additions & 5 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -9,7 +9,8 @@
# All configuration values have a default; values that are commented out
# serve to show the default.

import sys, os
import sys
import os

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
@@ -46,9 +47,9 @@
# built documents.
#
# The short X.Y version.
version = '3.0'
version = '4.2'
# The full version, including alpha/beta/rc tags.
release = '3.0.0'
release = '4.2.0'

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@@ -89,7 +90,7 @@

# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'nature'
html_theme = "sphinx_rtd_theme"

# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
@@ -118,7 +119,7 @@
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# html_static_path = ['_static']

# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
5 changes: 2 additions & 3 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -8,9 +8,9 @@ Waffle is feature flipper for Django. You can define the conditions for
which a flag should be active, and use it in a number of ways.

:Version: |release|
:Code: https://github.com/django-waffle/django-waffle
:Code: https://github.com/jazzband/django-waffle
:License: BSD; see LICENSE file
:Issues: https://github.com/django-waffle/django-waffle/issues
:Issues: https://github.com/jazzband/django-waffle/issues

Contents:

@@ -33,4 +33,3 @@ Indices and tables
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

18 changes: 17 additions & 1 deletion docs/starting/configuring.rst
Original file line number Diff line number Diff line change
@@ -28,6 +28,22 @@ behavior.
Needs to be set at the start of a project, as the Django migrations framework does not
support changing swappable models after the initial migration.

``WAFFLE_SWITCH_MODEL``
The Django model that will be used for switches. Defaults to ``waffle.Switch``, which
provides basic switch functionality. This can be swapped for a custom Switch model if
additional fields or behaviors are needed. The custom model must inherit from
``waffle.models.AbstractBaseSwitch``. Needs to be set at the start of a project, as
Django’s migration framework does not support changing swappable models after the
initial migration.

``WAFFLE_SAMPLE_MODEL``
The Django model that will be used for samples. Defaults to ``waffle.Sample``, which
provides basic sample functionality. This can be swapped for a custom Sample model if
additional fields or behaviors are needed. The custom model must inherit from
``waffle.models.AbstractBaseSample``. Needs to be set at the start of a project, as
Django’s migration framework does not support changing swappable models after the
initial migration.

``WAFFLE_SWITCH_DEFAULT``
When a Switch is undefined in the database, Waffle considers it
``False``. Set this to ``True`` to make Waffle consider undefined
@@ -100,7 +116,7 @@ behavior.
The value describes the level of wanted warning, possible values are all levels know by pythons default logging,
e.g. ``logging.WARNING``.
Defaults to ``None``.


``WAFFLE_ENABLE_ADMIN_PAGES``
Enables the default admin pages for Waffle models. This is True by default,
25 changes: 4 additions & 21 deletions docs/starting/installation.rst
Original file line number Diff line number Diff line change
@@ -11,19 +11,17 @@ met, installing Waffle is a simple process.
Getting Waffle
==============

Waffle is `hosted on PyPI`_ and can be installed with ``pip`` or
``easy_install``:
Waffle is `hosted on PyPI`_ and can be installed with ``pip``

.. code-block:: shell
$ pip install django-waffle
$ easy_install django-waffle
Waffle is also available `on GitHub`_. In general, ``master`` should be
stable, but use caution depending on unreleased versions.

.. _hosted on PyPI: http://pypi.python.org/pypi/django-waffle
.. _on GitHub: https://github.com/django-waffle/django-waffle
.. _on GitHub: https://github.com/jazzband/django-waffle


.. _installation-settings:
@@ -80,33 +78,18 @@ With django-jinja_, add the extension to the ``extensions`` list::
# ...
]

With jingo_, add it to the ``JINJA_CONFIG['extensions']`` list::

JINJA_CONFIG = {
'extensions': [
# ...
'waffle.jinja.WaffleExtension',
],
# ...
}


.. _installation-settings-migrations:

Database Schema
===============

Waffle includes `Django migrations`_ for creating the correct database
schema. If using Django >= 1.7, simply run the ``migrate`` management
command after adding Waffle to ``INSTALLED_APPS``:
schema. Simply run the ``migrate`` management command after adding Waffle to
``INSTALLED_APPS``:

.. code-block:: shell
$ django-admin.py migrate
If you're using a version of Django without migrations, you can run
``syncdb`` to create the Waffle tables.

.. _Django migrations: https://docs.djangoproject.com/en/dev/topics/migrations/
.. _django-jinja: https://pypi.python.org/pypi/django-jinja/
.. _jingo: http://jingo.readthedocs.org/
6 changes: 2 additions & 4 deletions docs/testing/automated.rst
Original file line number Diff line number Diff line change
@@ -53,9 +53,8 @@ Tests that run in a separate process, such as Selenium tests, may not
have access to the test database or the ability to mock Waffle values.

For tests that make HTTP requests to the system-under-test (e.g. with
Selenium_ or PhantomJS_) the ``WAFFLE_OVERRIDE`` :ref:`setting
<starting-configuring>` makes it possible to control the value of any
*Flag* via the querystring.
Selenium_) the ``WAFFLE_OVERRIDE`` :ref:`setting <starting-configuring>`
makes it possible to control the value of any *Flag* via the querystring.

.. highlight:: http

@@ -72,4 +71,3 @@ or that it is "off"::
.. _mock: http://pypi.python.org/pypi/mock/
.. _fudge: http://farmdev.com/projects/fudge/
.. _Selenium: http://www.seleniumhq.org/
.. _PhantomJS: http://phantomjs.org/
8 changes: 4 additions & 4 deletions docs/testing/user.rst
Original file line number Diff line number Diff line change
@@ -55,8 +55,8 @@ an HTTP header instead of a querystring parameter.
When a flag, ``foo``, is in testing mode, simply provide the HTTP header
``DWFT-Foo: 1`` (or ``0``) to turn the flag on (or off).

This feature can also allow for a downstream service to make the decison of
enabling/disabling a flag, and then propagating that decision to other upsteam
This feature can also allow for a downstream service to make the decision of
enabling/disabling a flag, and then propagating that decision to other upstream
services, allowing for a more complete testing of more complex microservice
infrastructures.

@@ -159,6 +159,6 @@ Wow, good work!
You can use similar methods to derive the impact on other factors.


.. _session variables: https://developers.google.com/analytics/devguides/collection/upgrade/reference/gajs-analyticsjs#custom-vars
.. _#80: https://github.com/django-waffle/django-waffle/issues/80
.. _session variables: https://support.google.com/analytics/answer/9191807?hl=en
.. _#80: https://github.com/jazzband/django-waffle/issues/80
.. _StatsD: https://github.com/etsy/statsd
34 changes: 22 additions & 12 deletions docs/types/flag.rst
Original file line number Diff line number Diff line change
@@ -43,33 +43,33 @@ Flag Attributes
Flags can be administered through the Django `admin site`_ or the
:ref:`command line <usage-cli>`. They have the following attributes:

:Name:
:name:
The name of the flag. Will be used to identify the flag everywhere.
:Everyone:
:everyone:
Globally set the Flag, **overriding all other criteria**. Leave as
*Unknown* to use other criteria.
:Testing:
:testing:
Can the flag be specified via a querystring parameter? :ref:`See
below <types-flag-testing>`.
:Percent:
:percent:
A percentage of users for whom the flag will be active, if no other
criteria applies to them.
:Superusers:
:superusers:
Is this flag always active for superusers?
:Staff:
:staff:
Is this flag always active for staff?
:Authenticated:
:authenticated:
Is this flag always active for authenticated users?
:Languages:
:languages:
Is the ``LANGUAGE_CODE`` of the request in this list?
(Comma-separated values.)
:Groups:
:groups:
A list of group IDs for which this flag will always be active.
:Users:
:users:
A list of user IDs for which this flag will always be active.
:Rollout:
:rollout:
Activate Rollout mode? :ref:`See below <types-flag-rollout>`.
:Note:
:note:
Describe where the flag is used.

A Flag will be active if *any* of the criteria are true for the current
@@ -85,6 +85,16 @@ are in the group *or* if they are in the 12%.
the actual proportion of users for whom the Flag is active will
probably differ slightly from the Percentage value.

Flag Methods
============

The Flag class has the following public methods:

:is_active:
Determines if the flag is active for a given request. Returns a boolean value.
:is_active_for_user:
Determines if the flag is active for a given user. Returns a boolean value.


.. _types-flag-custom-model:

18 changes: 14 additions & 4 deletions docs/types/sample.rst
Original file line number Diff line number Diff line change
@@ -42,13 +42,23 @@ Sample Attributes
Samples can be administered through the Django `admin site`_ or the
:ref:`command line <usage-cli>`. They have the following attributes:

:Name:
:name:
The name of the Sample.
:Percent:
:percent:
A number from 0.0 to 100.0 that determines how often the Sample
will be active.
:Note:
Describe where the Sample is used.
:note:
Describes where the Sample is used.



Sample Methods
==============

The Sample class has the following public methods:

:is_active:
Determines if the sample is active. Returns a boolean value.


.. _admin site: https://docs.djangoproject.com/en/dev/ref/contrib/admin/
17 changes: 13 additions & 4 deletions docs/types/switch.rst
Original file line number Diff line number Diff line change
@@ -15,17 +15,26 @@ Switch Attributes
Switches can be administered through the Django `admin site`_ or the
:ref:`command line <usage-cli>`. They have the following attributes:

:Name:
:name:
The name of the Switch.
:Active:
:active:
Is the Switch active or inactive.
:Note:
Describe where the Switch is used.
:note:
Describes where the Switch is used.


.. _admin site: https://docs.djangoproject.com/en/dev/ref/contrib/admin/


Switch Methods
==============

The Switch class has the following public methods:

:is_active:
Determines if the switch is active. Returns a boolean value.


.. _types-custom-switch-models:

Custom Switch Models
2 changes: 1 addition & 1 deletion docs/usage/decorators.rst
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ Flags
@waffle_flag('flag_name')
def myview(request):
pass

@waffle_flag('flag_name', 'url_name_to_redirect_to')
def myotherview(request):
pass
2 changes: 1 addition & 1 deletion docs/usage/json.rst
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ Although :doc:`WaffleJS<javascript>` returns the status of all
:ref:`samples <types-sample>`, it does so by exposing a Javascript
object, rather than returning the data in a directly consumable format.

In cases where a directly consumable format is preferrable,
In cases where a directly consumable format is preferable,
Waffle also exposes this data as JSON via the ``waffle_status`` view.


2 changes: 1 addition & 1 deletion docs/usage/mixins.rst
Original file line number Diff line number Diff line change
@@ -39,4 +39,4 @@ WaffleSampleMixin
from waffle.mixins import WaffleSampleMixin
class MyClass(WaffleSampleMixin, View):
waffle_sample= "my_sample"
waffle_sample= "my_sample"
4 changes: 2 additions & 2 deletions docs/usage/templates.rst
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ features on the front-end. It includes support for both Django's
built-in templates and for Jinja2_.

.. warning::

Before using samples in templates, see the warning in the
:ref:`Sample chapter <types-sample>`.

@@ -92,7 +92,7 @@ Switches
--------

::

{% if waffle.switch('switch_name') %}
switch_name is active!
{% endif %}
2 changes: 1 addition & 1 deletion docs/usage/views.rst
Original file line number Diff line number Diff line change
@@ -49,5 +49,5 @@ Samples
Returns ``True`` if the sample is active, else ``False``.

.. warning::

See the warning in the :ref:`Sample chapter <types-sample>`.
153 changes: 153 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
[build-system]
requires = ["setuptools>=61.2"]
build-backend = "setuptools.build_meta"

[project]
name = "django-waffle"
dynamic = ["version"]
authors = [{name = "James Socol", email = "me@jamessocol.com"}]
license = {text = "BSD"}
description = "A feature flipper for Django."
readme = "README.rst"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Framework :: Django",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
]
requires-python = ">=3.8"
dependencies = ["django>=4.2"]
[project.optional-dependencies]
docs = ["sphinx", "sphinx-rtd-theme"]

[project.urls]
Homepage = "http://github.com/django-waffle/django-waffle"

[tool.setuptools]
zip-safe = false
include-package-data = true

[tool.setuptools.dynamic]
version = {attr = "waffle.__version__"}

[tool.setuptools.packages.find]
exclude = ["test_app"] # test_settings
namespaces = false

[tool.setuptools.package-data]
waffle = ["py.typed"]

[tool.mypy]
python_version = "3.8"
exclude = "waffle/tests"
disallow_incomplete_defs = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
strict_equality = true

[[tool.mypy.overrides]]
module = ["django.*"]
ignore_missing_imports = true

[tool.ruff]
line-length = 120
target-version = "py38"

[tool.ruff.lint]
select = [
"AIR", # Airflow
"ASYNC", # flake8-async
"BLE", # flake8-blind-except
"C90", # McCabe cyclomatic complexity
"DJ", # flake8-django
"DTZ", # flake8-datetimez
"E", # pycodestyle errors
"F", # Pyflakes
"FIX", # flake8-fixme
"FLY", # flynt
"G", # flake8-logging-format
"ICN", # flake8-import-conventions
"INP", # flake8-no-pep420
"INT", # flake8-gettext
"NPY", # NumPy-specific rules
"PD", # pandas-vet
"PIE", # flake8-pie
"PL", # Pylint
"PYI", # flake8-pyi
"RSE", # flake8-raise
"SLOT", # flake8-slots
"T10", # flake8-debugger
"T20", # flake8-print
"TD", # flake8-todos
"TID", # flake8-tidy-imports
"UP", # pyupgrade
"W", # pycodestyle warnings
"YTT", # flake8-2020
# "A", # flake8-builtins
# "ANN", # flake8-annotations
# "ARG", # flake8-unused-arguments
# "B", # flake8-bugbear
# "C4", # flake8-comprehensions
# "COM", # flake8-commas
# "CPY", # flake8-copyright
# "D", # pydocstyle
# "EM", # flake8-errmsg
# "ERA", # eradicate
# "EXE", # flake8-executable
# "FA", # flake8-future-annotations
# "FBT", # flake8-boolean-trap
# "I", # isort
# "ISC", # flake8-implicit-str-concat
# "N", # pep8-naming
# "PERF", # Perflint
# "PGH", # pygrep-hooks
# "PT", # flake8-pytest-style
# "PTH", # flake8-use-pathlib
# "Q", # flake8-quotes
# "RET", # flake8-return
# "RUF", # Ruff-specific rules
# "S", # flake8-bandit
# "SIM", # flake8-simplify
# "SLF", # flake8-self
# "TCH", # flake8-type-checking
# "TRY", # tryceratops
]
# Files not checked:
# - migrations: most of these are autogenerated and don't need a check
# - docs: contains autogenerated code that doesn't need a check
exclude = [
"*/migrations/*",
"docs",
]
ignore = ["F401"]

[tool.ruff.lint.mccabe]
max-complexity = 23

[tool.ruff.lint.per-file-ignores]
"docs/conf.py" = ["INP001"]
"test_app/models.py" = ["DJ008"] # FIXME
"waffle/models.py" = ["DJ012", "PYI019"] # FIXME

[tool.ruff.lint.pylint]
allow-magic-value-types = ["float", "int", "str"]
max-args = 6 # default is 5
max-branches = 23 # default is 12
max-returns = 13 # default is 6
max-statements = 51 # default is 50
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -2,6 +2,6 @@ Django
django-jinja>=2.4.1,<3
transifex-client

flake8
mock==1.3.0
mypy
ruff
tox
4 changes: 2 additions & 2 deletions requirements/test.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
flake8
mock==4.0.3
ruff
tox
coverage
7 changes: 5 additions & 2 deletions run.sh
Original file line number Diff line number Diff line change
@@ -6,7 +6,8 @@ export DJANGO_SETTINGS_MODULE="test_settings"
usage() {
echo "USAGE: $0 [command]"
echo " test - run the waffle tests"
echo " lint - run flake8"
echo " lint - run ruff"
echo " typecheck - run mypy"
echo " shell - open the Django shell"
echo " makemigrations - create a schema migration"
exit 1
@@ -19,7 +20,9 @@ case "$CMD" in
"test" )
DJANGO_SETTINGS_MODULE=test_settings django-admin test waffle $@ ;;
"lint" )
flake8 waffle $@ ;;
ruff check ;;
"typecheck" )
mypy waffle $@ ;;
"shell" )
django-admin shell $@ ;;
"makemigrations" )
3 changes: 0 additions & 3 deletions setup.cfg

This file was deleted.

36 changes: 0 additions & 36 deletions setup.py

This file was deleted.

20 changes: 10 additions & 10 deletions test_app/urls.py
Original file line number Diff line number Diff line change
@@ -24,25 +24,25 @@ def handler500(r, exception=None):
path('foo_view', views.foo_view, name='foo_view'),
path('foo_view_with_args/<int:some_number>/', views.foo_view_with_args, name='foo_view_with_args'),
path('switched_view_with_valid_redirect',
views.switched_view_with_valid_redirect),
views.switched_view_with_valid_redirect),
path('switched_view_with_valid_url_name',
views.switched_view_with_valid_url_name),
views.switched_view_with_valid_url_name),
path('switched_view_with_args_with_valid_redirect/<int:some_number>/',
views.switched_view_with_args_with_valid_redirect),
views.switched_view_with_args_with_valid_redirect),
path('switched_view_with_args_with_valid_url_name/<int:some_number>/',
views.switched_view_with_args_with_valid_url_name),
views.switched_view_with_args_with_valid_url_name),
path('switched_view_with_invalid_redirect',
views.switched_view_with_invalid_redirect),
views.switched_view_with_invalid_redirect),
path('flagged_view_with_valid_redirect',
views.flagged_view_with_valid_redirect),
views.flagged_view_with_valid_redirect),
path('flagged_view_with_valid_url_name',
views.flagged_view_with_valid_url_name),
views.flagged_view_with_valid_url_name),
path('flagged_view_with_args_with_valid_redirect/<int:some_number>/',
views.flagged_view_with_args_with_valid_redirect),
views.flagged_view_with_args_with_valid_redirect),
path('flagged_view_with_args_with_valid_url_name/<int:some_number>/',
views.flagged_view_with_args_with_valid_url_name),
views.flagged_view_with_args_with_valid_url_name),
path('flagged_view_with_invalid_redirect',
views.flagged_view_with_invalid_redirect),
views.flagged_view_with_invalid_redirect),
path('flag-off', views.flagged_off_view),
path('', include('waffle.urls')),
path('admin/', admin.site.urls),
2 changes: 1 addition & 1 deletion test_settings.py
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@

# Make filepaths relative to settings.
ROOT = os.path.dirname(os.path.abspath(__file__))
path = lambda *a: os.path.join(ROOT, *a)
path = lambda *a: os.path.join(ROOT, *a) # noqa: E731

DEBUG = True
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
28 changes: 21 additions & 7 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,30 +1,44 @@
[tox]
envlist =
py{37,38,39,310}-django{32}
py{38,39,310}-django{40,41}
py{38,39}-django{42}
py{310}-django{42,50,51}
py{311}-django{42,50,51}
py{312}-django{42,50,51}
py{313}-django{51}
isolated_build = True

[gh-actions]
python =
3.7: py37
3.8: py38
3.9: py39
3.10: py310
3.11: py311
3.12: py312
3.13: py313

[testenv]
allowlist_externals = ./run.sh
deps =
django32: Django>=3.2,<3.3
django40: Django>=4.0,<4.1
django41: Django>=4.1,<4.2
django42: Django>=4.2,<4.3
django50: Django>=5.0,<5.1
django51: Django>=5.1,<5.2
djangomain: https://github.com/django/django/archive/main.tar.gz
-r{toxinidir}/requirements.txt
commands =
./run.sh test

[testenv:i18n]
deps =
Django>=3.2,<4.2
Django>=4.2,<5.2
-r{toxinidir}/requirements.txt
commands =
./run.sh makemessages
./run.sh compilemessages
./run.sh find_uncommitted_translations

[testenv:typecheck]
deps =
Django>=4.2,<5.2
-r{toxinidir}/requirements.txt
commands =
./run.sh typecheck
9 changes: 0 additions & 9 deletions waffle/.flake8

This file was deleted.

36 changes: 20 additions & 16 deletions waffle/__init__.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,49 @@
import django
from __future__ import annotations

from typing import TYPE_CHECKING

from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest

from waffle.utils import get_setting
from django.apps import apps as django_apps

VERSION = (3, 0, 0)
__version__ = '.'.join(map(str, VERSION))
if TYPE_CHECKING:
from waffle.models import AbstractBaseFlag, AbstractBaseSample, AbstractBaseSwitch

__version__ = '4.2.0'


def flag_is_active(request, flag_name, read_only=False):
def flag_is_active(request: HttpRequest, flag_name: str, read_only: bool = False) -> bool | None:
flag = get_waffle_flag_model().get(flag_name)
return flag.is_active(request, read_only=read_only)


def switch_is_active(switch_name):
def switch_is_active(switch_name: str) -> bool:
switch = get_waffle_switch_model().get(switch_name)
return switch.is_active()


def sample_is_active(sample_name):
def sample_is_active(sample_name: str) -> bool:
sample = get_waffle_sample_model().get(sample_name)
return sample.is_active()


def get_waffle_flag_model():
def get_waffle_flag_model() -> type[AbstractBaseFlag]:
return get_waffle_model('FLAG_MODEL')


def get_waffle_switch_model():
def get_waffle_switch_model() -> type[AbstractBaseSwitch]:
return get_waffle_model('SWITCH_MODEL')


def get_waffle_sample_model():
def get_waffle_sample_model() -> type[AbstractBaseSample]:
return get_waffle_model('SAMPLE_MODEL')


def get_waffle_model(setting_name):
def get_waffle_model(setting_name: str) -> (
type[AbstractBaseFlag | AbstractBaseSwitch | AbstractBaseSample]
):
"""
Returns the waffle Flag model that is active in this project.
"""
@@ -55,12 +63,8 @@ def get_waffle_model(setting_name):
try:
return django_apps.get_model(flag_model_name)
except ValueError:
raise ImproperlyConfigured("WAFFLE_{} must be of the form 'app_label.model_name'".format(
setting_name
))
raise ImproperlyConfigured(f"WAFFLE_{setting_name} must be of the form 'app_label.model_name'")
except LookupError:
raise ImproperlyConfigured(
"WAFFLE_{} refers to model '{}' that has not been installed".format(
setting_name, flag_model_name
)
f"WAFFLE_{setting_name} refers to model '{flag_model_name}' that has not been installed"
)
45 changes: 27 additions & 18 deletions waffle/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from __future__ import annotations

from typing import Any

from django.contrib import admin
from django.contrib.admin.models import LogEntry, CHANGE, DELETION
from django.contrib.admin.widgets import ManyToManyRawIdWidget
from django.contrib.contenttypes.models import ContentType
from django.http import HttpRequest
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _

@@ -12,7 +17,7 @@
class BaseAdmin(admin.ModelAdmin):
search_fields = ('name', 'note')

def get_actions(self, request):
def get_actions(self, request: HttpRequest) -> dict[str, Any]:
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
@@ -29,6 +34,10 @@ def _add_log_entry(user, model, description, action_flag):
)


@admin.action(
description=_('Enable selected flags for everyone'),
permissions=('change',),
)
def enable_for_all(ma, request, qs):
# Iterate over all objects to cause cache invalidation.
for f in qs.all():
@@ -37,6 +46,10 @@ def enable_for_all(ma, request, qs):
f.save()


@admin.action(
description=_('Disable selected flags for everyone'),
permissions=('change',),
)
def disable_for_all(ma, request, qs):
# Iterate over all objects to cause cache invalidation.
for f in qs.all():
@@ -45,29 +58,24 @@ def disable_for_all(ma, request, qs):
f.save()


@admin.action(
description=_('Delete selected'),
permissions=('delete',),
)
def delete_individually(ma, request, qs):
# Iterate over all objects to cause cache invalidation.
for f in qs.all():
_add_log_entry(request.user, f, "deleted", DELETION)
f.delete()


enable_for_all.short_description = _('Enable selected flags for everyone')
disable_for_all.short_description = _('Disable selected flags for everyone')
delete_individually.short_description = _('Delete selected')

enable_for_all.allowed_permissions = ('change',)
disable_for_all.allowed_permissions = ('change',)
delete_individually.allowed_permissions = ('delete',)


class InformativeManyToManyRawIdWidget(ManyToManyRawIdWidget):
"""Widget for ManyToManyField to Users.
Will display the names of the users in a parenthesised list after the
input field. This widget works with all models that have a "name" field.
"""
def label_and_url_for_value(self, values):
def label_and_url_for_value(self, values: Any) -> tuple[str, str]:
names = []
key = self.rel.get_related_field().name
for value in values:
@@ -100,27 +108,28 @@ def formfield_for_dbfield(self, db_field, **kwargs):
return super().formfield_for_dbfield(db_field, **kwargs)


@admin.action(
description=_('Enable selected switches'),
permissions=('change',),
)
def enable_switches(ma, request, qs):
for switch in qs:
_add_log_entry(request.user, switch, "on", CHANGE)
switch.active = True
switch.save()


@admin.action(
description=_('Disable selected switches'),
permissions=('change',),
)
def disable_switches(ma, request, qs):
for switch in qs:
_add_log_entry(request.user, switch, "off", CHANGE)
switch.active = False
switch.save()


enable_switches.short_description = _('Enable selected switches')
disable_switches.short_description = _('Disable selected switches')

enable_switches.allowed_permissions = ('change',)
disable_switches.allowed_permissions = ('change',)


class SwitchAdmin(BaseAdmin):
actions = [enable_switches, disable_switches, delete_individually]
list_display = ('name', 'active', 'note', 'created', 'modified')
2 changes: 1 addition & 1 deletion waffle/apps.py
Original file line number Diff line number Diff line change
@@ -6,5 +6,5 @@ class WaffleConfig(AppConfig):
verbose_name = 'django-waffle'
default_auto_field = 'django.db.models.AutoField'

def ready(self):
def ready(self) -> None:
import waffle.signals # noqa: F401
21 changes: 15 additions & 6 deletions waffle/decorators.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
from __future__ import annotations

from functools import wraps, WRAPPER_ASSIGNMENTS
from typing import Any, Callable

from django.http import Http404
from django.http import Http404, HttpRequest, HttpResponse, HttpResponsePermanentRedirect, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse, NoReverseMatch

from waffle import flag_is_active, switch_is_active


def waffle_flag(flag_name, redirect_to=None):
def decorator(view):
def waffle_flag(
flag_name: str, redirect_to: Callable | str | None = None,
) -> Callable[[Callable[[HttpRequest], HttpResponse]], Callable[[HttpRequest], HttpResponse]]:
def decorator(view: Callable[[HttpRequest], HttpResponse]) -> Callable[[HttpRequest], HttpResponse]:
@wraps(view, assigned=WRAPPER_ASSIGNMENTS)
def _wrapped_view(request, *args, **kwargs):
if flag_name.startswith('!'):
@@ -28,8 +33,10 @@ def _wrapped_view(request, *args, **kwargs):
return decorator


def waffle_switch(switch_name, redirect_to=None):
def decorator(view):
def waffle_switch(
switch_name: str, redirect_to: Callable | str | None = None,
) -> Callable[[Callable[[HttpRequest], HttpResponse]], Callable[[HttpRequest], HttpResponse]]:
def decorator(view: Callable[[HttpRequest], HttpResponse]) -> Callable[[HttpRequest], HttpResponse]:
@wraps(view, assigned=WRAPPER_ASSIGNMENTS)
def _wrapped_view(request, *args, **kwargs):
if switch_name.startswith('!'):
@@ -49,7 +56,9 @@ def _wrapped_view(request, *args, **kwargs):
return decorator


def get_response_to_redirect(view, *args, **kwargs):
def get_response_to_redirect(
view: Callable | str | None, *args: Any, **kwargs: Any,
) -> HttpResponseRedirect | HttpResponsePermanentRedirect | None:
try:
return redirect(reverse(view, args=args, kwargs=kwargs)) if view else None
except NoReverseMatch:
2 changes: 1 addition & 1 deletion waffle/jinja.py
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
from jinja2 import pass_context
except ImportError:
# NOTE: We can get rid of this when we stop supporting Jinja2 < 3.
from jinja2 import contextfunction as pass_context
from jinja2 import contextfunction as pass_context # type: ignore


@pass_context
4 changes: 2 additions & 2 deletions waffle/locale/ru/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
@@ -2,10 +2,10 @@
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#
# Translators:
# Clinton Blackburn <clinton.blackburn@gmail.com>, 2021
#
#
#, fuzzy
msgid ""
msgstr ""
14 changes: 8 additions & 6 deletions waffle/management/commands/waffle_delete.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.core.management.base import BaseCommand
from typing import Any

from django.core.management.base import BaseCommand, CommandParser

from waffle import (
get_waffle_flag_model,
@@ -8,7 +10,7 @@


class Command(BaseCommand):
def add_arguments(self, parser):
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
'--flags',
action='store',
@@ -30,13 +32,13 @@ def add_arguments(self, parser):

help = 'Delete flags, samples, and switches from database'

def handle(self, *args, **options):
def handle(self, *args: Any, **options: Any) -> None:
flags = options['flag_names']
if flags:
flag_queryset = get_waffle_flag_model().objects.filter(name__in=flags)
flag_count = flag_queryset.count()
flag_queryset.delete()
self.stdout.write('Deleted %s Flags' % flag_count)
self.stdout.write(f'Deleted {flag_count} Flags')

switches = options['switch_names']
if switches:
@@ -45,11 +47,11 @@ def handle(self, *args, **options):
)
switch_count = switches_queryset.count()
switches_queryset.delete()
self.stdout.write('Deleted %s Switches' % switch_count)
self.stdout.write(f'Deleted {switch_count} Switches')

samples = options['sample_names']
if samples:
sample_queryset = get_waffle_sample_model().objects.filter(name__in=samples)
sample_count = sample_queryset.count()
sample_queryset.delete()
self.stdout.write('Deleted %s Samples' % sample_count)
self.stdout.write(f'Deleted {sample_count} Samples')
134 changes: 73 additions & 61 deletions waffle/management/commands/waffle_flag.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
from typing import Any

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.db.models import Q

from waffle.models import AbstractBaseFlag
from waffle import get_waffle_flag_model

UserModel = get_user_model()


class Command(BaseCommand):
def add_arguments(self, parser):
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
'name',
nargs='?',
@@ -81,6 +84,13 @@ def add_arguments(self, parser):
dest='rollout',
default=False,
help='Turn on rollout mode.')
parser.add_argument(
'--testing', '-t',
action='store_true',
dest='testing',
default=False,
help='Turn on testing mode, allowing the flag to be specified via '
'a querystring parameter.')
parser.add_argument(
'--create',
action='store_true',
@@ -91,25 +101,11 @@ def add_arguments(self, parser):

help = 'Modify a flag.'

def handle(self, *args, **options):
def handle(self, *args: Any, **options: Any) -> None:
if options['list_flags']:
self.stdout.write('Flags:')
for flag in get_waffle_flag_model().objects.iterator():
self.stdout.write('NAME: %s' % flag.name)
self.stdout.write('SUPERUSERS: %s' % flag.superusers)
self.stdout.write('EVERYONE: %s' % flag.everyone)
self.stdout.write('AUTHENTICATED: %s' % flag.authenticated)
self.stdout.write('PERCENT: %s' % flag.percent)
self.stdout.write('TESTING: %s' % flag.testing)
self.stdout.write('ROLLOUT: %s' % flag.rollout)
self.stdout.write('STAFF: %s' % flag.staff)
self.stdout.write('GROUPS: %s' % list(
flag.groups.values_list('name', flat=True))
)
self.stdout.write('USERS: %s' % list(
flag.users.values_list(UserModel.USERNAME_FIELD, flat=True))
)
self.stdout.write('')
self.log_flag_to_stdout(flag)
return

flag_name = options['name']
@@ -120,54 +116,70 @@ def handle(self, *args, **options):
if options['create']:
flag, created = get_waffle_flag_model().objects.get_or_create(name=flag_name)
if created:
self.stdout.write('Creating flag: %s' % flag_name)
self.stdout.write(f'Creating flag: {flag_name}')
else:
try:
flag = get_waffle_flag_model().objects.get(name=flag_name)
except get_waffle_flag_model().DoesNotExist:
raise CommandError('This flag does not exist.')

# Loop through all options, setting Flag attributes that
# match (ie. don't want to try setting flag.verbosity)
for option in options:
# Group isn't an attribute on the Flag, but a related Many to Many
# field, so we handle it a bit differently by looking up groups and
# adding each group to the flag individually
if option == 'group':
group_hash = {}
for group in options['group']:
try:
group_instance = Group.objects.get(name=group)
group_hash[group_instance.name] = group_instance.id
except Group.DoesNotExist:
raise CommandError('Group %s does not exist' % group)
# If 'append' was not passed, we clear related groups
if not options['append']:
flag.groups.clear()
self.stdout.write('Setting group(s): %s' % (
[name for name, _id in group_hash.items()])
)
for group_name, group_id in group_hash.items():
flag.groups.add(group_id)
elif option == 'user':
user_hash = set()
for username in options['user']:
try:
user_instance = UserModel.objects.get(
Q(**{UserModel.USERNAME_FIELD: username}) |
Q(**{UserModel.EMAIL_FIELD: username})
)
user_hash.add(user_instance)
except UserModel.DoesNotExist:
raise CommandError('User %s does not exist' % username)
# If 'append' was not passed, we clear related users
if not options['append']:
flag.users.clear()
self.stdout.write('Setting user(s): %s' % user_hash)
# for user in user_hash:
flag.users.add(*[user.id for user in user_hash])
elif hasattr(flag, option):
self.stdout.write(f'Setting {option}: {options[option]}')
setattr(flag, option, options[option])
# Group isn't an attribute on the Flag, but a related Many-to-Many
# field, so we handle it a bit differently by looking up groups and
# adding each group to the flag individually
options_append = options.pop('append')
if groups := options.pop('group'):
group_hash = {}
for group in groups:
try:
group_instance = Group.objects.get(name=group)
group_hash[group_instance.name] = group_instance.id
except Group.DoesNotExist:
raise CommandError(f'Group {group} does not exist')
# If 'append' was not passed, we clear related groups
if not options_append:
flag.groups.clear()
self.stdout.write('Setting group(s): %s' % (
[name for name, _id in group_hash.items()])
)
for group_id in group_hash.values():
flag.groups.add(group_id)
if users := options.pop('user'):
user_hash = set()
for username in users:
try:
user_instance = UserModel.objects.get(
Q(**{UserModel.USERNAME_FIELD: username})
| Q(**{UserModel.EMAIL_FIELD: username})
)
user_hash.add(user_instance)
except UserModel.DoesNotExist:
raise CommandError(f'User {username} does not exist')
# If 'append' was not passed, we clear related users
if not options_append:
flag.users.clear()
self.stdout.write(f'Setting user(s): {user_hash}')
# for user in user_hash:
flag.users.add(*[user.id for user in user_hash])
for option_name, option in options.items():
if hasattr(flag, option_name):
self.stdout.write(f'Setting {option_name}: {option}')
setattr(flag, option_name, option)

flag.save()

def log_flag_to_stdout(self, flag: AbstractBaseFlag) -> None:
self.stdout.write(f'NAME: {flag.name}')
self.stdout.write(f'SUPERUSERS: {flag.superusers}')
self.stdout.write(f'EVERYONE: {flag.everyone}')
self.stdout.write(f'AUTHENTICATED: {flag.authenticated}')
self.stdout.write(f'PERCENT: {flag.percent}')
self.stdout.write(f'TESTING: {flag.testing}')
self.stdout.write(f'ROLLOUT: {flag.rollout}')
self.stdout.write(f'STAFF: {flag.staff}')
self.stdout.write('GROUPS: {}'.format(list(
flag.groups.values_list('name', flat=True)))
)
self.stdout.write('USERS: {}'.format(list(
flag.users.values_list(UserModel.USERNAME_FIELD, flat=True)))
)
self.stdout.write('')
13 changes: 8 additions & 5 deletions waffle/management/commands/waffle_sample.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from django.core.management.base import BaseCommand, CommandError
from typing import Any

from django.core.management.base import BaseCommand, CommandError, CommandParser

from waffle import get_waffle_sample_model


class Command(BaseCommand):
def add_arguments(self, parser):
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
'name',
nargs='?',
@@ -26,7 +29,7 @@ def add_arguments(self, parser):

help = 'Change percentage of a sample.'

def handle(self, *args, **options):
def handle(self, *args: Any, **options: Any) -> None:
if options['list_samples']:
self.stdout.write('Samples:')
for sample in get_waffle_sample_model().objects.iterator():
@@ -45,15 +48,15 @@ def handle(self, *args, **options):
try:
percent = float(percent)
if not (0.0 <= percent <= 100.0):
raise ValueError()
raise ValueError
except ValueError:
raise CommandError('You need to enter a valid percentage value.')

if options['create']:
sample, created = get_waffle_sample_model().objects.get_or_create(
name=sample_name, defaults={'percent': 0})
if created:
self.stdout.write('Creating sample: %s' % sample_name)
self.stdout.write(f'Creating sample: {sample_name}')
else:
try:
sample = get_waffle_sample_model().objects.get(name=sample_name)
16 changes: 9 additions & 7 deletions waffle/management/commands/waffle_switch.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
from typing import Any

from argparse import ArgumentTypeError
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand, CommandError, CommandParser

from waffle import get_waffle_switch_model


def on_off_bool(string):
def on_off_bool(string: str) -> bool:
if string not in ['on', 'off']:
raise ArgumentTypeError("invalid choice: %r (choose from 'on', "
"'off')" % string)
raise ArgumentTypeError(f"invalid choice: {string!r} (choose from 'on', "
"'off')")
return string == 'on'


class Command(BaseCommand):
def add_arguments(self, parser):
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
'name',
nargs='?',
@@ -37,7 +39,7 @@ def add_arguments(self, parser):

help = 'Activate or deactivate a switch.'

def handle(self, *args, **options):
def handle(self, *args: Any, **options: Any) -> None:
if options['list_switches']:
self.stdout.write('Switches:')
for switch in get_waffle_switch_model().objects.iterator():
@@ -58,7 +60,7 @@ def handle(self, *args, **options):
name=switch_name
)
if created:
self.stdout.write('Creating switch: %s' % switch_name)
self.stdout.write(f'Creating switch: {switch_name}')
else:
try:
switch = get_waffle_switch_model().objects.get(name=switch_name)
20 changes: 14 additions & 6 deletions waffle/managers.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
from typing import TYPE_CHECKING, Any, Generic, TypeVar

from django.db import models

from waffle.utils import get_setting, get_cache


class BaseManager(models.Manager):
if TYPE_CHECKING:
from waffle.models import _BaseModelType, AbstractBaseFlag, AbstractBaseSample, AbstractBaseSwitch # noqa: F401
else:
_BaseModelType = TypeVar("_BaseModelType")


class BaseManager(models.Manager, Generic[_BaseModelType]):
KEY_SETTING = ''

def get_by_natural_key(self, name):
def get_by_natural_key(self, name: str) -> _BaseModelType:
return self.get(name=name)

def create(self, *args, **kwargs):
def create(self, *args: Any, **kwargs: Any) -> _BaseModelType:
cache = get_cache()
ret = super().create(*args, **kwargs)
cache_key = get_setting(self.KEY_SETTING)
cache.delete(cache_key)
return ret


class FlagManager(BaseManager):
class FlagManager(BaseManager['AbstractBaseFlag']):
KEY_SETTING = 'ALL_FLAGS_CACHE_KEY'


class SwitchManager(BaseManager):
class SwitchManager(BaseManager['AbstractBaseSwitch']):
KEY_SETTING = 'ALL_SWITCHES_CACHE_KEY'


class SampleManager(BaseManager):
class SampleManager(BaseManager['AbstractBaseSample']):
KEY_SETTING = 'ALL_SAMPLES_CACHE_KEY'
3 changes: 2 additions & 1 deletion waffle/middleware.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from django.http import HttpRequest, HttpResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.encoding import smart_str

from waffle.utils import get_setting


class WaffleMiddleware(MiddlewareMixin):
def process_response(self, request, response):
def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
secure = get_setting('SECURE')
max_age = get_setting('MAX_AGE')

8 changes: 5 additions & 3 deletions waffle/mixins.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from functools import partial

from django.http import Http404
@@ -24,7 +26,7 @@ class WaffleFlagMixin(BaseWaffleMixin):
waffle_flag
"""

waffle_flag = None
waffle_flag: str | None = None

def dispatch(self, request, *args, **kwargs):
func = partial(flag_is_active, request)
@@ -42,7 +44,7 @@ class WaffleSampleMixin(BaseWaffleMixin):
waffle_sample.
"""

waffle_sample = None
waffle_sample: str | None = None

def dispatch(self, request, *args, **kwargs):
active = self.validate_waffle(self.waffle_sample, sample_is_active)
@@ -59,7 +61,7 @@ class WaffleSwitchMixin(BaseWaffleMixin):
waffle_switch.
"""

waffle_switch = None
waffle_switch: str | None = None

def dispatch(self, request, *args, **kwargs):
active = self.validate_waffle(self.waffle_switch, switch_is_active)
65 changes: 38 additions & 27 deletions waffle/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from __future__ import annotations

import logging
import random
from decimal import Decimal
import logging
from typing import Any, TypeVar

from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import AbstractBaseUser, Group
from django.db import models, router, transaction
from django.http import HttpRequest
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

@@ -20,25 +24,29 @@

CACHE_EMPTY = '-'


_BaseModelType = TypeVar("_BaseModelType", bound="BaseModel")


class BaseModel(models.Model):
SINGLE_CACHE_KEY = ''
ALL_CACHE_KEY = ''

class Meta:
abstract = True

def __str__(self):
def __str__(self) -> str:
return self.name

def natural_key(self):
def natural_key(self) -> tuple[str]:
return (self.name,)

@classmethod
def _cache_key(cls, name):
def _cache_key(cls, name: str) -> str:
return keyfmt(get_setting(cls.SINGLE_CACHE_KEY), name)

@classmethod
def get(cls, name):
def get(cls: type[_BaseModelType], name: str) -> _BaseModelType:
cache = get_cache()
cache_key = cls._cache_key(name)
cached = cache.get(cache_key)
@@ -57,14 +65,14 @@ def get(cls, name):
return obj

@classmethod
def get_from_db(cls, name):
def get_from_db(cls: type[_BaseModelType], name: str) -> _BaseModelType:
objects = cls.objects
if get_setting('READ_FROM_WRITE_DB'):
objects = objects.using(router.db_for_write(cls))
return objects.get(name=name)

@classmethod
def get_all(cls):
def get_all(cls: type[_BaseModelType]) -> list[_BaseModelType]:
cache = get_cache()
cache_key = get_setting(cls.ALL_CACHE_KEY)
cached = cache.get(cache_key)
@@ -82,21 +90,21 @@ def get_all(cls):
return objs

@classmethod
def get_all_from_db(cls):
def get_all_from_db(cls: type[_BaseModelType]) -> list[_BaseModelType]:
objects = cls.objects
if get_setting('READ_FROM_WRITE_DB'):
objects = objects.using(router.db_for_write(cls))
return list(objects.all())

def flush(self):
def flush(self) -> None:
cache = get_cache()
keys = [
self._cache_key(self.name),
get_setting(self.ALL_CACHE_KEY),
]
cache.delete_many(keys)

def save(self, *args, **kwargs):
def save(self, *args: Any, **kwargs: Any) -> None:
self.modified = timezone.now()
ret = super().save(*args, **kwargs)
if hasattr(transaction, 'on_commit'):
@@ -105,7 +113,7 @@ def save(self, *args, **kwargs):
self.flush()
return ret

def delete(self, *args, **kwargs):
def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]:
ret = super().delete(*args, **kwargs)
if hasattr(transaction, 'on_commit'):
transaction.on_commit(self.flush)
@@ -114,7 +122,7 @@ def delete(self, *args, **kwargs):
return ret


def set_flag(request, flag_name, active=True, session_only=False):
def set_flag(request: HttpRequest, flag_name: str, active: bool | None = True, session_only: bool = False) -> None:
"""Set a flag value on a request object."""
if not hasattr(request, 'waffles'):
request.waffles = {}
@@ -208,20 +216,23 @@ class Meta:
verbose_name = _('Flag')
verbose_name_plural = _('Flags')

def flush(self):
def flush(self) -> None:
cache = get_cache()
keys = self.get_flush_keys()
cache.delete_many(keys)

def get_flush_keys(self, flush_keys=None):
def get_flush_keys(self, flush_keys: list[str] | None = None) -> list[str]:
flush_keys = flush_keys or []
flush_keys.extend([
self._cache_key(self.name),
get_setting('ALL_FLAGS_CACHE_KEY'),
])
return flush_keys

def is_active_for_user(self, user):
def is_active_for_user(self, user: AbstractBaseUser) -> bool | None:
if self.everyone:
return True

if self.authenticated and user.is_authenticated:
return True

@@ -233,21 +244,21 @@ def is_active_for_user(self, user):

return None

def _is_active_for_user(self, request):
def _is_active_for_user(self, request: HttpRequest) -> bool | None:
user = getattr(request, "user", None)
if user:
return self.is_active_for_user(user)
return False

def _is_active_for_language(self, request):
def _is_active_for_language(self, request: HttpRequest) -> bool | None:
if self.languages:
languages = [ln.strip() for ln in self.languages.split(',')]
if (hasattr(request, 'LANGUAGE_CODE') and
request.LANGUAGE_CODE in languages):
if (hasattr(request, 'LANGUAGE_CODE')
and request.LANGUAGE_CODE in languages):
return True
return None

def is_active(self, request, read_only=False):
def is_active(self, request: HttpRequest, read_only: bool = False) -> bool | None:
if not self.pk:
log_level = get_setting('LOG_MISSING_FLAGS')
if log_level:
@@ -341,15 +352,15 @@ class Meta(AbstractBaseFlag.Meta):
verbose_name = _('Flag')
verbose_name_plural = _('Flags')

def get_flush_keys(self, flush_keys=None):
def get_flush_keys(self, flush_keys: list[str] | None = None) -> list[str]:
flush_keys = super().get_flush_keys(flush_keys)
flush_keys.extend([
keyfmt(get_setting('FLAG_USERS_CACHE_KEY'), self.name),
keyfmt(get_setting('FLAG_GROUPS_CACHE_KEY'), self.name),
])
return flush_keys

def _get_user_ids(self):
def _get_user_ids(self) -> set[Any]:
cache = get_cache()
cache_key = keyfmt(get_setting('FLAG_USERS_CACHE_KEY'), self.name)
cached = cache.get(cache_key)
@@ -366,7 +377,7 @@ def _get_user_ids(self):
cache.add(cache_key, user_ids)
return user_ids

def _get_group_ids(self):
def _get_group_ids(self) -> set[Any]:
cache = get_cache()
cache_key = keyfmt(get_setting('FLAG_GROUPS_CACHE_KEY'), self.name)
cached = cache.get(cache_key)
@@ -383,7 +394,7 @@ def _get_group_ids(self):
cache.add(cache_key, group_ids)
return group_ids

def is_active_for_user(self, user):
def is_active_for_user(self, user: AbstractBaseUser) -> bool | None:
is_active = super().is_active_for_user(user)
if is_active:
return is_active
@@ -459,7 +470,7 @@ class Meta:
verbose_name = _('Switch')
verbose_name_plural = _('Switches')

def is_active(self):
def is_active(self) -> bool:
if not self.pk:
log_level = get_setting('LOG_MISSING_SWITCHES')
if log_level:
@@ -524,7 +535,7 @@ class Meta:
verbose_name = _('Sample')
verbose_name_plural = _('Samples')

def is_active(self):
def is_active(self) -> bool:
if not self.pk:
log_level = get_setting('LOG_MISSING_SAMPLES')
if log_level:
Empty file added waffle/py.typed
Empty file.
9 changes: 4 additions & 5 deletions waffle/templatetags/waffle_tags.py
Original file line number Diff line number Diff line change
@@ -20,7 +20,7 @@ def __init__(self, nodelist_true, nodelist_false, condition, name,
self.compiled_name = compiled_name

def __repr__(self):
return '<Waffle node: %s>' % self.name
return f'<Waffle node: {self.name}>'

def __iter__(self):
yield from self.nodelist_true
@@ -41,16 +41,15 @@ def render(self, context):
def handle_token(cls, parser, token, kind, condition):
bits = token.split_contents()
if len(bits) < 2:
raise template.TemplateSyntaxError("%r tag requires an argument" %
bits[0])
raise template.TemplateSyntaxError(f"{bits[0]!r} tag requires an argument")

name = bits[1]
compiled_name = parser.compile_filter(name)

nodelist_true = parser.parse(('else', 'end%s' % kind))
nodelist_true = parser.parse(('else', f'end{kind}'))
token = parser.next_token()
if token.contents == 'else':
nodelist_false = parser.parse(('end%s' % kind,))
nodelist_false = parser.parse((f'end{kind}',))
parser.delete_first_token()
else:
nodelist_false = template.NodeList()
3 changes: 2 additions & 1 deletion waffle/tests/test_management.py
Original file line number Diff line number Diff line change
@@ -17,13 +17,14 @@ def test_create(self):
name = 'test'
percent = 20
Group.objects.create(name='waffle_group')
call_command('waffle_flag', name, percent=percent,
call_command('waffle_flag', name, percent=percent, testing=True,
superusers=True, staff=True, authenticated=True,
rollout=True, create=True, group=['waffle_group'])

flag = get_waffle_flag_model().objects.get(name=name)
self.assertEqual(flag.percent, percent)
self.assertIsNone(flag.everyone)
self.assertTrue(flag.testing)
self.assertTrue(flag.superusers)
self.assertTrue(flag.staff)
self.assertTrue(flag.authenticated)
4 changes: 2 additions & 2 deletions waffle/tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -29,7 +29,7 @@ def test_rollout_cookies():
resp = HttpResponse()
resp = WaffleMiddleware().process_response(get, resp)
for k in get.waffles:
cookie = 'dwf_%s' % k
cookie = f'dwf_{k}'
assert cookie in resp.cookies
assert str(get.waffles[k][0]) == resp.cookies[cookie].value
if get.waffles[k][1]:
@@ -44,6 +44,6 @@ def test_testing_cookies():
resp = HttpResponse()
resp = WaffleMiddleware().process_response(get, resp)
for k in get.waffle_tests:
cookie = 'dwft_%s' % k
cookie = f'dwft_{k}'
assert str(get.waffle_tests[k]) == resp.cookies[cookie].value
assert not resp.cookies[cookie]['max-age']
6 changes: 6 additions & 0 deletions waffle/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
get_waffle_sample_model,
get_waffle_switch_model,
)
from django.contrib.auth.models import User


class ModelsTests(TestCase):
@@ -30,3 +31,8 @@ def test_natural_keys(self):
def test_flag_is_not_active_for_none_requests(self):
flag = get_waffle_flag_model().objects.create(name='test-flag')
self.assertEqual(flag.is_active(None), False)

def test_is_active_for_user_when_everyone_is_active(self):
flag = get_waffle_flag_model().objects.create(name='test-flag')
flag.everyone = True
self.assertEqual(flag.is_active_for_user(User()), True)
6 changes: 3 additions & 3 deletions waffle/tests/test_testutils.py
Original file line number Diff line number Diff line change
@@ -197,7 +197,7 @@ def test_sample_existed_and_was_100(self):
assert not waffle.sample_is_active('foo')

self.assertEqual(Decimal('100.0'),
waffle.get_waffle_sample_model().objects.get(name='foo').percent)
waffle.get_waffle_sample_model().objects.get(name='foo').percent)

def test_sample_existed_and_was_0(self):
waffle.get_waffle_sample_model().objects.create(name='foo', percent='0.0')
@@ -209,7 +209,7 @@ def test_sample_existed_and_was_0(self):
assert not waffle.sample_is_active('foo')

self.assertEqual(Decimal('0.0'),
waffle.get_waffle_sample_model().objects.get(name='foo').percent)
waffle.get_waffle_sample_model().objects.get(name='foo').percent)

def test_sample_existed_and_was_50(self):
waffle.get_waffle_sample_model().objects.create(name='foo', percent='50.0')
@@ -221,7 +221,7 @@ def test_sample_existed_and_was_50(self):
assert not waffle.sample_is_active('foo')

self.assertEqual(Decimal('50.0'),
waffle.get_waffle_sample_model().objects.get(name='foo').percent)
waffle.get_waffle_sample_model().objects.get(name='foo').percent)

def test_sample_did_not_exist(self):
assert not waffle.get_waffle_sample_model().objects.filter(name='foo').exists()
39 changes: 21 additions & 18 deletions waffle/testutils.py
Original file line number Diff line number Diff line change
@@ -1,46 +1,49 @@
from typing import Generic, Optional, TypeVar, Union

from django.test.utils import TestContextDecorator

from waffle import (
get_waffle_flag_model,
get_waffle_sample_model,
get_waffle_switch_model,
)
from waffle.models import Switch, Sample


__all__ = ['override_flag', 'override_sample', 'override_switch']

_T = TypeVar("_T")


class _overrider(TestContextDecorator):
def __init__(self, name, active):
class _overrider(TestContextDecorator, Generic[_T]):
def __init__(self, name: str, active: _T):
super().__init__()
self.name = name
self.active = active

def get(self):
def get(self) -> None:
self.obj, self.created = self.cls.objects.get_or_create(name=self.name)

def update(self, active):
def update(self, active: _T) -> None:
raise NotImplementedError

def get_value(self):
def get_value(self) -> _T:
raise NotImplementedError

def enable(self):
def enable(self) -> None:
self.get()
self.old_value = self.get_value()
if self.old_value != self.active:
self.update(self.active)

def disable(self):
def disable(self) -> None:
if self.created:
self.obj.delete()
self.obj.flush()
else:
self.update(self.old_value)


class override_switch(_overrider):
class override_switch(_overrider[bool]):
"""
override_switch is a contextmanager for easier testing of switches.
@@ -64,41 +67,41 @@ def test_happy_mode_enabled():
"""
cls = get_waffle_switch_model()

def update(self, active):
def update(self, active: bool) -> None:
obj = self.cls.objects.get(pk=self.obj.pk)
obj.active = active
obj.save()
obj.flush()

def get_value(self):
def get_value(self) -> bool:
return self.obj.active


class override_flag(_overrider):
class override_flag(_overrider[Optional[bool]]):
cls = get_waffle_flag_model()

def update(self, active):
def update(self, active: Optional[bool]) -> None:
obj = self.cls.objects.get(pk=self.obj.pk)
obj.everyone = active
obj.save()
obj.flush()

def get_value(self):
def get_value(self) -> Optional[bool]:
return self.obj.everyone


class override_sample(_overrider):
class override_sample(_overrider[Union[bool, float]]):
cls = get_waffle_sample_model()

def get(self):
def get(self) -> None:
try:
self.obj = self.cls.objects.get(name=self.name)
self.created = False
except self.cls.DoesNotExist:
self.obj = self.cls.objects.create(name=self.name, percent='0.0')
self.created = True

def update(self, active):
def update(self, active: Union[bool, float]) -> None:
if active is True:
p = 100.0
elif active is False:
@@ -110,7 +113,7 @@ def update(self, active):
obj.save()
obj.flush()

def get_value(self):
def get_value(self) -> Union[bool, float]:
p = self.obj.percent
if p == 100.0:
return True
11 changes: 7 additions & 4 deletions waffle/utils.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
from __future__ import annotations

import hashlib
from typing import Any

from django.conf import settings
from django.core.cache import caches
from django.core.cache import BaseCache, caches

import waffle
from waffle import defaults


def get_setting(name, default=None):
def get_setting(name: str, default: Any = None) -> Any:
try:
return getattr(settings, 'WAFFLE_' + name)
except AttributeError:
return getattr(defaults, name, default)


def keyfmt(k, v=None):
def keyfmt(k: str, v: str | None = None) -> str:
prefix = get_setting('CACHE_PREFIX') + waffle.__version__
if v is None:
key = prefix + k
@@ -23,6 +26,6 @@ def keyfmt(k, v=None):
return key


def get_cache():
def get_cache() -> BaseCache:
CACHE_NAME = get_setting('CACHE_NAME')
return caches[CACHE_NAME]
10 changes: 7 additions & 3 deletions waffle/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from django.http import HttpResponse, JsonResponse
from __future__ import annotations

from typing import Any

from django.http import HttpRequest, HttpResponse, JsonResponse
from django.template import loader
from django.views.decorators.cache import never_cache

@@ -12,7 +16,7 @@ def wafflejs(request):
content_type='application/x-javascript')


def _generate_waffle_js(request):
def _generate_waffle_js(request: HttpRequest) -> str:
flags = get_waffle_flag_model().get_all()
flag_values = [(f.name, f.is_active(request)) for f in flags]

@@ -37,7 +41,7 @@ def waffle_json(request):
return JsonResponse(_generate_waffle_json(request))


def _generate_waffle_json(request):
def _generate_waffle_json(request: HttpRequest) -> dict[str, dict[str, Any]]:
flags = get_waffle_flag_model().get_all()
flag_values = {
f.name: {