Skip to main content

Embeddable Python IP allocation library

Project description

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 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

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

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.

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.

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.

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.

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:

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.

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.

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.

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:

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):

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

# 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:

  • MemoryBackendthreading.RLock. Single-process by definition.
  • JSONFileBackendfilelock + atomic-rename + fsync(file) + fsync(parent dir). Crash-safe within POSIX rename semantics.
  • SQLAlchemyBackendSELECT … 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

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ipalloc-1.0.0.tar.gz (52.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ipalloc-1.0.0-py3-none-any.whl (48.6 kB view details)

Uploaded Python 3

File details

Details for the file ipalloc-1.0.0.tar.gz.

File metadata

  • Download URL: ipalloc-1.0.0.tar.gz
  • Upload date:
  • Size: 52.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for ipalloc-1.0.0.tar.gz
Algorithm Hash digest
SHA256 1ce1bb04851f20f6b4499d8172ac9208e4d6954783a0501b615287fd05058069
MD5 124b1324659b0aee6848a34019935024
BLAKE2b-256 55648cedd95379f110329b13b94c12408945f295563fe8e259f6f161b1d8487e

See more details on using hashes here.

Provenance

The following attestation bundles were made for ipalloc-1.0.0.tar.gz:

Publisher: release.yml on 0xMattijs/ipalloc

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ipalloc-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: ipalloc-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 48.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for ipalloc-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d328e1d98c1cb2a53734ae7f99299a27b5a81b684b1b75dc6d46ce013a870bd5
MD5 7f47a7cc9e2c840ee64f2253670c95a1
BLAKE2b-256 2a571ffc05fc613d3016bc49f5882a0ae961fc4f9eb98615c9e40f91907f30e5

See more details on using hashes here.

Provenance

The following attestation bundles were made for ipalloc-1.0.0-py3-none-any.whl:

Publisher: release.yml on 0xMattijs/ipalloc

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page