Metadata-Version: 2.4
Name: toggleflag
Version: 0.3.0
Summary: Lightweight feature flags with zero dependencies — file, env, or code config
Author-email: Ravi Teja Prabhala Venkata <raviteja.prabhala@gmail.com>
License: MIT
Keywords: feature-flags,feature-toggles,a-b-testing
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"

# toggleflag

[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Tests](https://img.shields.io/badge/tests-87%20passed-brightgreen.svg)](tests/)

**Lightweight feature flags for Python — no server, no dependencies.**

```python
import toggle

flags = toggle.Flags()

flags.define("new_checkout", default=True)
flags.define("experiment_v2", variants=["control", "treatment"], rollout_pct=30)

flags.is_on("new_checkout")                              # True
flags.variant("experiment_v2", user_id="u1")            # "treatment" (30% of the time)

with flags.context(user_id="u1", country="US"):
    flags.is_on("us_only_feature")                       # checks per-user + per-country
```

---

## Why toggleflag?

Feature flags are essential for modern software delivery. They enable teams to ship code behind toggles, decouple deployment from release, run A/B experiments, and instantly disable broken features in production.

Existing solutions have significant trade-offs:

| **Concern** | **toggleflag** | **LaunchDarkly** | **Unleash** | **django-flags** |
|---|---|---|---|---|
| Requires a server | ❌ No | ✅ Yes | ✅ Yes | ❌ No |
| External dependencies | **Zero** | SDK dependency | SDK dependency | Django framework |
| Setup time | `< 1 minute` | Account + SDK setup | Self-hosted cluster | Django project |
| Per-user targeting | ✅ | ✅ | ✅ | Limited |
| Percentage rollout | ✅ Deterministic | ✅ | ✅ | ❌ |
| Country targeting | ✅ | ✅ (paid) | ✅ (enterprise) | ❌ |
| A/B testing variants | ✅ | ✅ (paid) | ✅ | ❌ |
| Context propagation | ✅ | ✅ | ✅ | ❌ |
| Live config reload | ✅ File watching | ✅ Streaming | ✅ Polling | ✅ |
| Thread-safe | ✅ | ✅ | ✅ | ✅ |
| Pricing | Free (MIT) | $10–$500+/mo | Free (OSS) / Enterprise | Free |
| Python-only | ✅ | Multi-language | Multi-language | Python (Django) |

**toggleflag** fills a gap: a zero-dependency, pure-Python feature flag library that supports sophisticated targeting rules without requiring any infrastructure.

---

## Installation

```bash
pip install toggleflag
```

That's it. No servers to configure. No databases to provision. No accounts to create.

---

## Quick Start

### Boolean Flags

```python
import toggle

flags = toggle.Flags()

flags.define("dark_mode", default=True)
flags.define("maintenance_mode", default=False)

flags.is_on("dark_mode")          # True
flags.is_off("maintenance_mode")  # True
```

### Percentage Rollout

```python
flags.define("new_checkout", default=False, rollout_pct=50)

# Deterministic: same user always gets the same result
flags.is_on("new_checkout", user_id="alice")   # True (consistent)
flags.is_on("new_checkout", user_id="bob")     # False (consistent)
```

### A/B Testing with Variants

```python
flags.define("pricing_page", variants=["control", "variant_a", "variant_b"], rollout_pct=40)

# Returns a variant string, or None if the user is outside the rollout
flags.variant("pricing_page", user_id="u1")  # "variant_a"
flags.variant("pricing_page", user_id="u2")  # None (outside 40% rollout)
```

### Per-User Targeting

```python
# Beta access for specific users
flags.define("beta_features", default=False, user_ids=["alice", "bob", "charlie"])

flags.is_on("beta_features", user_id="alice")   # True
flags.is_on("beta_features", user_id="dave")    # False

# Per-user overrides (force on/off regardless of other rules)
flags.define("experiment", default=False, rollout_pct=10,
             overrides={"alice": True, "bob": False})

flags.is_on("experiment", user_id="alice")  # True (forced on)
flags.is_on("experiment", user_id="bob")    # False (forced off)
```

### Country Targeting

```python
flags.define("gdpr_consent_flow", default=True, countries=["DE", "FR", "GB"])

flags.is_on("gdpr_consent_flow", country="US")  # False
flags.is_on("gdpr_consent_flow", country="DE")  # True
```

### Context Propagation

```python
with flags.context(user_id="alice", country="US"):
    # All flag checks within this block use alice/US context
    flags.is_on("beta_features")      # True (alice is in user_ids)
    flags.is_on("us_only_promo")      # True (US country match)
    flags.variant("experiment_v2")    # Deterministic variant for alice

    # Nested contexts merge with parent
    with flags.context(country="DE"):
        flags.is_on("gdpr_consent_flow")  # True (DE, alice's user_id preserved)

# Explicit arguments override context
with flags.context(user_id="alice"):
    flags.is_on("beta_features", user_id="bob")  # False (explicit arg wins)
```

### File-Based Configuration

```json
{
  "new_checkout": {"default": true, "rollout_pct": 50},
  "dark_mode": {"default": true},
  "experiment_v2": {
    "variants": ["control", "treatment"],
    "rollout_pct": 30,
    "countries": ["US", "GB"]
  },
  "beta_features": {
    "default": false,
    "user_ids": ["alice", "bob"]
  }
}
```

```python
flags = toggle.Flags()
flags.load_file("flags.json")
```

### Environment Variables

```bash
export TOGGLE_DARK_MODE=true
export TOGGLE_NEW_CHECKOUT=50
export TOGGLE_EXPERIMENT=50:treatment,control
```

```python
flags = toggle.Flags()
flags.load_env()

# Custom prefix
flags.load_env(prefix="APP_")  # reads APP_DARK_MODE=true, etc.
```

### Auto-Reload File Changes

```python
watcher = flags.watch("flags.json", poll_interval=2.0)

# In your request handler or background loop:
if watcher.changed():
    flags.load(watcher.read())
    print("Flags reloaded!")
```

---

## Deterministic Rollout

Percentage-based targeting in toggleflag is **deterministic** — the same user always gets the same result, across requests, servers, and restarts.

This works by computing `MD5(flag_name + ":" + user_id) % 100` and comparing against `rollout_pct`. The result depends only on the flag name and user identifier, not on random state.

```python
flags.define("experiment", variants=["control", "treatment"], rollout_pct=30)

# These calls always return the same value for the same user
flags.variant("experiment", user_id="alice")  # Always "treatment"
flags.variant("experiment", user_id="bob")    # Always None
```

**Why MD5?** MD5 is fast (important for request-time evaluation), uniformly distributed, and deterministic. While not cryptographically secure, that's irrelevant here — we're bucketing users, not protecting secrets.

**No user ID?** When no user identifier is provided, the system uses a default sentinel value, meaning all anonymous users get the same result. For meaningful rollouts, always provide a `user_id`.

---

## API Reference

### `Flags()`

Create a new flag engine instance. Thread-safe.

### `flags.define(name, *, default=False, rollout_pct=?, variants=None, user_ids=None, countries=None, overrides=None)`

Define a feature flag.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `name` | `str` | — | Flag identifier |
| `default` | `bool` | `False` | Default on/off state (used when no rollout_pct is set) |
| `rollout_pct` | `int` | auto | 0–100+ percentage of users who see the flag |
| `variants` | `list[str]` | `[]` | Variant names for A/B testing |
| `user_ids` | `list[str]` | `None` | Allow-list of user IDs |
| `countries` | `list[str]` | `None` | Allow-list of country codes |
| `overrides` | `dict` | `{}` | Per-user forced values `{"user_id": True/False/"variant"}` |

### `flags.is_on(name, *, user_id=None, country=None) → bool`

Check if a flag is on for the given context.

### `flags.is_off(name, **kwargs) → bool`

Inverse of `is_on`.

### `flags.variant(name, *, user_id=None, country=None) → str | None`

Return the A/B variant for the user, or `None` if outside rollout.

### `flags.context(*, user_id=None, country=None, **extra) → ContextManager`

Push a temporary evaluation context. Nestable — child contexts merge with parents.

### `flags.load(data: dict)` / `flags.load_file(path)` / `flags.load_env(prefix="TOGGLE_")`

Bulk-load flag definitions from a dict, JSON/YAML file, or environment variables.

### `flags.watch(path, poll_interval=2.0) → FileWatcher`

Create a file watcher for live config reloading.

### `flags.list_flags() → list[str]` / `flags.get(name) → FlagDef | None` / `flags.undefine(name)` / `flags.clear()`

Introspection and management methods.

---

## Design Philosophy

1. **Zero dependencies.** Pure Python 3.10+. No external packages. No database. No network calls. `pip install` and go.

2. **Deterministic by default.** Same user, same flag, same result — always. No random state to corrupt or drift.

3. **Configuration flexibility.** Programmatic API, JSON/YAML files, environment variables, or any combination. Use what fits your deployment.

4. **Targeting composure.** User IDs, countries, percentages, and overrides compose naturally. Each rule is a filter; they stack.

5. **No infrastructure required.** Unlike LaunchDarkly or Unleash, toggleflag needs no server, database, or cluster. A JSON file is sufficient for production use.

6. **Thread-safe.** All flag reads and writes are protected by locks. Safe for multi-threaded web servers (Django, Flask, FastAPI).

7. **Explicit is better than implicit.** Flags must be defined before use. Undefined flags return `False` rather than raising errors — fail soft in production.

---

## Use Cases

### A/B Testing
```python
flags.define("pricing_page", variants=["control", "variant_a", "variant_b"], rollout_pct=50)

variant = flags.variant("pricing_page", user_id=current_user.id)
if variant == "variant_a":
    return render_pricing_v2(request)
return render_pricing(request)
```

### Phased Rollout
```python
# Week 1: 10% of users
flags.define("new_api", default=False, rollout_pct=10)

# Week 2: Increase to 50%
flags.define("new_api", default=False, rollout_pct=50)

# Week 3: Full launch
flags.define("new_api", default=True)
```

### Canary Releases
```python
flags.define("v2_engine", default=False, user_ids=["alice", "bob", "qa_team"])

if flags.is_on("v2_engine", user_id=request.user.id):
    return v2_handler(request)
return v1_handler(request)
```

### Kill Switches
```python
# Disable a feature instantly without redeploying
flags.define("external_payments", default=True)

# In a config file — flip to false to disable
# {"external_payments": {"default": false}}
```

### Country-Specific Features
```python
flags.define("eu_compliance", default=True, countries=["DE", "FR", "IT", "ES", "GB"])
flags.define("us_tax_form", default=True, countries=["US"])
```

### Beta Programs
```python
flags.define("beta_ai_features", default=False, user_ids=beta_users)
```

---

## Testing

```bash
pip install -e ".[dev]"
pytest tests/ -v
```

87 tests covering all targeting rules, context propagation, file/env loading, thread safety, and edge cases.

---

## License

MIT © Ravi Teja Prabhala Venkata
