Chapter 26: Managing Python Projects with Poetry — Part 2

Bring consistency, clarity, and operational safety to your network automation code.

In case you're trying to read this through your email, be warned: This article is large and may get clipped by services like Gmail and others.

This isn't just an article or a typical post: it is a Python crash course delivered to your inbox. However, since services like Gmail often clip larger emails, I strongly recommend opening it in your browser instead.

Also, make sure to read the previous chapters before this one!

5) Zero-to-Reproducible: A Step-by-Step Junos NETCONF Project

This walk-through takes you from an empty folder to a reproducible Junos NETCONF automation repo that any teammate (or CI runner) can clone and run with the exact same Python and dependency graph. We’ll pin the interpreter with Pyenv, declare and lock dependencies with Poetry, and implement a minimal get-config / edit-config workflow using ncclient, lxml, and xmltodict, with friendly output via rich.

0) Scaffold the repo and interpreter (Pyenv)

mkdir junos-netconf-project && cd $_
git init

# Choose a precise Python and make it explicit
pyenv install -s 3.11.9
pyenv local 3.11.9       # writes .python-version
python -V                # -> Python 3.11.9
git add .python-version && git commit -m "pin interpreter to 3.11.9"

1) Install/configure Poetry and in-project venv

# If you don't have pipx yet:
python -m pip install --user pipx && python -m pipx ensurepath
# New shell may be required for PATH to refresh

pipx install poetry
poetry --version

# Keep virtualenv inside the repo for portability
poetry config virtualenvs.in-project true --local

# Bind Poetry to the Pyenv interpreter
poetry env use "$(pyenv which python)"
poetry env info

2) Initialize the project and add dependencies

poetry init  # accept prompts; set Python constraint to ^3.11; description optional
poetry add ncclient lxml xmltodict httpx rich

This writes pyproject.toml (intent) and, after your first poetry lock/poetry install, a poetry.lock (exact resolved state).

Add a tiny script entry point so operators (and CI) can run a clear command:

# pyproject.toml (add near the bottom)
[tool.poetry.scripts]
junos-ops = "netops_junos.cli:main"

3) Project layout (src/ layout recommended)

mkdir -p src/netops_junos
touch src/netops_junos/__init__.py
touch src/netops_junos/cli.py
touch src/netops_junos/netconf.py

src/netops_junos/netconf.py — connection helpers and simple RPCs

# src/netops_junos/netconf.py
from __future__ import annotations
import os
from contextlib import contextmanager
from ncclient import manager
import xmltodict

JUNOS_NS = "http://xml.juniper.net/xnm/1.1/xnm"

@contextmanager
def junos_session(
    host: str,
    username: str | None = None,
    password: str | None = None,
    port: int = 830,
    timeout: int = 30,
):
    """
    Opens an ncclient session to a Junos device.
    Credentials can come from args or env: JUNOS_USER / JUNOS_PASS.
    """
    username = username or os.getenv("JUNOS_USER")
    password = password or os.getenv("JUNOS_PASS")

    if not (username and password):
        raise RuntimeError("Provide credentials via args or JUNOS_USER/JUNOS_PASS env vars.")

    with manager.connect(
        host=host,
        port=port,
        username=username,
        password=password,
        hostkey_verify=False,         # for labs; enable verification in prod
        allow_agent=True,
        look_for_keys=True,
        device_params={"name": "junos"},
        timeout=timeout,
    ) as m:
        yield m


def get_hostname(mgr: manager.Manager) -> str:
    """Return the current system host-name via get-config subtree filter."""
    filter_xml = f"""
    <configuration xmlns="{JUNOS_NS}">
      <system>
        <host-name/>
      </system>
    </configuration>
    """
    reply = mgr.get_config(source="candidate", filter=("subtree", filter_xml))
    data = xmltodict.parse(reply.xml)
    # rpc-reply -> data -> configuration -> system -> host-name
    return (
        data.get("rpc-reply", {})
            .get("data", {})
            .get("configuration", {})
            .get("system", {})
            .get("host-name", "")
    ) or ""


def set_hostname(mgr: manager.Manager, new_name: str) -> None:
    """
    Merge a new system host-name and commit.
    """
    config_xml = f"""
    <configuration xmlns="{JUNOS_NS}">
      <system>
        <host-name>{new_name}</host-name>
      </system>
    </configuration>
    """
    mgr.edit_config(target="candidate", config=config_xml, default_operation="merge")
    mgr.validate()   # server-side validation
    mgr.commit()     # regular commit; use confirmed commits for change windows


def set_interface_description(mgr: manager.Manager, if_name: str, desc: str) -> None:
    """
    Set interface description and commit. Example if_name: ge-0/0/0
    """
    config_xml = f"""
    <configuration xmlns="{JUNOS_NS}">
      <interfaces>
        <interface>
          <name>{if_name}</name>
          <description>{desc}</description>
        </interface>
      </interfaces>
    </configuration>
    """
    mgr.edit_config(target="candidate", config=config_xml, default_operation="merge")
    mgr.validate()
    mgr.commit()

src/netops_junos/cli.py — operator-friendly CLI

# src/netops_junos/cli.py
from __future__ import annotations
import argparse
from rich.console import Console
from rich.table import Table
from .netconf import junos_session, get_hostname, set_hostname, set_interface_description

console = Console()

def main():
    parser = argparse.ArgumentParser(prog="junos-ops", description="Minimal Junos NETCONF ops")
    parser.add_argument("--host", required=True, help="Device mgmt IP/DNS")
    parser.add_argument("--user", help="Username (or JUNOS_USER env)")
    parser.add_argument("--pass", dest="password", help="Password (or JUNOS_PASS env)")
    sub = parser.add_subparsers(dest="cmd", required=True)

    sub.add_parser("get-hostname", help="Read system host-name")

    p_sethn = sub.add_parser("set-hostname", help="Set system host-name and commit")
    p_sethn.add_argument("--name", required=True)

    p_setdesc = sub.add_parser("set-int-desc", help="Set interface description and commit")
    p_setdesc.add_argument("--if", dest="if_name", required=True, help="e.g., ge-0/0/0")
    p_setdesc.add_argument("--desc", required=True, help="Description text")

    args = parser.parse_args()

    with junos_session(args.host, username=args.user, password=args.password) as m:
        if args.cmd == "get-hostname":
            hn = get_hostname(m)
            table = Table(title="Junos Hostname")
            table.add_column("Host")
            table.add_column("Hostname")
            table.add_row(args.host, hn or "<empty>")
            console.print(table)

        elif args.cmd == "set-hostname":
            set_hostname(m, args.name)
            console.print(f"[green]Committed host-name '{args.name}'[/]")

        elif args.cmd == "set-int-desc":
            set_interface_description(m, args.if_name, args.desc)
            console.print(f"[green]Committed description on {args.if_name}: '{args.desc}'[/]")

Subscribe to our premium content to read the rest.

Become a paying subscriber to get access to this post and other subscriber-only content. No fluff. No marketing slides. Just real engineering, deep insights, and the career momentum you’ve been looking for.

Already a paying subscriber? Sign In.

A subscription gets you:

  • • ✅ Exclusive career tools and job prep guidance
  • • ✅ Unfiltered breakdowns of protocols, automation, and architecture
  • • ✅ Real-world lab scenarios and how to solve them
  • • ✅ Hands-on deep dives with annotated configs and diagrams
  • • ✅ Priority AMA access — ask me anything