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.mdand is the source of truth for design decisions. Native per-backend async (asyncpg,aiosqlite) is deferred to v2; v1 ships anasyncio.to_threadwrapper 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 subclassAllocationPolicy.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:
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 UPDATEon the pool row (Postgres, MySQL);BEGIN IMMEDIATEon SQLite. WAL +synchronous=NORMALpragmas 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1ce1bb04851f20f6b4499d8172ac9208e4d6954783a0501b615287fd05058069
|
|
| MD5 |
124b1324659b0aee6848a34019935024
|
|
| BLAKE2b-256 |
55648cedd95379f110329b13b94c12408945f295563fe8e259f6f161b1d8487e
|
Provenance
The following attestation bundles were made for ipalloc-1.0.0.tar.gz:
Publisher:
release.yml on 0xMattijs/ipalloc
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ipalloc-1.0.0.tar.gz -
Subject digest:
1ce1bb04851f20f6b4499d8172ac9208e4d6954783a0501b615287fd05058069 - Sigstore transparency entry: 1412250090
- Sigstore integration time:
-
Permalink:
0xMattijs/ipalloc@2ee4fa15652afa49777aa7792d2bc503ad814127 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/0xMattijs
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2ee4fa15652afa49777aa7792d2bc503ad814127 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d328e1d98c1cb2a53734ae7f99299a27b5a81b684b1b75dc6d46ce013a870bd5
|
|
| MD5 |
7f47a7cc9e2c840ee64f2253670c95a1
|
|
| BLAKE2b-256 |
2a571ffc05fc613d3016bc49f5882a0ae961fc4f9eb98615c9e40f91907f30e5
|
Provenance
The following attestation bundles were made for ipalloc-1.0.0-py3-none-any.whl:
Publisher:
release.yml on 0xMattijs/ipalloc
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ipalloc-1.0.0-py3-none-any.whl -
Subject digest:
d328e1d98c1cb2a53734ae7f99299a27b5a81b684b1b75dc6d46ce013a870bd5 - Sigstore transparency entry: 1412250237
- Sigstore integration time:
-
Permalink:
0xMattijs/ipalloc@2ee4fa15652afa49777aa7792d2bc503ad814127 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/0xMattijs
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2ee4fa15652afa49777aa7792d2bc503ad814127 -
Trigger Event:
push
-
Statement type: