Metadata-Version: 2.4
Name: ipalloc
Version: 1.0.0
Summary: Embeddable Python IP allocation library
Project-URL: Homepage, https://github.com/0xMattijs/ipalloc
Project-URL: Repository, https://github.com/0xMattijs/ipalloc
Project-URL: Issues, https://github.com/0xMattijs/ipalloc/issues
Project-URL: Changelog, https://github.com/0xMattijs/ipalloc/blob/main/CHANGELOG.md
Author-email: Mattijs van Ommeren <mattijs@qlibr.com>
License: MIT
License-File: LICENSE
Keywords: allocation,ip,ipam,network,pool
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
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 :: System :: Networking
Requires-Python: >=3.10
Requires-Dist: netaddr>=1.0
Provides-Extra: all
Requires-Dist: filelock>=3.12; extra == 'all'
Requires-Dist: sqlalchemy>=2.0; extra == 'all'
Provides-Extra: file
Requires-Dist: filelock>=3.12; extra == 'file'
Provides-Extra: sql
Requires-Dist: sqlalchemy>=2.0; extra == 'sql'
Description-Content-Type: text/markdown

# ipalloc

An embeddable Python library for IP allocation pools. Sits in the gap between
"ten lines of `ipaddress`" and "deploy a full IPAM service" — the same shape
as a typed dict on top of a pool, with persistence, sticky-by-key leases,
TTL expiry, sub-pool delegation, and three pluggable backends.

Library first. The CLI is a thin shim over the same public API.

> **Status:** v1.0. The PRD lives at [`docs/introduction.md`](docs/introduction.md)
> and is the source of truth for design decisions. Native per-backend async
> (`asyncpg`, `aiosqlite`) is deferred to v2; v1 ships an `asyncio.to_thread`
> wrapper with the same surface.

## Install

```bash
pip install ipalloc                 # core: memory backend only
pip install ipalloc[file]           # adds JSON file backend
pip install ipalloc[sql]            # adds SQL backends
pip install ipalloc[all]            # everything
```

Core dependency is `netaddr`. `[file]` adds `filelock`; `[sql]` adds
`sqlalchemy>=2.0`. Python 3.10+. Licensed MIT.

## Quick start

```python
from ipalloc import Store, RandomPolicy

with Store.open("memory://") as store:
    pool = store.create_pool(
        name="vpn-prod",
        ranges=["10.0.0.0/24", "10.0.2.0/24"],
        policy=RandomPolicy(),
        exclude=["10.0.0.1"],          # gateway
        tags={"tenant": "acme", "env": "prod"},
    )

    ip = pool.allocate(key="laptop-42", ttl=3600, metadata={"user": "alice"})
    print(ip)                           # e.g. 10.0.0.137

    pool.renew("laptop-42", ttl=3600)   # extend the lease
    pool.release("laptop-42")
```

`Store.open` dispatches by URI scheme:

| Scheme | Backend | Notes |
|---|---|---|
| `memory://` | `MemoryBackend` | single-process, ephemeral |
| `file:///path/state.json` | `JSONFileBackend` | one pool per file, atomic rename + fsync |
| `sqlite:///path` | `SQLAlchemyBackend` | WAL + `BEGIN IMMEDIATE` |
| `postgresql://…`, `mysql://…`, anything SQLAlchemy speaks | `SQLAlchemyBackend` | `SELECT … FOR UPDATE` per-pool row |

## Concepts

- **`Store`** — persistence root. Holds one pool (file backend) or many (memory, SQL).
- **`Pool`** — a named address space (CIDRs and/or ranges, IPv4 and/or IPv6) with a policy and boundary configuration.
- **`Allocation`** — one IP (or one delegated CIDR for sub-pools), keyed by a caller-supplied string and optionally TTL'd.
- **`Policy`** — pluggable strategy that picks IPs from the free set. Built-ins: `HeadPolicy`, `TailPolicy`, `RandomPolicy`. Custom policies subclass `AllocationPolicy`.
- **`Backend`** — the persistence Protocol. Built-ins above; third-party backends register against the same shape.

## Behaviors that aren't obvious

### Sticky-by-key

`pool.allocate(key)` first checks for an existing live allocation under that
key — if one exists, it returns the same IP. The key is required and unique
among non-expired entries within a pool.

```python
ip1 = pool.allocate("alice", ttl=60)
ip2 = pool.allocate("alice", ttl=60)
assert ip1 == ip2                    # sticky reuse within the lease
```

Sticky reuse extends through TTL expiry as long as the IP hasn't been
reassigned to someone else: the same key gets the same IP back even after
the lease lapses.

### TTL and lazy reclaim

`ttl=None` (the default) means permanent. Otherwise, allocations carry an
`expires_at` and are reclaimed lazily on access — there is no background
thread, which keeps the library fully embeddable.

The lazy sweep runs on `allocate`, `is_allocated`, `utilization`,
`allocations`, `get_allocation`, and bulk operations. It is opportunistic:
read methods upgrade to a write transaction only when expired entries
actually exist; otherwise they stay pure. `expire` events fire at most once
per expiration per `Pool` instance, so hook-based DHCP/DNS integrations
get notified even without a follow-up mutation.

### Reservations

`pool.reserve(ip, key, ttl=…)` pins a specific IP under a key. Wins over an
*expired* sticky stub (emitting an `expire` event for the displaced entry);
a live allocation under a different key raises `KeyConflict`.

```python
pool.reserve("10.0.0.5", "gateway")
```

### Bulk operations

`pool.allocate_many(keys, ttl=…)` and `pool.release_many(keys)` are atomic
and all-or-nothing. On insufficient capacity, `allocate_many` raises
`PoolExhausted` (with `available_count`) **before** any mutation. Existing
live allocations for keys in the batch are returned as-is — only missing
keys consume new IPs.

```python
ips = pool.allocate_many(["a", "b", "c"], ttl=3600)
# {"a": "10.0.0.0", "b": "10.0.0.1", "c": "10.0.0.2"}
```

### Sub-pools

Carve an aligned `/N` block from a parent pool and hand it back as a child
`Pool` with its own policy and lifecycle.

```python
child = pool.allocate_subpool(prefix_length=28, key="tenant-acme",
                              policy=HeadPolicy())
child_ip = child.allocate("vm-001")
```

Boundary defaults (`exclude_network`, `exclude_broadcast`, `exclude=[…]`)
inherit from the parent and can be overridden. `tags` are not inherited
(they're descriptive, not boundary). `release_subpool(key)` returns the
delegated CIDR to the parent — refused if the child has live allocations.

Recursive sub-pools (children of children) work; no depth limit.

## Policies

Three built-ins. All implement `select(free_set, count=1, contiguous_prefix=None)`:

- `HeadPolicy` — lowest free IP first; compact allocation, leaves large free blocks.
- `TailPolicy` — highest free IP first.
- `RandomPolicy(seed=None)` — uniform random over the free set. IPv6-safe — picks random offsets within total cardinality and walks the underlying CIDRs without enumeration.

Custom policies subclass `AllocationPolicy`:

```python
from ipalloc import AllocationPolicy, register_policy

@register_policy
class MyPolicy(AllocationPolicy):
    name = "mine"
    def select(self, free_set, count=1, contiguous_prefix=None):
        ...
```

Registered policies survive serialization round-trips through any backend.

## Hooks

Pools accept callbacks invoked on allocation lifecycle events.

```python
from ipalloc import LoggingHook
from ipalloc.hooks import LoggingHook   # also works

pool.add_hook("allocate", LoggingHook())
pool.add_hook(None, my_callback)        # wildcard: every event
```

Event actions: `allocate` (including sticky reuse), `release`, `reserve`,
`renew`, `expire`. Hooks fire **after commit** by default — failures are
logged but don't affect the operation. Pass `pre_commit=True` to register
a hook that fires inside the transaction and may raise to abort the
operation.

```python
def deny_if_quota_exceeded(event):
    if user_over_quota(event.metadata["user"]):
        raise PermissionError(f"quota exceeded for {event.key!r}")

pool.add_hook("allocate", deny_if_quota_exceeded, pre_commit=True)
```

## Audit log

Append-only, separate from current state. Querying state never touches the
audit log; both stay fast.

```python
from datetime import datetime, timedelta, timezone

since = datetime.now(timezone.utc) - timedelta(days=7)
for entry in store.audit("vpn-prod", since=since, action="allocate"):
    print(entry.timestamp, entry.key, entry.ip_or_cidr, entry.actor)

# Manual retention; no automatic cleanup.
store.prune_audit(before=since)
```

Each entry carries `(timestamp, pool_name, action, ip_or_cidr, key, actor, metadata)`.
`actor` is an opaque caller-supplied string — the library never interprets it.

## Async API

Same surface, `to_thread`-backed in v1:

```python
from ipalloc import AsyncStore

async def main():
    async with await AsyncStore.open("postgresql://user@host/db") as store:
        pool = await store.get_pool("vpn-prod")
        ip = await pool.allocate("laptop-42", ttl=3600)

        async def notify_dhcp(event):
            await dhcp_client.bind(event.ip, event.key)

        pool.add_hook("allocate", notify_dhcp)   # async hooks supported
        pool.add_hook("expire", notify_dhcp)
```

Both `def` and `async def` hooks work in either pre-commit or post-commit
position. Async pre-commit hooks run to completion in a fresh event loop on
the worker thread — they must not await back into the same `AsyncPool` or
they'll deadlock.

The v1 async API is identical in shape to the planned native-async v2
implementation; users adopting it now won't see breakage at the upgrade.

## CLI

`ipalloc` is registered as a console script (stdlib argparse — no `click`):

```bash
ipalloc --store sqlite:///pools.db pool create vpn-prod \
    --cidr 10.0.0.0/24 --policy random --exclude 10.0.0.1 \
    --tag tenant=acme --tag env=prod

ipalloc --store sqlite:///pools.db allocate vpn-prod \
    --key laptop-42 --ttl 1h

ipalloc --store sqlite:///pools.db release vpn-prod --key laptop-42

ipalloc --store sqlite:///pools.db pools --tag env=prod --json
```

`--ttl` accepts seconds (`30`) or with a unit suffix (`30s`, `5m`, `2h`, `7d`).
`--json` flag on read commands emits machine-readable output.

Exit codes: `0` success, `1` generic, `2` `PoolExhausted`, `3` `KeyConflict`,
`4` not found.

## Import / export

```bash
# Full round-trip: pool config + allocations (+ optional audit)
ipalloc --store sqlite:///src.db export vpn-prod --format json --out dump.json
ipalloc --store sqlite:///dst.db import --format json dump.json

# Allocations-only spreadsheet workflow
ipalloc --store sqlite:///pools.db export vpn-prod --format csv --out ips.csv

# /etc/hosts fragment / DNS zone seed
ipalloc --store sqlite:///pools.db export vpn-prod --format hosts --out hosts.frag
```

JSON import flags: `--mode merge|replace|dry-run`, `--key-conflict skip|update|fail`.

## Concurrency

Each backend implements a transaction context that wraps the read-policy-write
sequence atomically:

- **`MemoryBackend`** — `threading.RLock`. Single-process by definition.
- **`JSONFileBackend`** — `filelock` + atomic-rename + fsync(file) + fsync(parent dir). Crash-safe within POSIX rename semantics.
- **`SQLAlchemyBackend`** — `SELECT … FOR UPDATE` on the pool row (Postgres, MySQL); `BEGIN IMMEDIATE` on SQLite. WAL + `synchronous=NORMAL` pragmas enabled by default.

The contract suite (`tests/_contract.py`) runs against all three backends.
`tests/test_concurrency.py` drives parallel processes against a shared
SQLite/JSON-file store and asserts no double allocation under contention.

## Schema versioning

JSON file format is versioned (`SCHEMA_VERSION = 1`). The library reads
`v=current` and `v=current-1` (one-step forward migration via the
`MIGRATIONS` registry in `_serde.py`). Older state files raise
`SchemaVersionError` with guidance to pin an older `ipalloc` release first
to migrate up.

## Out of scope for v1

DHCP/DNS integration (use hooks), web UI / REST API, ACLs / multi-tenant
auth, per-pool/per-key quotas, automatic audit retention, allocation
tagging policies. A WAL-based file backend is deferred — if your embedded
deployment outgrows JSON, switch to SQLite.

## Development

```bash
uv sync --all-extras                 # creates .venv, installs runtime + dev deps
uv run pytest                        # 224 passing, 10 skipped
uv run --group docs mkdocs serve     # build the docs site at http://127.0.0.1:8000
```

Tests are a single `BackendContractTests` mixin parameterized over IPv4 and
IPv6 — every backend runs the same suite. JSON-file skips a small subset of
multi-pool tests because of its single-pool-per-file contract.

## License

MIT. See [`pyproject.toml`](pyproject.toml).
