Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "monthly"
cooldown:
default-days: 30
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
cooldown:
default-days: 30
55 changes: 55 additions & 0 deletions .github/workflows/code-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Tests
on:
push:
branches: [ main ]
paths-ignore:
- 'docs/**'
- '*.md'
pull_request:
branches: [ main ]
paths-ignore:
- 'docs/**'
- '*.md'

permissions:
actions: read
contents: read
pull-requests: read

jobs:
test:
name: Tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.12", "3.13" ]
fail-fast: false
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: knobs_example
POSTGRES_USER: knobs
POSTGRES_PASSWORD: knobs
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- id: setup-uv
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
with:
enable-cache: true
cache-suffix: ${{ matrix.python-version }}
version: "latest"
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: uv sync --all-extras
- name: Run tests
run: uv run pytest
30 changes: 30 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Documentation
on:
push:
branches:
- main

permissions:
contents: read
pages: write
id-token: write

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/configure-pages@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: pip install zensical
- run: zensical build --clean
- uses: actions/upload-pages-artifact@v4
with:
path: site
- uses: actions/deploy-pages@v4
id: deployment
33 changes: 33 additions & 0 deletions .github/workflows/release-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Release docs
on:
push:
branches: [ main ]

permissions:
contents: write
pages: write
id-token: write

jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: true
- uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
with:
enable-cache: true
python-version: "3.12"
version: "latest"
- run: uv sync --only-group docs
- run: uv run zensical build --clean
- uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with:
path: site
- uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
id: deployment
28 changes: 28 additions & 0 deletions .github/workflows/release-pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Release PyPI Package

on:
push:
tags:
# Publish on any tag starting with a `v`, e.g. v1.2.3
- v*

jobs:
pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
environment:
name: release
permissions:
id-token: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
with:
enable-cache: false
python-version: "3.12"
version: "latest"
- run: uv version "${GITHUB_REF_NAME}"
- run: uv build
- run: uv publish --trusted-publishing always
14 changes: 14 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
fail_fast: false
default_language_version:
python: python3.12
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.5
hooks:
- id: ruff-check
args: [--fix]

- repo: builtin
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.PHONY: run_infra migrate makemigrations createsuperuser run

run_infra:
docker compose up -d

##@ Example app

migrate:
cd example_app && uv run python manage.py migrate

makemigrations:
cd example_app && uv run python manage.py makemigrations

createsuperuser:
cd example_app && DJANGO_SUPERUSER_PASSWORD=admin uv run python manage.py createsuperuser --username admin --email admin@example.com --noinput

run:
cd example_app && uv run granian --interface wsgi example_app.wsgi:application --blocking-threads 2 --reload
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,44 @@
# django-knobs
Django library for dynamic settings

[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-knobs?style=for-the-badge&logo=python)](https://pypi.org/project/django-knobs/)
[![PyPI](https://img.shields.io/pypi/v/django-knobs?style=for-the-badge&logo=pypi)](https://pypi.org/project/django-knobs/)
[![Checks](https://img.shields.io/github/check-runs/danfimov/django-knobs/main?nameFilter=Tests%20(3.12)&style=for-the-badge)](https://github.com/danfimov/django-knobs)

Library for dynamic settings / feature flags that can be changed at runtime without restarting the application from Django admin panel.

```bash
pip install django-knobs
```

![](docs/assets/banner.png)


## Setup

**1. Add to `INSTALLED_APPS`:**

```python
INSTALLED_APPS = [
...
"knobs",
]
```

**2. Run migrations:**

```bash
python manage.py migrate
```

**3. Define your config values in `settings.py`:**

```python
from knobs import Knob

KNOBS_CONFIG = {
"MAX_LOGIN_ATTEMPTS": Knob(default=5, help_text="Max failed logins before lockout", category="auth"),
"FEATURE_NEW_UI": Knob(default=False, help_text="Enable redesigned UI", category="features"),
"API_TIMEOUT": Knob(default=30.0, help_text="Outbound request timeout (seconds)", category="api"),
"WELCOME_MSG": Knob(default="Hello!", help_text="Welcome banner text", category="general"),
}
```
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: knobs_example
POSTGRES_USER: knobs
POSTGRES_PASSWORD: knobs
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U knobs -d knobs_example"]
interval: 3s
timeout: 5s
retries: 10
Binary file added docs/assets/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 75 additions & 0 deletions docs/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# How Caching Works

## Overview

django-knobs uses a three-tier architecture to make reads zero-latency:

```
knobs.MY_SETTING
LocalCache (in-process dict) ← only read source, zero latency
│ full reload when MAX(updated_at) changes
SyncThread (daemon) ──────────────────────────► KnobValue (DB)
immediate write on admin save
+ post_save signal → LocalCache
```

## Tier 1 — Local In-Process Cache

`LocalCache` is a plain Python dict protected by a `threading.RLock`. Reading a knob value is a single dict lookup — no network, no serialization overhead, no blocking.

Each process has its own `LocalCache`. They are independent; writes to one do not automatically propagate to others.

## Tier 2 — Background Sync Thread

A daemon thread (`knobs-sync`) wakes up every `SYNC_INTERVAL` seconds and runs:

```sql
SELECT MAX(updated_at) FROM knobs_knobvalue
```

If the result changed since the last check, it issues a second query to fetch all rows and rebuilds the local cache atomically. This means:

- **No change:** one cheap query, nothing else.
- **Any change:** one more query to fetch all rows.

The reload replaces the entire cache at once (not entry by entry), so readers always see a consistent snapshot.

## Startup Sync

When `STARTUP_SYNC = True` (default), `AppConfig.ready()` calls `_sync()` synchronously before the first request. This ensures the cache is populated with DB values before any traffic hits the server.

## Admin Save — Same-Process Instant Update

When a `KnobValue` is saved (e.g., via the Django admin), the `post_save` signal fires `knob_post_change` and immediately calls `_cache.set(name, coerced_value)` in the same process. No waiting for the next sync cycle.

Other processes pick up the change at their next sync tick, within `SYNC_INTERVAL` seconds.

## Comparison with django-constance

| | django-knobs | django-constance |
|---|---|---|
| Per-request database call | Never | Always |
| Cross-process propagation | Within `SYNC_INTERVAL` seconds | Immediate (shared cache) |
| Dependency on external cache | None | Required (Redis/Memcached) |
| Latency for reading a value | ~50 ns (dict lookup) | ~1–5 ms (cache hit) |

django-knobs trades instant cross-process propagation for zero per-request overhead. This is the right trade-off for most settings that change infrequently.

## Signals

`knob_post_change` is fired after a value is saved, in the same process:

```python
from knobs.signals import knob_post_change

def on_change(sender, name, old_value, new_value, **kwargs):
print(f"{name} changed from {old_value!r} to {new_value!r}")

knob_post_change.connect(on_change)
```

`knob_pre_change` is available for pre-save validation hooks (not yet wired to admin save — use Django's model validation for that).
Loading