Skip to main content

eip-pydantic

PyPI version Python versions Tests codecov License: MIT

eip-pydantic is a Python package for the EfficientIP SolidServer REST API which leans heavily on Pydantic.

Here's how it differs from other packages in the EfficientIP space:

  • Based on Pydantic: all EfficientIP objects are mapped to Pydantic models, making it easy to integrate into existing Pydantic based systems (e.g. FastAPI)
  • Both async/await and sync styles supported
  • Unit-of-work pattern inspired by SQLAlchemy
  • Expression builder for filter and sort clauses inspired by SQLAlchemy
  • Full type annotations, passes pyright and mypy

Quick Example

Synchronous

from eip_pydantic import Session
from eip_pydantic.models import IpAddress, RowEnabled, Subnet

with Session("solidserver.example.com", "admin", "secret") as session:
    # Three conditions AND-ed together: an ordinary field, a LIKE pattern, and
    # a custom class parameter ("environment") — the last of which requires no
    # special handling on your part, see "The expression builder" below.
    where = (
        (Subnet.c.site_id == "7")
        & (Subnet.c.subnet_name.like("%app-tier%"))
        & (Subnet.c.environment == "production")
    )
    subnet = session.one(Subnet, where=where)

    # Pydantic has already done real work parsing this row:
    print(subnet.subnet)          # IPv4Network('10.20.1.0/24') — decoded from wire-format hex
    print(subnet.subnet_level)    # int, not the wire's "1" string
    print(subnet.row_enabled is RowEnabled.ENABLED)  # a real enum, safely comparable — not a raw code
    print(subnet.parent_subnet_id)  # None if this is a top-level block ("0" on the wire)

    # subnet_id is frozen — the server assigns it, so mutating it is a bug we
    # want to catch immediately rather than on the next flush():
    try:
        subnet.subnet_id = 999999
    except Exception as exc:
        print(f"rejected as expected: {exc.__class__.__name__}")

    # Find a free address inside the subnet we just looked up.
    candidates = session.find_free_address(subnet=subnet, max_find=1)
    free_ip = candidates[0]

    # Register the candidate IP.
    new_host = session.create(
        IpAddress, subnet,
        hostaddr=str(free_ip.ip_addr),
        name="auto-provisioned",
        class_params={"environment": "production"},
    )
    session.flush()  # one POST; new_host.ip_id is now populated

    print(f"claimed {new_host.hostaddr} as {new_host.name} (id={new_host.ip_id})")

Asynchronous

import asyncio

from eip_pydantic import AsyncSession
from eip_pydantic.models import IpAddress, Subnet


async def main() -> None:
    async with AsyncSession("solidserver.example.com", "admin", "secret") as session:
        where = (
            (Subnet.c.site_id == "7")
            & (Subnet.c.subnet_name.like("%app-tier%"))
            & (Subnet.c.environment == "production")
        )
        subnet = await session.one(Subnet, where=where)

        candidates = await session.find_free_address(subnet=subnet, max_find=1)
        free_ip = candidates[0]

        new_host = session.create(
            IpAddress, subnet,
            hostaddr=str(free_ip.ip_addr),
            name="auto-provisioned",
            class_params={"environment": "production"},
        )
        await session.flush()

        print(f"claimed {new_host.hostaddr} as {new_host.name} (id={new_host.ip_id})")


asyncio.run(main())

Design philosophy

If you have used SQLAlchemy, the shape of this library will feel familiar. SolidServer's REST API is not a database, but it exposes a similar hierarchical, relational structure — spaces contain networks, networks contain pools and addresses, each object has a primary key, and objects reference each other by foreign key. eip-pydantic borrows three ideas from SQLAlchemy's ORM to make working with that structure pleasant:

SQLAlchemy eip-pydantic Purpose
Session Session / AsyncSession Unit-of-work object: tracks loaded objects, batches writes, exposes flush()
session.query(Model).filter(...) session.list(Model, where=...) Typed queries against a collection endpoint
Model.column == value Model.c.field == value Column expressions that build filter clauses instead of evaluating immediately
Identity map session.get(cls, pk) Only one Python object per (class, primary key) pair per session
Base declarative class SolidServerModel Common base class handling (de)serialization and dirty tracking

Installation

pip install eip-pydantic

Requires Python 3.11 or newer. The only runtime dependencies are httpx and pydantic (v2).

Core concepts

Models

Every SolidServer object type — Space, Subnet, Pool, IpAddress, DnsZone, DhcpScope, Vlan, and more — is a Pydantic model subclassing SolidServerModel. Fields are typed and coerced automatically from SolidServer's string-typed wire format: IP addresses (including the hex-encoded ones) become ipaddress.IPv4Address / IPv4Network, integers and booleans are parsed properly, timestamps become timezone-aware datetime objects, unset foreign keys ("0" on the wire) become None, and fields the server marks read-only — primary keys, trace/audit fields — are declared frozen=True, so assigning to them raises a pydantic.ValidationError immediately at the point of the mistake rather than failing later on flush(). All of this is visible in the Quickstart example above.

Session is a unit of work

Session.list(), .get(), .one(), and .one_or_none() fetch objects and automatically register them for change tracking. Nothing is sent to the server until you call flush() — or exit a with block cleanly, which calls it for you. session.get(cls, pk) additionally maintains an identity map, so repeated lookups of the same object within a session never issue a second HTTP request:

with Session(host, user, password) as session:
    space = session.one(Space, where=Space.c.site_name == "production")
    space.site_description = "Primary production IPAM space"
    # still just an attribute assignment — no HTTP request yet

    same_space = session.get(Space, space.site_id)
    assert same_space is space  # identity map: same Python object, no second request

# flush() ran on exit: exactly one PUT request for the one dirty object

Creating (and deleting) a hierarchy

session.create() constructs a new model instance and registers it for a POST on the next flush(). Passing a parent= object automatically injects the correct foreign key field — you never have to remember whether a Subnet wants site_id or parent_subnet_id — and objects are flushed in the order they were registered, so a child's parent-id field is filled in automatically once the parent itself has been assigned a server-side id:

from ipaddress import IPv4Network

with Session(host, user, password) as session:
    space = session.get(Space, 7)

    block = session.create(
        Subnet, space,
        subnet_name="prod-block",
        subnet=IPv4Network("10.0.0.0/16"),
        subnet_level=0,
    )  # space.site_id was injected automatically

    dmz = session.create(
        Subnet, block,
        subnet_name="prod-dmz",
        subnet=IPv4Network("10.0.1.0/24"),
        subnet_level=1,
    )  # block.parent_subnet_id will be injected once `block` itself has an id

    session.flush()
    # block is POSTed first (registration order), then dmz — by which point
    # block.subnet_id exists and was filled into dmz automatically
    print(block.subnet_id, dmz.subnet_id)

    # Deletes are not batched: this issues the DELETE immediately and drops
    # the object from the identity cache and the tracked list.
    stale = session.one(Subnet, where=Subnet.c.subnet_name == "decommissioned")
    session.delete(stale)

session.new(obj) registers a manually constructed model instance the same way create() does, and session.add(obj) tracks an already-loaded object for writes without requiring it came from list()/get().

The expression builder

Every model class exposes a .c accessor (short for "columns", mirroring SQLAlchemy's Table.c) that produces typed column expressions. Comparing a column expression against a value builds a Condition — a small, immutable object that serializes to the SolidServer WHERE-clause syntax and does nothing until it's passed to list(), one(), count(), or similar. Condition objects compose with & / | exactly like SQLAlchemy's and_() / or_(), and .asc() / .desc() build an OrderByExpr the same way:

from eip_pydantic.models import Subnet

Subnet.c.subnet_name == "prod-dmz"          # subnet_name='prod-dmz'
Subnet.c.subnet_name != "legacy"            # subnet_name!='legacy'
Subnet.c.subnet_size >= 128                 # subnet_size>='128'
Subnet.c.subnet_name.like("%prod%")         # subnet_name like '%prod%'
Subnet.c.site_id.in_(["1", "2", "3"])       # site_id in ('1', '2', '3')
Subnet.c.subnet_class_name.is_null()        # subnet_class_name=''

session.list(
    Subnet,
    where=(Subnet.c.site_id == "7") & (Subnet.c.subnet_name.like("%prod%")),
    orderby=Subnet.c.subnet_name.asc(),
)

where and orderby both also accept a raw string if you'd rather write the clause yourself (session.list(Subnet, where="site_id='7'")) — typed expressions and raw strings can be mixed freely.

Why bother with the expression builder?

Beyond avoiding string concatenation bugs, the expression builder understands SolidServer-specific wire encodings and applies them automatically — as seen in the Quickstart example's Subnet.c.environment == "production" condition, which required no special syntax even though environment isn't a declared field at all. Any attribute accessed via .c that isn't a declared field is assumed to be a custom class parameter: the correct tag_network_environment='production' condition is built, and the TAGS=network.environment query parameter that SolidServer requires for tag-based filtering is collected from the whole expression tree and injected into the request automatically. You never have to compute it yourself.

The same automatic handling applies to two other SolidServer-specific wire encodings:

from ipaddress import IPv4Address, IPv4Network

# Hex-encoded IPv4 columns (start_ip_addr, end_ip_addr, ...) accept an
# IPv4Address, a dotted string, or raw hex — and always compare correctly:
Subnet.c.start_ip_addr == IPv4Address("10.0.0.1")   # start_ip_addr='0a000001'
Subnet.c.start_ip_addr >= "10.0.0.10"                # start_ip_addr>='0a00000a'

# Subnet.c.subnet is a single Python attribute (an IPv4Network), but on the
# wire a network is a *pair* of hex-encoded start/end addresses — comparing
# against it produces the correct compound condition automatically:
Subnet.c.subnet == IPv4Network("10.16.1.0/24")
# (start_ip_addr='0a100100') and (end_ip_addr='0a1001ff')

Class parameters

Class parameters — SolidServer's mechanism for attaching arbitrary key/value metadata to any object — are accessible as a typed, dict-like container on every model: ClassParamDict. It behaves like a dict[str, str] for reading and writing, while separately tracking each key's inheritance mode (set, inherited, inherited_or_set) and propagation mode (propagate, restrict):

subnet.class_params["environment"]              # "production"
subnet.class_params["environment"] = "staging"   # marks the object dirty, ready for flush()

subnet.class_params.is_set("environment")        # True — explicitly set here
subnet.class_params.is_restrict("environment")   # False — propagates to child objects

# Explicit control over inheritance/propagation, rather than the defaults above:
subnet.class_params.set("owner", "network-team", inherited_or_set=False, restrict=True)

del subnet.class_params["environment"]  # staged for deletion on next flush()

Class parameters can also be set at creation time as a plain dict, exactly as shown in the Quickstart example's class_params={"environment": "production"}.

Error handling

All exceptions raised by the SDK derive from SolidServerError:

from eip_pydantic import ApiError, AuthenticationError, NotFoundError, SolidServerError

try:
    with Session(host, user, password) as session:
        session.get(Subnet, 999999)
except NotFoundError as exc:
    print(f"not found: {exc.status_code} {exc.message}")
except AuthenticationError:
    print("check credentials")
except ApiError as exc:
    print(f"API error: {exc.status_code} {exc.message}")
except SolidServerError:
    print("some other SDK error")

If a flush() fails partway through a batch of writes, the session is reset and every tracked object is marked invalidated — further attempts to read or mutate them raise InvalidatedError, so a partially-written unit of work cannot be mistaken for a consistent one. session.last_flush records exactly which writes succeeded before the failure for post-mortem inspection.

Author

License

Released under the MIT License.

Release files for eip-pydantic 0.0.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for eip-pydantic 0.0.1
File Size Uploaded
eip_pydantic-0.0.1.tar.gz 54.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for eip-pydantic 0.0.1
File Interpreter ABI Platform
eip_pydantic-0.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 125.4 kB

Release files / eip_pydantic-0.0.1.tar.gz

Download URL eip_pydantic-0.0.1.tar.gz
Size 54.5 kB
Tags Source
SHA-256 checksum
How to use checksums
db4e144028f5793ce88024a9150e8eac392132d625dc95da8e8c62a4b56ed0f8
BLAKE2b-256 checksum
How to use checksums
40d3627303c4670b956d2aefadb0a7d010a2e3a35f3b7a8027e3ea1459c24f2d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 13, 2026.

Transparency log

Release files / eip_pydantic-0.0.1-py3-none-any.whl

Download URL eip_pydantic-0.0.1-py3-none-any.whl
Size 70.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
748449499202b4af4342853281ba6fcb43d4f25955638e85efa4dfe6c90d2a7e
BLAKE2b-256 checksum
How to use checksums
a345ce1b571b39b29baa105a36fe00256206d97a2221aeff945b8171658a2cf8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 13, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.0.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page