The design DSL

Niwaki design DSL — declarative, IDE-friendly ACI provisioning.

Build a detached design tree with operator vocabulary (no APIC class names, no session), then validate and push it in one call. The DSL covers the whole uni subtree — tenants, access policies (infra), fabric policies (fabric), and controller policies — through one uniform vocabulary:

from niwaki import Niwaki
from niwaki.design import tenant

config = (
    tenant("prod")
    .app("prod")
        .epg("frontend").bind(bd="frontend")
    .bd("frontend")
        .set(unicast_routing=True)
        .bind(vrf="prod")
        .subnet("10.0.1.1/24")
    .vrf("prod")
)

with Niwaki("https://apic.example.com", "admin", "secret") as aci:
    config.push(aci, mode="strict")   # one atomic POST, all-or-nothing

Every design is rooted at polUni; design() starts an empty multi-domain design, while tenant() / infra() / fabric() / controller() are shorthands that declare the first domain and return its cursor — sibling domains stay one maker call away:

cfg = design()
cfg.fabric().datetime_policy("prod-ntp")
cfg.infra().vlan_pool("prod", "static").range("vlan-100", "vlan-199")
cfg.tenant("prod").vrf("main")

Core rules (see the Design-first architecture page in the documentation):

  • Structure is literal: every maker maps 1:1 to a real APIC child class; nothing is silently created or hidden.

  • Verbatim is translated: names and parameters use operator vocabulary (entry("http", tcp=80), scope="vrf").

  • References are lazy: bind() / provide() / consume() resolve at push time — forward references allowed, closed-world validated.

  • No I/O during construction: transport is injected at push() only.

Root factories

Each root opens a curated, fully typed surface: the makers, their keyword arguments, the bind() aliases and the verbs available at every position are generated and documented in the DSL reference.

niwaki.design.design()[source]

Create an empty detached design rooted at polUni (uni).

The uniform entry point for multi-domain designs: every other root factory (tenant(), infra(), fabric(), controller()) is sugar for design().<maker>(...). No session and no I/O — the tree is built in memory and pushed later via push().

Returns:

Root UniCursor of a new empty design.

Return type:

UniCursor

Example:

cfg = design()
cfg.fabric().datetime_policy("prod-ntp")
cfg.infra().vlan_pool("prod", "static")
cfg.tenant("prod").vrf("main")
payload = cfg.to_payload()   # one atomic polUni envelope
niwaki.design.tenant(
name,
*,
annotation=None,
description=None,
display_name=None,
owner_key=None,
owner_tag=None,
userdom=None,
)[source]

Create a detached design and declare its fvTenant root.

Shorthand for design().tenant(...) — the returned cursor sits on the declared object; the design root is the enclosing polUni node, so sibling domains stay reachable through the implicit-pop makers.

Returns:

TenantCursor on the new fvTenant node.

Return type:

TenantCursor

niwaki.design.infra(*, annotation=None, display_name=None, owner_key=None, owner_tag=None, userdom=None)[source]

Create a detached design and declare its infraInfra root.

Shorthand for design().infra(...) — the returned cursor sits on the declared object; the design root is the enclosing polUni node, so sibling domains stay reachable through the implicit-pop makers.

Returns:

InfraCursor on the new infraInfra node.

Return type:

InfraCursor

niwaki.design.fabric(
*,
annotation=None,
name=None,
display_name=None,
owner_key=None,
owner_tag=None,
userdom=None,
)[source]

Create a detached design and declare its fabricInst root.

Shorthand for design().fabric(...) — the returned cursor sits on the declared object; the design root is the enclosing polUni node, so sibling domains stay reachable through the implicit-pop makers.

Returns:

FabricCursor on the new fabricInst node.

Return type:

FabricCursor

niwaki.design.controller(
*,
annotation=None,
name=None,
display_name=None,
owner_key=None,
owner_tag=None,
userdom=None,
)[source]

Create a detached design and declare its ctrlrInst root.

Shorthand for design().controller(...) — the returned cursor sits on the declared object; the design root is the enclosing polUni node, so sibling domains stay reachable through the implicit-pop makers.

Returns:

ControllerCursor on the new ctrlrInst node.

Return type:

ControllerCursor

References that carry configuration

A bind(), a bind_dn() or a verb usually takes a plain name; wrap the target in ref() when the relationship object itself carries configuration (a domain attachment’s immediacy, a subject filter’s log directive, a node’s management address).

niwaki.design.ref(target, **attrs)[source]

Reference target and configure the relationship itself.

Parameters:
  • target (str) – Name of the referenced object — or, under bind_dn(), its raw DN.

  • **attrs (Any) – Fields to set on the relationship (Rs) object, e.g. encap="vlan-201", directives="log", direction="import".

Returns:

A Ref usable anywhere a plain name is – bind(), bind_dn() and the contract verbs.

Return type:

Ref

Example

>>> from niwaki.design import ref, tenant
>>> cfg = tenant("prod")
>>> cfg.filter("http").entry("tcp-80", tcp=80)
<Cursor ...>
>>> subject = cfg.contract("web").subject("http")
>>> _ = subject.bind(filter=ref("http", directives="log"))
class niwaki.design.Ref(target, attrs)[source]

Bases: object

A reference that carries configuration of its own.

Most relationships in the MIT are pure edges — the Rs object stores the target and nothing else, so naming the target is the whole reference:

epg.bind(bd="web")

Some carry configuration: an EPG-to-domain attachment holds the static encap and the resolution immediacy, a filter attached to a subject holds its directives, a route-control profile attachment holds its direction. Wrap the target in ref() to set them:

epg.bind(domain=ref("vmm-prod", encap="vlan-201", untagged=False))
subject.bind(filter=ref("http", directives="log"))

The attributes are validated against the relationship class at declaration time, like every other field in a design.

target

Name of the referenced object (or its raw DN under bind_dn).

Type:

str

attrs

Extra fields set on the relationship object itself.

Type:

dict[str, Any]

Cursor

Every position is a typed cursor subclass of Cursor — the makers and the set() / bind() signatures are generated per position (see the DSL reference). The base class below is the behaviour they all share.

class niwaki.design.Cursor(node)[source]

Bases: object

A position in a design tree, exposing the curated build vocabulary.

Do not instantiate directly — obtain the root cursor from niwaki.design.tenant() and children from maker calls.

property design_node: DesignNode

The underlying design node (read-only structural handle).

Deliberately verbose: short names on the cursor belong to the curated vocabulary (.node() is a maker under a route reflector or a vPC pair) — the generator enforces that no maker shadows the base cursor API.

property dn: str

Distinguished Name this node will occupy once pushed.

Returns:

DN string rooted at uni (e.g. "uni/tn-prod/BD-web").

set(**attrs)[source]

Set scalar attributes on the current object.

Values are merged with previously set attributes (last call wins) and the full attribute set is re-validated through the Pydantic model immediately — constraint violations raise at the call site, before any push.

Parameters:

**attrs (Any) – Field values using the human-readable Python names (e.g. unicast_routing=True). Sugar applies where defined (e.g. scope="vrf" on a contract).

Returns:

This cursor, for chaining.

Raises:
  • DesignError – Unknown attribute name, ACI wire name used instead of the Python field name, or attempt to change a naming prop.

  • pydantic.ValidationError – A value violates the model constraints.

Return type:

Cursor

bind(**targets)[source]

Declare lazy Rs relationships by target vocabulary and name.

Each alias=name pair records a reference resolved at push time — forward references are allowed, and the relationship class, its flavor (name vs DN) and the side it lives on are derived from REFERENCE_MAP, so .vrf("prod").bind(l3out="prod") works even though the Rs object lives on the L3Out side.

The alias must be curated on the current level — binds do not climb to an ancestor. Declare each alias on the object that owns it (.bd("b").bind(vrf="v")), so a relation can never land silently on a parent.

Parameters:

**targets (Any) – One or more alias=name pairs (e.g. vrf="prod").

Returns:

This cursor, for chaining.

Raises:

DesignError – An alias is not bindable at any level of the path.

Return type:

Cursor

bind_dn(**targets)[source]

Reference objects outside the design by raw DN (escape hatch).

Same aliases as bind(), but the value is a full DN and no closed-world lookup happens — the DN is trusted as-is and the APIC is the one to reject a dangling reference. Only aliases whose Rs class targets by DN qualify; name-flavored aliases must go through bind() (the Rs object physically stores a name, not a DN).

Parameters:

**targets (str | Ref) – One or more alias=dn pairs (e.g. vlan_pool="uni/infra/vlanns-[shared]-static"), or a ref() when the relation itself carries configuration (ref(dn, immediacy="immediate")).

Returns:

This cursor, for chaining.

Raises:

DesignError – The alias is unknown, its relation lives on the target side, or it targets by name rather than by DN.

Return type:

Cursor

provide(contract)[source]

Declare that this EPG provides contract (creates fvRsProv).

Parameters:

contract (str | Ref) – Name of a contract declared in this design, or a ref() when the relation itself carries configuration (ref("web", prio="level1")).

Returns:

This cursor, for chaining.

Return type:

Cursor

consume(contract)[source]

Declare that this EPG consumes contract (creates fvRsCons).

Parameters:

contract (str | Ref) – Name of a contract declared in this design, or a ref() when the relation itself carries configuration (ref("web", prio="level1")).

Returns:

This cursor, for chaining.

Return type:

Cursor

intra_epg(contract)[source]

Declare contract between the endpoints of this EPG (fvRsIntraEpg).

The APIC applies it to traffic that stays inside the group — the EPG must be intra-EPG isolated for it to bite.

Parameters:

contract (str | Ref) – Name of a contract declared in this design, or a ref() when the relation itself carries configuration (ref("web", prio="level1")).

Returns:

This cursor, for chaining.

Return type:

Cursor

mo(cls, **kwargs)[source]

Declare a child of an arbitrary generated class (escape hatch).

For classes outside the curated vocabulary. Naming props are picked from kwargs by name; the remainder are scalar attributes.

Parameters:
Returns:

Cursor on the new child node.

Raises:

DesignErrorcls is not a valid APIC child of this object.

Return type:

Cursor

to_payload()[source]

Validate, resolve references, and return the atomic push payload.

Follows the Query.build() house pattern: full inspection without execution. The returned dict is exactly what push(mode="strict") POSTs to /api/mo/uni.json.

Returns:

polUni envelope dict wrapping the whole design.

Raises:
Return type:

dict[str, Any]

push(
client: Niwaki,
*,
mode: Literal['strict', 'staged'] = 'strict',
) PushReport[source]
push(client: Niwaki, *, mode: Literal['plan']) PlanResult
push(
client: AsyncNiwaki,
*,
mode: Literal['strict', 'staged'] = 'strict',
) _Coroutine[Any, Any, PushReport]
push(
client: AsyncNiwaki,
*,
mode: Literal['plan'],
) _Coroutine[Any, Any, PlanResult]

Validate the design and push it through client.

Always operates on the whole design tree regardless of which cursor it is called on. Construction never touches the network — transport is injected here and only here.

Modes:
  • "strict" (default): closed-world validation (every reference must resolve inside the design), then one atomic nested POST to /api/mo/uni.json — all or nothing.

  • "staged": compile to per-object operations executed in DN-depth waves (parents before children) with a detailed report; a failing wave stops the remaining ones.

  • "plan": dry run — read the current APIC state and report what would be created or changed, pushing nothing.

Parameters:
  • client (Niwaki | AsyncNiwaki) – A connected Niwaki (returns the result directly) or AsyncNiwaki (returns an awaitable).

  • mode (PushMode) – "strict" | "staged" | "plan".

Returns:

PushReport for write modes, PlanResult for "plan" (wrapped in a coroutine for async clients).

Raises:
Return type:

PushReport | PlanResult | _Coroutine[Any, Any, PushReport | PlanResult]

Push results

class niwaki.design.PushReport(mode, dns, request_count)[source]

Bases: object

Summary of a successful strict or staged push.

mode

The push mode that produced this report.

Type:

str

dns

DNs written, in execution order (includes resolved Rs objects).

Type:

list[str]

request_count

Number of HTTP requests issued (1 for strict).

Type:

int

class niwaki.design.PlanResult(creates, updates, unchanged)[source]

Bases: object

Dry-run report of what a push would change (plan mode).

Deletions are out of scope by design: a plan never proposes removing objects that exist on the APIC but not in the design.

creates

DNs that do not exist on the APIC and would be created.

Type:

list[str]

updates

Per-DN field changes as {field: (current, desired)}.

Type:

dict[str, dict[str, tuple[Any, Any]]]

unchanged

DNs already matching the desired state.

Type:

list[str]

property has_changes: bool

True when a push would modify anything on the APIC.