Metadata-Version: 2.4
Name: flagday
Version: 1.0.0
Summary: Simple, opinionated feature flags for Django.
Project-URL: Homepage, https://github.com/Xof/flagday
Project-URL: Source, https://github.com/Xof/flagday
Project-URL: Issues, https://github.com/Xof/flagday/issues
Project-URL: Changelog, https://github.com/Xof/flagday/blob/main/CHANGELOG.md
Author-email: Christophe Pettus <xof@thebuild.com>
License-Expression: MIT
License-File: LICENSE
Keywords: django,feature-flags,feature-toggle,flags
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Provides-Extra: dev
Requires-Dist: django-stubs[compatible-mypy]>=5.1; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-django>=4.9; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: test
Requires-Dist: pytest-django>=4.9; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# flagday

Simple, opinionated feature flags for Django.

[![PyPI version](https://img.shields.io/pypi/v/flagday.svg)](https://pypi.org/project/flagday/)
[![Python versions](https://img.shields.io/pypi/pyversions/flagday.svg)](https://pypi.org/project/flagday/)
[![Django versions](https://img.shields.io/badge/Django-4.2%20%7C%205.1%20%7C%205.2%20%7C%206.0-blue.svg)](https://pypi.org/project/flagday/)
[![CI](https://github.com/Xof/flagday/actions/workflows/ci.yml/badge.svg)](https://github.com/Xof/flagday/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/Xof/flagday/blob/main/LICENSE)

## Why flagday?

flagday gives you dynamic, database-backed feature flags for Django with a deliberately tiny surface: read a flag with `flag.something`, set it with `flag.something = value`. No third-party SaaS, no gRPC, no per-request fan-out — just a Django model and a thin attribute-style accessor.

flagday is **stateless by design**: every read does its own DB query. That means changes are visible immediately to every process without cache invalidation logic, and it means flagday is wrong for hot paths that read the same flag thousands of times per request. If you need caching, wrap `flag` in your own thin layer — flagday will not grow one in v1.

## Installation

```bash
pip install flagday
```

Add `"flagday"` to `INSTALLED_APPS`:

```python
INSTALLED_APPS = [
    # ...
    "flagday",
]
```

Run migrations:

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

## Quick start

```python
from flagday import flag

# Read — returns the stored string, or False (default) if the flag doesn't exist.
if flag.maintenance_mode:
    show_maintenance_banner()

# Set — upserts the row.
flag.maintenance_mode = True
flag.api_rate_limit = "100"
```

## Reading flags

`flag.<name>` issues one DB lookup per call. There is no caching layer. Reads are synchronous (a blocking ORM query); offload them to a worker thread (e.g. `asgiref.sync.sync_to_async`) if you need a flag inside an `async` view.

When the flag does not exist, the return value is governed by the
`FLAGDAY_MISSING_FLAG_HANDLING` setting:

| Mode | Behavior |
|---|---|
| `"return_false"` *(default)* | returns `False` |
| `"return_empty"` | returns `""` |
| `"return_none"` | returns `None` |
| `"exception"` | raises `FlagdayNoSuchFlagException` |

Reading a name that begins with `_` (or a dunder) raises `AttributeError` *without* hitting the database, so Python's own attribute probes never turn into flag queries. A name that is an actual method of the singleton (e.g. `set_from_json`) resolves to that method on read — so it can't be read as a flag either. The reserved-name rule that *rejects* such names raises on writes and on `set_from_json` keys; see [Reserved names](#reserved-names).

## Setting flags

`flag.<name> = value` upserts the underlying row. Python values are translated to storage strings as follows:

| Python value | Stored value |
|---|---|
| `False` | `""` (empty string) |
| `True` | `"True"` |
| `None` | `NULL` |
| anything else | `str(value)` |

The `False` → `""` mapping is what lets adopters distinguish *missing* (NULL) from *false* (empty string) when introspecting the database directly. **All reads return strings** (or one of the missing-flag sentinels listed above), so adopters parse on the read side if they need a richer type.

```python
flag.maintenance = True       # stored as "True"
flag.feature_x = False        # stored as ""
flag.cleared = None           # stored as NULL
flag.threshold = 42           # stored as "42"
```

Assigning to a reserved name raises:

```python
flag._private = "x"           # AttributeError: '_private' is reserved...
flag.set_from_json = "x"      # AttributeError: 'set_from_json' is reserved...
```

Flag names are limited to **255 characters** (the model's `name` column); assigning a longer name raises `ValueError` before any write.

## Bulk loading from JSON

`flag.set_from_json(blob)` accepts a JSON object or a list of objects, and upserts every key inside a single transaction (so a late failure rolls back earlier writes).

```python
flag.set_from_json('{"feature_a": true, "feature_b": "experiment-2"}')

flag.set_from_json('[{"feature_a": true}, {"feature_b": "experiment-2"}]')
```

The blob must be `str`, `bytes`, or `bytearray`. Values must be JSON scalars (`string`, `number`, `bool`, `null`); nested objects and arrays raise `FlagdayInvalidJSONException`. Keys longer than 255 characters raise `FlagdayInvalidJSONException` as well.

## Configuration

Only one setting:

```python
# settings.py
FLAGDAY_MISSING_FLAG_HANDLING = "return_false"  # default
```

Valid values: `"return_false"`, `"return_empty"`, `"return_none"`, `"exception"`. Anything else raises `ImproperlyConfigured` the next time a missing flag is read.

## Templates

Add the context processor:

```python
TEMPLATES = [
    {
        # ...
        "OPTIONS": {
            "context_processors": [
                # ...
                "flagday.context_processors.flag",
            ],
        },
    }
]
```

Then in templates:

```html
{% if flag.maintenance_mode %}
    <p>The site is under maintenance.</p>
{% endif %}
```

## Reserved names

flagday rejects two classes of flag names:

1. **Underscore-prefixed names** (`_foo`, `__bar__`). These short-circuit at the attribute layer to avoid being confused with Python's internal protocol attributes (dunders, name-mangled attributes).
2. **Names that match an actual method on the singleton.** Today the only such method is `set_from_json`, but the rule is general: if the name resolves on `type(flag)`, it cannot be used as a flag name.

Reserved names raise `AttributeError` when assigned via `flag.<name> = ...`. When supplied as a key to `set_from_json`, they raise `FlagdayInvalidJSONException` instead.

## Exceptions

```python
from flagday import (
    FlagdayException,                # base class for everything below
    FlagdayNoSuchFlagException,      # raised by flag.<name> when missing + mode="exception"
    FlagdayInvalidJSONException,     # raised by flag.set_from_json on bad input
)
```

`FlagdayNoSuchFlagException` exposes the missing flag name as `.flag_name`.

## Versioning & compatibility

flagday follows [Semantic Versioning](https://semver.org/). The 1.0.0 release is `Production/Stable`; the public API is `from flagday import flag, Flag, FlagdayException, FlagdayNoSuchFlagException, FlagdayInvalidJSONException` and the `FLAGDAY_MISSING_FLAG_HANDLING` setting.

Supported runtimes:

- **Python:** 3.10, 3.11, 3.12, 3.13
- **Django:** 4.2 LTS, 5.1, 5.2, 6.0

Both declarations are open-ended: `Django>=4.2` and `requires-python = ">=3.10"` have no upper bound. The versions listed above are the ones exercised in CI; newer Python and Django releases are presumed compatible until proven otherwise.

## Development

```bash
git clone https://github.com/Xof/flagday.git
cd flagday
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

pytest                  # run tests
ruff check .            # lint
ruff format --check .   # format check
mypy src tests          # type-check
```

For contributors: [ARCHITECTURE.md](ARCHITECTURE.md) is the component map, invariants, and landmines (the fast cold-start reference); [THEORY.md](THEORY.md) is the design rationale — why flagday is stateless, the NULL/empty-string distinction, and the reserved-name machinery.

## License

MIT — see [LICENSE](https://github.com/Xof/flagday/blob/main/LICENSE).
