├── tests ├── __init__.py ├── jinja │ ├── template1.j2 │ ├── data1.yaml │ ├── data2.yaml │ ├── template3.j2 │ ├── data3.yaml │ ├── templateFail.j2 │ ├── template4.j2 │ └── template2.j2 ├── ttp │ ├── template2.txt │ ├── data2.txt │ ├── template1.txt │ └── data1.txt ├── jsonpatch │ ├── data_patch.json │ ├── data1.yaml │ └── data2.yaml ├── test_nettowel.py ├── yaml │ ├── yaml1.yaml │ └── json1.json ├── test_main_cli.py ├── test_exceptions.py ├── test_ttp_cli.py ├── test_jsonpatch.py ├── test_yaml_cli.py ├── test_ipaddress_cli.py └── test_jinja_cli.py ├── src └── nettowel │ ├── py.typed │ ├── cli │ ├── __init__.py │ ├── _output.py │ ├── logging.py │ ├── help.py │ ├── napalm.py │ ├── nornir.py │ ├── scrapli.py │ ├── textfsm.py │ ├── pandas.py │ ├── diff.py │ ├── ip.py │ ├── yaml.py │ ├── main.py │ ├── _common.py │ ├── ttp.py │ ├── jsonpatch.py │ ├── netmiko.py │ ├── jinja.py │ └── restconf.py │ ├── __init__.py │ ├── __main__.py │ ├── logger.py │ ├── _common.py │ ├── trogon_tui.py │ ├── ttp.py │ ├── pandas.py │ ├── yaml.py │ ├── exceptions.py │ ├── jsonpatch.py │ ├── restconf.py │ ├── netconf.py │ ├── netmiko.py │ └── jinja.py ├── .github ├── FUNDING.yml └── workflows │ └── main.yaml ├── imgs ├── help.png ├── piping.png ├── trogon.png ├── ip-info.png ├── netmiko-cli.png ├── ttp-render.png ├── yaml-dump.png ├── yaml-load.png ├── env-settings.png ├── nettowel-help.png ├── network-info.png ├── restconf-get.png ├── jinja-render-1.png ├── jinja-render-3.png ├── jinja-validate.png ├── jinja-variables.png ├── jsonpatch-apply.png ├── jsonpatch-create.png ├── netmiko-autodetect.png ├── restconf-post-put.png ├── netmiko-device-types.png └── restconf-patch-delete.png ├── Dockerfile ├── .env.template ├── Makefile ├── CONTRIBUTING.md ├── .gitignore ├── pyproject.toml ├── README.md ├── CODE_OF_CONDUCT.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nettowel/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nettowel/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ubaumann] -------------------------------------------------------------------------------- /tests/jinja/template1.j2: -------------------------------------------------------------------------------- 1 | Hi {{ name }} 2 | {{ text }} -------------------------------------------------------------------------------- /src/nettowel/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.1" # From Makefile 2 | -------------------------------------------------------------------------------- /tests/jinja/data1.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Joe 3 | text: How are you doing? 4 | ... -------------------------------------------------------------------------------- /tests/jinja/data2.yaml: -------------------------------------------------------------------------------- 1 | animals: 2 | cow: moo 3 | pig: oink 4 | duck: quack -------------------------------------------------------------------------------- /imgs/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/help.png -------------------------------------------------------------------------------- /imgs/piping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/piping.png -------------------------------------------------------------------------------- /imgs/trogon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/trogon.png -------------------------------------------------------------------------------- /src/nettowel/cli/_output.py: -------------------------------------------------------------------------------- 1 | from rich.columns import Columns 2 | from rich.panel import Panel 3 | -------------------------------------------------------------------------------- /imgs/ip-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/ip-info.png -------------------------------------------------------------------------------- /imgs/netmiko-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/netmiko-cli.png -------------------------------------------------------------------------------- /imgs/ttp-render.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/ttp-render.png -------------------------------------------------------------------------------- /imgs/yaml-dump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/yaml-dump.png -------------------------------------------------------------------------------- /imgs/yaml-load.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/yaml-load.png -------------------------------------------------------------------------------- /imgs/env-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/env-settings.png -------------------------------------------------------------------------------- /imgs/nettowel-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/nettowel-help.png -------------------------------------------------------------------------------- /imgs/network-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/network-info.png -------------------------------------------------------------------------------- /imgs/restconf-get.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/restconf-get.png -------------------------------------------------------------------------------- /imgs/jinja-render-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/jinja-render-1.png -------------------------------------------------------------------------------- /imgs/jinja-render-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/jinja-render-3.png -------------------------------------------------------------------------------- /imgs/jinja-validate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/jinja-validate.png -------------------------------------------------------------------------------- /imgs/jinja-variables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/jinja-variables.png -------------------------------------------------------------------------------- /imgs/jsonpatch-apply.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/jsonpatch-apply.png -------------------------------------------------------------------------------- /imgs/jsonpatch-create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/jsonpatch-create.png -------------------------------------------------------------------------------- /src/nettowel/__main__.py: -------------------------------------------------------------------------------- 1 | from nettowel.cli.main import run 2 | 3 | 4 | if __name__ == "__main__": 5 | run() 6 | -------------------------------------------------------------------------------- /imgs/netmiko-autodetect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/netmiko-autodetect.png -------------------------------------------------------------------------------- /imgs/restconf-post-put.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/restconf-post-put.png -------------------------------------------------------------------------------- /imgs/netmiko-device-types.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/netmiko-device-types.png -------------------------------------------------------------------------------- /imgs/restconf-patch-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/HEAD/imgs/restconf-patch-delete.png -------------------------------------------------------------------------------- /tests/ttp/template2.txt: -------------------------------------------------------------------------------- 1 | interface {{ interface }} 2 | ip address {{ ip }}/{{ mask }} 3 | description {{ description }} 4 | ip vrf {{ vrf }} -------------------------------------------------------------------------------- /tests/jsonpatch/data_patch.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "op": "replace", 4 | "path": "/interfaces/1/mask", 5 | "value": "255.255.255.192" 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /tests/test_nettowel.py: -------------------------------------------------------------------------------- 1 | from nettowel import __version__ 2 | 3 | 4 | def test_version() -> None: 5 | assert __version__ == "0.6.1" # From Makefile 6 | -------------------------------------------------------------------------------- /tests/jinja/template3.j2: -------------------------------------------------------------------------------- 1 | {% for interface in interfaces %} 2 | interface {{ interface.name }} 3 | ip address {{ interface.addr }} {{ interface.mask}} 4 | {% endfor %} -------------------------------------------------------------------------------- /src/nettowel/logger.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | 3 | log = getLogger("nettowel") 4 | 5 | 6 | def configure_logger(level: int) -> None: 7 | log.setLevel(level=level) 8 | -------------------------------------------------------------------------------- /tests/jinja/data3.yaml: -------------------------------------------------------------------------------- 1 | interfaces: 2 | - name: Loopback0 3 | addr: 10.10.10.10 4 | mask: 255.255.255.255 5 | - name: Ethernet0/1 6 | addr: 192.168.1.1 7 | mask: 255.255.255.0 8 | -------------------------------------------------------------------------------- /tests/jinja/templateFail.j2: -------------------------------------------------------------------------------- 1 | {% for interface in interfaces %} 2 | interface {{ interface.name }} 3 | ip address {{ interface.addr }} {{ interface.mask}} 4 | {% if {{ test }} %} 5 | }} 6 | {% endfor %} -------------------------------------------------------------------------------- /tests/jsonpatch/data1.yaml: -------------------------------------------------------------------------------- 1 | interfaces: 2 | - name: Loopback0 3 | addr: 10.10.10.10 4 | mask: 255.255.255.255 5 | - name: Ethernet0/1 6 | addr: 192.168.1.1 7 | mask: 255.255.255.0 8 | -------------------------------------------------------------------------------- /tests/jsonpatch/data2.yaml: -------------------------------------------------------------------------------- 1 | interfaces: 2 | - name: Loopback0 3 | addr: 10.10.10.10 4 | mask: 255.255.255.255 5 | - name: Ethernet0/1 6 | addr: 192.168.1.1 7 | mask: 255.255.255.192 8 | -------------------------------------------------------------------------------- /tests/ttp/data2.txt: -------------------------------------------------------------------------------- 1 | interface Loopback0 2 | description Router-id-loopback 3 | ip address 192.168.0.113/24 4 | ! 5 | interface Vlan778 6 | description CPE_Acces_Vlan 7 | ip address 2002::fd37/124 8 | ip vrf CPE1 9 | ! -------------------------------------------------------------------------------- /tests/jinja/template4.j2: -------------------------------------------------------------------------------- 1 | {{ number | abs }} 2 | {{ text | trim }} 3 | {{ mylist | first}} 4 | {{ name | default("test") }} 5 | 6 | {% for interface in interfaces %} 7 | interface {{ interface.name }} 8 | ip address {{ interface.addr }} {{ interface.mask}} 9 | {% endfor %} 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON 2 | FROM python:3.13 3 | 4 | WORKDIR /workspace 5 | 6 | # Install uv. 7 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 8 | 9 | COPY . . 10 | 11 | ENV UV_PROJECT_ENVIRONMENT=/usr/local 12 | RUN uv sync --frozen --no-cache --all-extras 13 | 14 | CMD ["nettowel", "help"] -------------------------------------------------------------------------------- /tests/ttp/template1.txt: -------------------------------------------------------------------------------- 1 | 2 | {{ protocol }} {{ ip | IP }} {{ age | replace("-", "-1") }} {{ mac | mac_eui }} {{ type | let("interface", "Unknown") }} 3 | {{ protocol }} {{ ip | IP }} {{ age | replace("-", "-1") }} {{ mac | mac_eui }} {{ type }} {{ interface | resuball("short_interface_names") }} 4 | -------------------------------------------------------------------------------- /tests/jinja/template2.j2: -------------------------------------------------------------------------------- 1 | {% for animal, sound in animals.items() %} 2 | Old MacDonald had a farm, E I E I O, 3 | And on his farm he had a {{animal}}, E I E I O. 4 | With a {{sound}} {{sound}} here and a {{sound}} {{sound}} there, 5 | Here a {{sound}}, there a {{sound}}, everywhere a {{sound}} {{sound}}. 6 | Old MacDonald had a farm, E I E I O. 7 | {% endfor %} -------------------------------------------------------------------------------- /tests/yaml/yaml1.yaml: -------------------------------------------------------------------------------- 1 | name: Urs 2 | pickups_lines: 3 | - Well, here I am. What are your other two wishes? 4 | - Are you from Tennessee? Because you're the only 10 I see! 5 | - I wish I were cross-eyed so I can see you twice. 6 | - I’m learning about important dates in history. Wanna be one of them? 7 | - Do you like Star Wars? Because Yoda only one for me! -------------------------------------------------------------------------------- /src/nettowel/cli/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from rich.logging import RichHandler 3 | 4 | FORMAT = "%(message)s" 5 | 6 | 7 | def configure_logger(level: int) -> None: 8 | logging.basicConfig( 9 | level=level, format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] 10 | ) 11 | log.setLevel(level) 12 | 13 | 14 | log = logging.getLogger("nettowel") 15 | log.info("Hello, World!") 16 | -------------------------------------------------------------------------------- /tests/test_main_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typer.testing import CliRunner 3 | from nettowel.cli.main import app 4 | 5 | runner = CliRunner() 6 | 7 | 8 | def test_help() -> None: 9 | result = runner.invoke(app, ["--help"]) 10 | assert result.exit_code == 0 11 | assert "Awesome collection of network automation functions" in result.stdout 12 | assert "Failed to import" not in result.stderr 13 | assert not result.stderr 14 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from nettowel.exceptions import NettowelDependencyMissing 2 | 3 | 4 | def test_dependency_error() -> None: 5 | exc = NettowelDependencyMissing("myPackage", "tests") 6 | assert ( 7 | "myPackage is not installed. Install it with pip: pip install nettowel[tests]" 8 | == str(exc) 9 | ) 10 | 11 | exc = NettowelDependencyMissing("myPackage", "tests", "{package}|{group}") 12 | assert "myPackage|tests" == str(exc) 13 | -------------------------------------------------------------------------------- /src/nettowel/_common.py: -------------------------------------------------------------------------------- 1 | from nettowel.exceptions import NettowelDependencyMissing 2 | from nettowel.logger import log 3 | 4 | 5 | def needs(needs: bool, package: str, group: str) -> bool: 6 | if not needs: 7 | log.warning( 8 | "Package %s is not installed. It is part of the nettowel group %s", 9 | package, 10 | group, 11 | ) 12 | raise NettowelDependencyMissing(package, group) 13 | log.debug("Package %s is installed", package) 14 | return True 15 | -------------------------------------------------------------------------------- /tests/yaml/json1.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [], 3 | "created_at": "2020-01-05 13:42:25.352697", 4 | "icon_url": "https://assets.chucknorris.host/img/avatar/chuck-norris.png", 5 | "id": "G4hjVuvdSnWD03cug8sdOA", 6 | "updated_at": "2020-01-05 13:42:25.352697", 7 | "url": "https://api.chucknorris.io/jokes/G4hjVuvdSnWD03cug8sdOA", 8 | "value": "Little-known fact: Lionel Richie wrote 'Dancing On The Ceiling' about the time he attended a Chuck Norris party where he reversed the gravity at his house for the night." 9 | } 10 | -------------------------------------------------------------------------------- /tests/ttp/data1.txt: -------------------------------------------------------------------------------- 1 | Protocol Address Age (min) Hardware Addr Type Interface 2 | Internet 10.18.8.1 43 0000.0c9f.f13d ARPA GigabitEthernet6 3 | Internet 10.18.8.2 5 002a.6af5.53c1 ARPA GigabitEthernet6 4 | Internet 10.18.8.3 0 002a.6af1.7d81 ARPA GigabitEthernet6 5 | Internet 10.18.8.41 152 5254.00fd.7dba ARPA GigabitEthernet6 6 | Internet 10.18.8.42 85 5254.0006.3e59 ARPA GigabitEthernet6 7 | Internet 10.18.8.43 104 5254.00a6.d787 ARPA GigabitEthernet6 8 | -------------------------------------------------------------------------------- /src/nettowel/cli/help.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from qrcode import QRCode 3 | 4 | from nettowel.logger import log 5 | 6 | HELP_MARKDOWN = """ 7 | **NetTowel** is a collection of network automation functions. 8 | > This project is in an early stage and still under heavy development. 9 | 10 | Use cases 11 | - Demonstrations 12 | - Troubleshooting 13 | - Fun 14 | 15 | """ 16 | 17 | 18 | def get_qrcode(data: str) -> str: 19 | log.debug("Create QRcode %s", data) 20 | qr = QRCode() 21 | qr.add_data(data) 22 | out = StringIO() 23 | qr.print_ascii(out) 24 | out.seek(0) 25 | return out.read().rstrip("\n") 26 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # BASICS 2 | 3 | 4 | # NETMIKO 5 | # NETTOWEL_DEVICE_TYPE=cisco_ios 6 | # NETTOWEL_USER=username 7 | # NETTOWEL_PASSWORD=password 8 | # NETTOWEL_PORT=22 9 | # NETTOWEL_SECRET=secret 10 | # NETTOWEL_NETMIKO_TEXTFSM=True 11 | # NETTOWEL_NETMIKO_TTP=True 12 | # NETTOWEL_NETMIKO_TTP_TEMPLATE=template.txt 13 | # NETTOWEL_NETMIKO_GENIE=True 14 | # NETTOWEL_SSH_CONFIG=ssh_config 15 | # NETTOWEL_USE_KEY=True 16 | # NETTOWEL_KEY_FILE=private.key 17 | # NETTOWEL_SESSION_LOG=netmiko_logging.log 18 | 19 | # RESTCONF 20 | # NETTOWEL_HOST=sw01 21 | # NETTOWEL_USER=username 22 | # NETTOWEL_PASSWORD=password 23 | # NETTOWEL_RESTCONF_PORT=443 24 | # NETTOWEL_VERIFY=False -------------------------------------------------------------------------------- /src/nettowel/trogon_tui.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | from typer import Context, Typer 3 | from typer.main import get_group 4 | 5 | from nettowel._common import needs 6 | from nettowel.logger import log 7 | from nettowel.exceptions import NettowelTimeoutError 8 | 9 | _module = "tui" 10 | 11 | try: 12 | from trogon import Trogon 13 | 14 | log.debug("Successfully imported %s", _module) 15 | TROGON_INSTALLED = True 16 | 17 | except ImportError: 18 | log.warning("Failed to import %s", _module) 19 | TROGON_INSTALLED = False 20 | 21 | 22 | def run_trogon_tui( 23 | app: Typer, 24 | ctx: Context, 25 | ) -> None: 26 | needs(TROGON_INSTALLED, "Trogon", _module) 27 | Trogon(get_group(app), click_context=ctx).run() 28 | -------------------------------------------------------------------------------- /src/nettowel/ttp.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from nettowel._common import needs 3 | from nettowel.logger import log 4 | 5 | _module = "ttp" 6 | 7 | try: 8 | from ttp import ttp 9 | 10 | log.debug("Successfully imported %s", _module) 11 | TTP_INSTALLED = True 12 | 13 | except ImportError: 14 | log.warning("Failed to import %s", _module) 15 | TTP_INSTALLED = False 16 | 17 | 18 | def render_template( 19 | data: str, 20 | template: str, 21 | ) -> Any: 22 | needs(TTP_INSTALLED, "TTP", _module) 23 | try: 24 | parser = ttp(data=data, template=template) 25 | parser.parse() 26 | results = parser.result(structure="flat_list") 27 | except Exception as exc: 28 | raise exc 29 | return results 30 | -------------------------------------------------------------------------------- /src/nettowel/cli/napalm.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import inspect as rich_inspect 3 | from rich import print_json 4 | 5 | from nettowel.cli._common import get_typer_app 6 | 7 | 8 | app = get_typer_app(help="NAPALM functions") 9 | 10 | 11 | @app.command() 12 | def demo( 13 | ctx: typer.Context, 14 | # demo: str, 15 | json: bool = typer.Option(default=False, help="json output"), 16 | ) -> None: 17 | try: 18 | raise NotImplementedError("Not Implemented Yet") 19 | if json: 20 | print_json(data=None) 21 | else: 22 | rich_inspect(None) 23 | raise typer.Exit(0) 24 | 25 | except NotImplementedError as exc: 26 | typer.echo(exc) 27 | raise typer.Exit(1) 28 | 29 | 30 | if __name__ == "__main__": 31 | app() 32 | -------------------------------------------------------------------------------- /src/nettowel/cli/nornir.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import inspect as rich_inspect 3 | from rich import print_json 4 | 5 | from nettowel.cli._common import get_typer_app 6 | 7 | 8 | app = get_typer_app(help="Nornir functions") 9 | 10 | 11 | @app.command() 12 | def run( 13 | ctx: typer.Context, 14 | # demo: str, 15 | json: bool = typer.Option(default=False, help="json output"), 16 | ) -> None: 17 | try: 18 | raise NotImplementedError("Not Implemented Yet") 19 | if json: 20 | print_json(data=None) 21 | else: 22 | rich_inspect(None) 23 | raise typer.Exit(0) 24 | 25 | except NotImplementedError as exc: 26 | typer.echo(exc) 27 | raise typer.Exit(1) 28 | 29 | 30 | if __name__ == "__main__": 31 | app() 32 | -------------------------------------------------------------------------------- /src/nettowel/cli/scrapli.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import inspect as rich_inspect 3 | from rich import print_json 4 | 5 | from nettowel.cli._common import get_typer_app 6 | 7 | 8 | app = get_typer_app(help="Scrapli functions") 9 | 10 | 11 | @app.command() 12 | def cli( 13 | ctx: typer.Context, 14 | # demo: str, 15 | json: bool = typer.Option(default=False, help="json output"), 16 | ) -> None: 17 | try: 18 | raise NotImplementedError("Not Implemented Yet") 19 | if json: 20 | print_json(data=None) 21 | else: 22 | rich_inspect(None) 23 | raise typer.Exit(0) 24 | 25 | except NotImplementedError as exc: 26 | typer.echo(exc) 27 | raise typer.Exit(1) 28 | 29 | 30 | if __name__ == "__main__": 31 | app() 32 | -------------------------------------------------------------------------------- /src/nettowel/cli/textfsm.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import inspect as rich_inspect 3 | from rich import print_json 4 | 5 | from nettowel.cli._common import get_typer_app 6 | 7 | 8 | app = get_typer_app(help="TextFSM template functions") 9 | 10 | 11 | @app.command() 12 | def render( 13 | ctx: typer.Context, 14 | # demo: str, 15 | json: bool = typer.Option(default=False, help="json output"), 16 | ) -> None: 17 | try: 18 | raise NotImplementedError("Not Implemented Yet") 19 | if json: 20 | print_json(data=None) 21 | else: 22 | rich_inspect(None) 23 | raise typer.Exit(0) 24 | 25 | except NotImplementedError as exc: 26 | typer.echo(exc) 27 | raise typer.Exit(1) 28 | 29 | 30 | if __name__ == "__main__": 31 | app() 32 | -------------------------------------------------------------------------------- /src/nettowel/pandas.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import io 3 | from nettowel.logger import log 4 | from nettowel._common import needs 5 | 6 | _module = "pandas" 7 | 8 | try: 9 | import pandas as pd 10 | 11 | log.debug("Successfully imported %s", _module) 12 | PANDA_INSTALLED = True 13 | 14 | except ImportError: 15 | log.warning("Failed to import %s", _module) 16 | PANDA_INSTALLED = False 17 | 18 | 19 | def normalize(input_data: Any, sep: str) -> str: 20 | """Normalize/flatten yaml or json data 21 | 22 | Args: 23 | data (any): Python datastructure 24 | sep (str): Separator 25 | 26 | Returns: 27 | str: Normalized data as csv 28 | """ 29 | needs(PANDA_INSTALLED, "pandas", _module) 30 | df = pd.json_normalize(input_data, sep=sep) 31 | with io.StringIO() as f: 32 | df.to_csv(f) 33 | return f.getvalue() 34 | -------------------------------------------------------------------------------- /src/nettowel/yaml.py: -------------------------------------------------------------------------------- 1 | from typing import IO, Any 2 | import io 3 | import ruamel.yaml 4 | from ruamel.yaml.error import MarkedYAMLError 5 | from nettowel.exceptions import NettowelInputError 6 | 7 | 8 | def load(f: IO[str]) -> Any: 9 | """YAML load data from stream 10 | 11 | Args: 12 | f (IO[str]): IO Stream with data 13 | 14 | Returns: 15 | Any: YAML Data 16 | """ 17 | yaml = ruamel.yaml.YAML(typ="safe") 18 | try: 19 | return yaml.load(f) 20 | 21 | except MarkedYAMLError as exc: 22 | raise NettowelInputError(str(exc)) 23 | 24 | 25 | def dump(data: Any) -> str: 26 | """Dump data as YAML to stream 27 | 28 | Args: 29 | data: Data to dump as YAML 30 | 31 | Returns: 32 | str: YAML Data 33 | """ 34 | stream = io.StringIO() 35 | yaml = ruamel.yaml.YAML(typ="safe") 36 | yaml.default_flow_style = False 37 | yaml.dump(data=data, stream=stream) 38 | return stream.getvalue() 39 | -------------------------------------------------------------------------------- /src/nettowel/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class NettowelException(Exception): ... 5 | 6 | 7 | class NettowelDependencyMissing(NettowelException): 8 | def __init__( 9 | self, 10 | package: str, 11 | extra_group: str, 12 | msg_format: str = "{package} is not installed. Install it with pip: pip install nettowel[{group}]", 13 | ) -> None: 14 | self.package = package 15 | self.extra_group = extra_group 16 | self.msg = msg_format.format(package=package, group=extra_group) 17 | super().__init__(self.msg) 18 | 19 | 20 | class NettowelSyntaxError(NettowelException): ... 21 | 22 | 23 | class NettowelTimeoutError(NettowelException): ... 24 | 25 | 26 | class NettowelUsageError(NettowelException): ... 27 | 28 | 29 | class NettowelRestconfError(NettowelException): 30 | def __init__( 31 | self, 32 | error_str: str, 33 | server_msg: Any, 34 | ) -> None: 35 | self.error_str = error_str 36 | self.server_msg = server_msg 37 | super().__init__(self.error_str) 38 | 39 | 40 | class NettowelInputError(NettowelException): ... 41 | -------------------------------------------------------------------------------- /src/nettowel/cli/pandas.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import print_json, print 3 | from rich.panel import Panel 4 | from nettowel.cli._common import get_typer_app, auto_complete_paths, read_yaml 5 | 6 | 7 | app = get_typer_app(help="[EXPERIMENTAL] Pandas tools") 8 | 9 | 10 | @app.command() 11 | def flatten( 12 | ctx: typer.Context, 13 | data: typer.FileText = typer.Argument( 14 | ..., 15 | exists=True, 16 | file_okay=True, 17 | dir_okay=False, 18 | readable=True, 19 | resolve_path=True, 20 | allow_dash=True, 21 | help="JSON/YAML file to flatten.", 22 | autocompletion=auto_complete_paths, 23 | ), 24 | sep: str = typer.Option(default=".", help="separator"), 25 | json: bool = typer.Option(default=False, help="json output"), 26 | raw: bool = typer.Option(default=False, help="raw output"), 27 | ) -> None: 28 | from nettowel.pandas import normalize 29 | 30 | input_data = read_yaml(data) 31 | result = normalize(input_data, sep=sep) 32 | 33 | if json: 34 | print_json(data={"result": result}) 35 | elif raw: 36 | print(result) 37 | else: 38 | print(Panel(result, title=f"[yellow]flatten", border_style="blue")) 39 | 40 | 41 | if __name__ == "__main__": 42 | app() 43 | -------------------------------------------------------------------------------- /src/nettowel/cli/diff.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import inspect as rich_inspect 3 | from rich import print_json 4 | 5 | from nettowel.cli._common import get_typer_app 6 | 7 | 8 | app = get_typer_app(help="(Deep)Diff functions") 9 | 10 | 11 | @app.command() 12 | def text_diff( 13 | ctx: typer.Context, 14 | # demo: str, 15 | json: bool = typer.Option(default=False, help="json output"), 16 | ) -> None: 17 | try: 18 | raise NotImplementedError("Not Implemented Yet") 19 | if json: 20 | print_json(data=None) 21 | else: 22 | rich_inspect(None) 23 | raise typer.Exit(0) 24 | 25 | except NotImplementedError as exc: 26 | typer.echo(exc) 27 | raise typer.Exit(1) 28 | 29 | 30 | @app.command() 31 | def data_diff( 32 | ctx: typer.Context, 33 | # demo: str, 34 | json: bool = typer.Option(default=False, help="json output"), 35 | ) -> None: 36 | try: 37 | raise NotImplementedError("Not Implemented Yet") 38 | if json: 39 | print_json(data=None) 40 | else: 41 | rich_inspect(None) 42 | raise typer.Exit(0) 43 | 44 | except NotImplementedError as exc: 45 | typer.echo(exc) 46 | raise typer.Exit(1) 47 | 48 | 49 | if __name__ == "__main__": 50 | app() 51 | -------------------------------------------------------------------------------- /src/nettowel/jsonpatch.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Dict 2 | from nettowel.logger import log 3 | from nettowel._common import needs 4 | 5 | _module = "jsonpatch" 6 | 7 | try: 8 | from jsonpatch import JsonPatch 9 | 10 | log.debug("Successfully imported %s", _module) 11 | JSONPATCH_INSTALLED = True 12 | 13 | except ImportError: 14 | log.warning("Failed to import %s", _module) 15 | JSONPATCH_INSTALLED = False 16 | 17 | 18 | def create(src: Any, dst: Any) -> "JsonPatch": 19 | """Create a JSON patch [RFC 6902](http://tools.ietf.org/html/rfc6902) 20 | 21 | Args: 22 | src (any): Data source datastructure 23 | dst (any): Data destination datastructure 24 | 25 | Returns: 26 | JsonPatch: JsonPatch object containing the patch 27 | """ 28 | needs(JSONPATCH_INSTALLED, "jsonpatch", _module) 29 | patch = JsonPatch.from_diff(src=src, dst=dst) 30 | return patch 31 | 32 | 33 | def apply(patch: List[Dict[str, Any]], data: Any) -> Any: 34 | """Apply a JSON patch [RFC 6902](http://tools.ietf.org/html/rfc6902) 35 | 36 | Args: 37 | patch (list[dict]): List of patch instructions 38 | data (any): Data to apply the patch onto 39 | 40 | Returns: 41 | result: Updated data object 42 | """ 43 | needs(JSONPATCH_INSTALLED, "jsonpatch", _module) 44 | jp = JsonPatch(patch) 45 | result = jp.apply(data) 46 | return result 47 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT=nettowel 2 | CODE_DIRS=src/${PROJECT} tests 3 | IMG_URL=https://raw.githubusercontent.com/InfrastructureAsCode-ch/nettowel/main/imgs/ 4 | 5 | # Run pytest 6 | .PHONY: pytest 7 | pytest: 8 | uv run pytest -vs ${ARGS} 9 | 10 | # Check if the python code needs to be reformatted 11 | .PHONY: black 12 | black: 13 | uv run black --check ${CODE_DIRS} 14 | 15 | # Python type check 16 | .PHONY: mypy 17 | mypy: 18 | uv run mypy ${CODE_DIRS} 19 | 20 | # Runn pytest, black and mypy 21 | .PHONY: tests 22 | tests: pytest black mypy 23 | 24 | # use "make bump ARGS=patch" to bump the version. ARGS can be patch, minor or major. 25 | .PHONY: bump 26 | bump: 27 | uv version --bump ${ARGS} 28 | sed -i -E "s|\"\b[0-9]+.\b[0-9]+.\b[0-9]+\" # From Makefile|\"`uv version --short`\" # From Makefile|g" src/${PROJECT}/__init__.py 29 | sed -i -E "s|\"\b[0-9]+.\b[0-9]+.\b[0-9]+\" # From Makefile|\"`uv version --short`\" # From Makefile|g" tests/test_${PROJECT}.py 30 | 31 | # Used in the pipeline to change the image urls befor publishing it on pypi.org 32 | .PHONY: fiximageurls 33 | fiximageurls: 34 | sed -i "s|](imgs/|](${IMG_URL}|g" README.md 35 | 36 | # Create a new tag and push it to origin. This will triger the pipeline and a new release will be published 37 | .PHONY: tag 38 | tag: 39 | git checkout main 40 | git pull 41 | git tag -a "v`uv version --short`" -m "Version v`uv version --short`" 42 | git push --tags 43 | git checkout - 44 | -------------------------------------------------------------------------------- /tests/test_ttp_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typer.testing import CliRunner 3 | from nettowel.cli.ttp import app 4 | 5 | pytestmark = pytest.mark.ttp 6 | runner = CliRunner() 7 | 8 | 9 | def test_help() -> None: 10 | result = runner.invoke(app, ["--help"]) 11 | assert result.exit_code == 0 12 | assert "render" in result.stdout 13 | 14 | 15 | def test_render_arp() -> None: 16 | result = runner.invoke( 17 | app, ["render", "tests/ttp/data1.txt", "tests/ttp/template1.txt"] 18 | ) 19 | assert result.exit_code == 0 20 | assert '"protocol": "Internet",' in result.stdout 21 | assert '"ip": "10.18.8.2",' in result.stdout 22 | assert '"age": 0,' in result.stdout 23 | assert '"mac": "52:54:00:fd:7d:ba",' in result.stdout 24 | assert '"type": "ARPA",' in result.stdout 25 | assert '"interface": "GigabitEthernet6"' in result.stdout 26 | 27 | 28 | def test_render_interface() -> None: 29 | result = runner.invoke( 30 | app, 31 | [ 32 | "render", 33 | "tests/ttp/data2.txt", 34 | "tests/ttp/template2.txt", 35 | "--print-result-only", 36 | ], 37 | ) 38 | assert result.exit_code == 0 39 | assert "interface Loopback0" not in result.stdout 40 | assert '"ip": "192.168.0.113",' in result.stdout 41 | assert '"vrf": "CPE1",' in result.stdout 42 | assert '"ip": "2002::fd37",' in result.stdout 43 | assert '"description": "CPE_Acces_Vlan",' in result.stdout 44 | -------------------------------------------------------------------------------- /src/nettowel/cli/ip.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import typer 3 | from rich import inspect as rich_inspect 4 | from rich import print_json 5 | 6 | from nettowel.cli._common import get_members, cleanup_dict, get_typer_app 7 | 8 | from ipaddress import ip_address, ip_network, IPv4Address, IPv6Address 9 | 10 | 11 | app = get_typer_app(help="IP Address tools") 12 | 13 | 14 | @app.command() 15 | def ip_info( 16 | ctx: typer.Context, 17 | ip: str, 18 | json: bool = typer.Option(default=False, help="json output"), 19 | ) -> None: 20 | try: 21 | ip_obj: Union[IPv4Address, IPv6Address] = ip_address(ip) 22 | data = get_members(ip_obj) 23 | data.pop("packed") 24 | if json: 25 | print_json(data=cleanup_dict(data)) 26 | else: 27 | rich_inspect(ip_obj) 28 | raise typer.Exit(0) 29 | 30 | except ValueError as exc: 31 | typer.echo(exc, err=True) 32 | raise typer.Exit(1) 33 | 34 | 35 | @app.command() 36 | def network_info( 37 | ctx: typer.Context, 38 | network: str, 39 | json: bool = typer.Option(default=False, help="json output"), 40 | ) -> None: 41 | try: 42 | net_obj = ip_network(network) 43 | data = get_members(net_obj) 44 | for key in ["broadcast_address", "hostmask", "netmask", "network_address"]: 45 | data[key] = str(data[key]) 46 | 47 | if json: 48 | print_json(data=cleanup_dict(data)) 49 | else: 50 | rich_inspect(net_obj) 51 | raise typer.Exit(0) 52 | 53 | except ValueError as exc: 54 | typer.echo(exc, err=True) 55 | raise typer.Exit(1) 56 | 57 | 58 | if __name__ == "__main__": 59 | app() 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to NetTowel 2 | 3 | Contributions are highly welcomed and appreciated! 4 | 5 | ## Development Environment 6 | 7 | NetTowel uses [uv](https://docs.astral.sh/uv/) for packaging and 8 | dependency management. 9 | 10 | ### Tests 11 | 12 | NetTowel uses [`pytest`](https://docs.pytest.org/) testing tool 13 | Run tests with the following command: 14 | 15 | ``` 16 | make pytest 17 | ``` 18 | 19 | Make sure to test new code and not break existing tests. 20 | 21 | ### Type Checking 22 | 23 | NetTowel uses type annotations and `mypy` is used as a static type checker. 24 | Run the following to type check NetTowel: 25 | 26 | ``` 27 | make mypy 28 | ``` 29 | 30 | Please add type annotations for all new code. 31 | 32 | ### Code Formatting 33 | 34 | NetTowel uses [`black`](https://github.com/psf/black) for code formatting. 35 | Run the following to check the formatting: 36 | 37 | ``` 38 | make black 39 | ``` 40 | 41 | ### All Tests 42 | 43 | Before pushing a commit all tests should be run. 44 | Run the following command: 45 | 46 | ``` 47 | make pytest 48 | ``` 49 | 50 | ### Bump version 51 | 52 | The version can be updated with the following command: 53 | Steps: patch, minor, major, prepatch, preminor, premajor, prerelease. 54 | 55 | ```bash 56 | make bump ARGS=patch 57 | ``` 58 | 59 | ### Install branch from git with extras 60 | 61 | To install a branch with `pip` and be able to specify the extras use the following command: 62 | 63 | ```bash 64 | pip install git+https://github.com/InfrastructureAsCode-ch/nettowel.git@#egg=nettowel[] 65 | ``` 66 | 67 | Example: 68 | 69 | ```bash 70 | pip install git+https://github.com/InfrastructureAsCode-ch/nettowel.git@develop#egg=nettowel[full] 71 | ``` 72 | -------------------------------------------------------------------------------- /tests/test_jsonpatch.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | from typer.testing import CliRunner 6 | from nettowel.cli.jsonpatch import app 7 | 8 | pytestmark = pytest.mark.jsonpatch 9 | runner = CliRunner() 10 | 11 | 12 | def test_help() -> None: 13 | result = runner.invoke(app, ["--help"]) 14 | assert result.exit_code == 0 15 | assert "apply" in result.stdout 16 | assert "create" in result.stdout 17 | 18 | 19 | def test_create() -> None: 20 | result = runner.invoke( 21 | app, ["create", "tests/jsonpatch/data1.yaml", "tests/jsonpatch/data2.yaml"] 22 | ) 23 | assert result.exit_code == 0 24 | assert "replace" in result.stdout 25 | assert "/interfaces/1/mask" in result.stdout 26 | assert "255.255.255.192" in result.stdout 27 | 28 | 29 | def test_create_raw() -> None: 30 | result = runner.invoke( 31 | app, 32 | ["create", "tests/jsonpatch/data1.yaml", "tests/jsonpatch/data2.yaml", "--raw"], 33 | ) 34 | assert result.exit_code == 0 35 | result = json.loads(result.stdout) 36 | with Path("tests/jsonpatch/data_patch.json").open() as f: 37 | expected = json.load(f) 38 | assert result == expected 39 | 40 | 41 | def test_apply() -> None: 42 | result = runner.invoke( 43 | app, ["apply", "tests/jsonpatch/data_patch.json", "tests/jsonpatch/data1.yaml"] 44 | ) 45 | assert result.exit_code == 0 46 | assert "255.255.255.192" in result.stdout 47 | 48 | 49 | def test_apply_raw() -> None: 50 | result = runner.invoke( 51 | app, 52 | [ 53 | "apply", 54 | "tests/jsonpatch/data_patch.json", 55 | "tests/jsonpatch/data1.yaml", 56 | "--raw", 57 | ], 58 | ) 59 | assert result.exit_code == 0 60 | result = json.loads(result.stdout) 61 | assert isinstance(result, dict) 62 | assert result["interfaces"][1]["mask"] == "255.255.255.192" 63 | -------------------------------------------------------------------------------- /tests/test_yaml_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typer.testing import CliRunner 3 | from nettowel.cli.yaml import app 4 | 5 | pytestmark = pytest.mark.yaml 6 | runner = CliRunner() 7 | 8 | 9 | def test_help() -> None: 10 | result = runner.invoke(app, ["--help"]) 11 | assert result.exit_code == 0 12 | assert "load" in result.stdout 13 | assert "dump" in result.stdout 14 | 15 | 16 | def test_load_json1() -> None: 17 | result = runner.invoke(app, ["load", "tests/yaml/json1.json"]) 18 | assert result.exit_code == 0 19 | assert '"id": "G4hjVuvdSnWD03cug8sdOA",' in result.stdout 20 | assert '"categories": [],' in result.stdout 21 | 22 | 23 | def test_load_json1_raw() -> None: 24 | result = runner.invoke(app, ["load", "tests/yaml/json1.json", "--raw"]) 25 | assert result.exit_code == 0 26 | assert '"id": "G4hjVuvdSnWD03cug8sdOA",' in result.stdout 27 | assert '"categories": [],' in result.stdout 28 | assert "─ Data ─" not in result.stdout 29 | 30 | 31 | def test_load_yaml1() -> None: 32 | result = runner.invoke(app, ["load", "tests/yaml/yaml1.yaml"]) 33 | assert result.exit_code == 0 34 | assert '"name": "Urs",' in result.stdout 35 | assert '"pickups_lines": [' in result.stdout 36 | 37 | 38 | def test_load_yaml1_raw() -> None: 39 | result = runner.invoke(app, ["load", "tests/yaml/yaml1.yaml", "--raw"]) 40 | assert result.exit_code == 0 41 | assert '"name": "Urs",' in result.stdout 42 | assert '"pickups_lines": [' in result.stdout 43 | assert "─ Data ─" not in result.stdout 44 | 45 | 46 | def test_dump_json1() -> None: 47 | result = runner.invoke(app, ["dump", "tests/yaml/json1.json"]) 48 | assert result.exit_code == 0 49 | assert "id: G4hjVuvdSnWD03cug8sdOA" in result.stdout 50 | assert "categories: []" in result.stdout 51 | 52 | 53 | def test_dump_json1_raw() -> None: 54 | result = runner.invoke(app, ["dump", "tests/yaml/json1.json", "--raw"]) 55 | assert result.exit_code == 0 56 | assert "id: G4hjVuvdSnWD03cug8sdOA" in result.stdout 57 | assert "categories: []" in result.stdout 58 | assert "─ YAML ─" not in result.stdout 59 | -------------------------------------------------------------------------------- /src/nettowel/restconf.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | import json 3 | from nettowel.logger import log 4 | from nettowel.exceptions import NettowelRestconfError 5 | 6 | import requests 7 | import urllib3 8 | 9 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 10 | 11 | content_type = { 12 | "json": "application/yang-data+json", 13 | "xml": "application/yang-data+xml", 14 | } 15 | accept = { 16 | "json": "application/yang-data+json, application/yang-data.errors+json", 17 | "xml": "application/yang-data+xml, application/yang-data.errors+xml", 18 | } 19 | 20 | 21 | def send_request( 22 | method: str, 23 | url: str, 24 | username: str, 25 | password: str, 26 | data: Optional[str] = None, 27 | verify: bool = True, 28 | send_xml: bool = False, 29 | return_xml: bool = False, 30 | ) -> Any: 31 | try: 32 | headers = { 33 | "Content-Type": content_type["xml"] if send_xml else content_type["json"], 34 | "Accept": accept["xml"] if return_xml else accept["json"], 35 | } 36 | response = requests.request( 37 | method=method, 38 | url=url, 39 | headers=headers, 40 | auth=(username, password), 41 | data=data, 42 | verify=verify, 43 | ) 44 | response.raise_for_status() 45 | if response.status_code == 204: 46 | return "204 No Content" 47 | if response.status_code == 201: 48 | return "201 Created" 49 | if response.text == "": 50 | return "Empty Response" 51 | return response.json() if not return_xml else response.text 52 | except requests.exceptions.ConnectionError as exc: 53 | raise NettowelRestconfError(str(exc), None) 54 | except requests.exceptions.RequestException as exc: 55 | exc_response: Any = None 56 | if exc.request is not None and exc.response is not None: 57 | if not exc.response.text: 58 | exc_response = None 59 | else: 60 | if return_xml: 61 | return exc.response.text 62 | try: 63 | exc_response = exc.response.json() 64 | except json.decoder.JSONDecodeError: 65 | exc_response = exc.response.text 66 | log.debug(exc_response) 67 | raise NettowelRestconfError(str(exc), exc_response) 68 | -------------------------------------------------------------------------------- /tests/test_ipaddress_cli.py: -------------------------------------------------------------------------------- 1 | from typer.testing import CliRunner 2 | from nettowel.cli.ip import app 3 | 4 | runner = CliRunner() 5 | 6 | 7 | def test_help() -> None: 8 | result = runner.invoke(app, ["--help"]) 9 | assert result.exit_code == 0 10 | assert "ip-info" in result.stdout 11 | assert "network-info" in result.stdout 12 | 13 | 14 | def test_ip_info_v4() -> None: 15 | result = runner.invoke(app, ["ip-info", "127.0.0.1"]) 16 | assert result.exit_code == 0 17 | assert "is_global = False" in result.stdout 18 | assert "is_private = True" in result.stdout 19 | assert "version = 4" in result.stdout 20 | assert "reverse_pointer = '1.0.0.127.in-addr.arpa'" in result.stdout 21 | 22 | 23 | def test_ip_info_v6() -> None: 24 | result = runner.invoke(app, ["ip-info", "::1"]) 25 | assert result.exit_code == 0 26 | assert "is_global = False" in result.stdout 27 | assert "is_private = True" in result.stdout 28 | assert "version = 6" in result.stdout 29 | assert "0000:0000:0000:0000:0000:0000:0000:0001" in result.stdout 30 | 31 | 32 | def test_ip_info_error() -> None: 33 | result = runner.invoke(app, ["ip-info", "0.0.0.0/0"]) 34 | assert result.exit_code == 1 35 | assert "'0.0.0.0/0' does not appear to be an IPv4 or IPv6 address" in result.stderr 36 | 37 | 38 | def test_network_info_v4() -> None: 39 | result = runner.invoke(app, ["network-info", "10.0.0.0/8"]) 40 | assert result.exit_code == 0 41 | assert "is_global = False" in result.stdout 42 | assert "is_private = True" in result.stdout 43 | assert "version = 4" in result.stdout 44 | assert "prefixlen = 8" in result.stdout 45 | assert "num_addresses = 16777216" in result.stdout 46 | assert "with_hostmask = '10.0.0.0/0.255.255.255'" in result.stdout 47 | 48 | 49 | def test_network_info_v6() -> None: 50 | result = runner.invoke(app, ["network-info", "2001:db8:c0fe::/64"]) 51 | assert result.exit_code == 0 52 | assert "is_global = False" in result.stdout 53 | assert "is_private = True" in result.stdout 54 | assert "version = 6" in result.stdout 55 | assert "2001:0db8:c0fe:0000:0000:0000:0000:0000/64" in result.stdout 56 | assert "num_addresses = 18446744073709551616" in result.stdout 57 | 58 | 59 | def test_network_info_error() -> None: 60 | result = runner.invoke(app, ["network-info", "127.0.0.1/33"]) 61 | assert result.exit_code == 1 62 | assert ( 63 | "'127.0.0.1/33' does not appear to be an IPv4 or IPv6 network" in result.stderr 64 | ) 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env* 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # VS Code 132 | .vscode/ -------------------------------------------------------------------------------- /src/nettowel/cli/yaml.py: -------------------------------------------------------------------------------- 1 | import typer 2 | from rich import print_json, print 3 | from rich.panel import Panel 4 | from rich.syntax import Syntax 5 | from rich.json import JSON 6 | 7 | from nettowel.cli._common import ( 8 | auto_complete_paths, 9 | read_yaml, 10 | get_typer_app, 11 | ) 12 | from nettowel.yaml import dump as yaml_dump 13 | from nettowel.exceptions import NettowelInputError 14 | 15 | app = get_typer_app(help="YAML functions") 16 | 17 | 18 | @app.command() 19 | def load( 20 | ctx: typer.Context, 21 | data_file_name: typer.FileText = typer.Argument( 22 | ..., 23 | exists=True, 24 | file_okay=True, 25 | dir_okay=False, 26 | readable=True, 27 | resolve_path=True, 28 | allow_dash=True, 29 | metavar="YAML", 30 | help="YAML Data", 31 | autocompletion=auto_complete_paths, 32 | ), 33 | json: bool = typer.Option(False, "--json", help="JSON output"), 34 | raw: bool = typer.Option(False, "--raw", help="Raw result output"), 35 | ) -> None: 36 | try: 37 | data = read_yaml(data_file_name) 38 | if json or raw: 39 | print_json(data=data) 40 | else: 41 | data_output = JSON.from_data(data) 42 | print(Panel(data_output, title="[yellow]Data", border_style="blue")) 43 | raise typer.Exit(0) 44 | except NettowelInputError as exc: 45 | typer.echo("Input is not valide", err=True) 46 | typer.echo(str(exc), err=True) 47 | raise typer.Exit(3) 48 | 49 | 50 | @app.command() 51 | def dump( 52 | ctx: typer.Context, 53 | data_file_name: typer.FileText = typer.Argument( 54 | ..., 55 | exists=True, 56 | file_okay=True, 57 | dir_okay=False, 58 | readable=True, 59 | resolve_path=True, 60 | allow_dash=True, 61 | metavar="YAML", 62 | help="YAML Data", 63 | autocompletion=auto_complete_paths, 64 | ), 65 | json: bool = typer.Option(False, "--json", help="JSON output"), 66 | raw: bool = typer.Option(False, "--raw", help="Raw result output"), 67 | ) -> None: 68 | try: 69 | data = read_yaml(data_file_name) 70 | result = yaml_dump(data).strip() 71 | if json: 72 | print_json(data={"yaml": result}) 73 | elif raw: 74 | print(result) 75 | else: 76 | print( 77 | Panel( 78 | Syntax( 79 | result, 80 | "yaml", 81 | line_numbers=True, 82 | indent_guides=True, 83 | ), 84 | border_style="blue", 85 | title="[yellow]YAML", 86 | ) 87 | ) 88 | raise typer.Exit(0) 89 | except NettowelInputError as exc: 90 | typer.echo("Input is not valide", err=True) 91 | typer.echo(str(exc), err=True) 92 | raise typer.Exit(3) 93 | 94 | 95 | if __name__ == "__main__": 96 | app() 97 | -------------------------------------------------------------------------------- /src/nettowel/netconf.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from nettowel.logger import log 3 | from nettowel._common import needs 4 | from nettowel.exceptions import NettowelTimeoutError 5 | 6 | _module = "ncclient" 7 | 8 | try: 9 | from ncclient import manager, xml_ 10 | 11 | log.debug("Successfully imported %s", _module) 12 | NCCLIENT_INSTALLED = True 13 | 14 | except ImportError: 15 | log.warning("Failed to import %s", _module) 16 | NCCLIENT_INSTALLED = False 17 | 18 | 19 | def get( 20 | host: str, 21 | username: Optional[str] = None, 22 | password: Optional[str] = None, 23 | port: int = 22, 24 | lock: bool = False, 25 | hostkey_verify: bool = True, 26 | ) -> str: 27 | try: 28 | with manager.connect( 29 | host=host, 30 | username=username, 31 | password=password, 32 | port=port, 33 | hostkey_verify=hostkey_verify, 34 | ) as m: 35 | try: 36 | if lock: 37 | m.lock("running") 38 | _ = m.get_config(source="running") 39 | 40 | filter_interface = """ 41 | 42 | 43 | 44 | """ 45 | 46 | _ = m.get(filter=("subtree", filter_interface)) 47 | 48 | filter_exec_banner = """ 49 | 50 | 51 | 52 | 53 | 54 | """ 55 | _ = m.get(filter=("subtree", filter_exec_banner)) 56 | 57 | _ = m.get(filter=("xpath", "/native/banner/exec")) 58 | 59 | set_exec_banner = """ 60 | 61 | 62 | 63 | 64 | 65 | test1234 66 | 67 | 68 | 69 | 70 | 71 | """ 72 | _ = m.edit_config( 73 | target="running", config=set_exec_banner, default_operation="merge" 74 | ) 75 | 76 | del_exec_banner = """ 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | """ 85 | result = m.edit_config(target="running", config=del_exec_banner) 86 | 87 | return str(result) 88 | 89 | finally: 90 | if lock: 91 | m.unlock("running") 92 | 93 | except Exception as esc: 94 | # raise NettowelTimeoutError(str(esc)) 95 | raise esc 96 | 97 | 98 | filter = """ 99 | 100 | 101 | """ 102 | -------------------------------------------------------------------------------- /src/nettowel/netmiko.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | from nettowel._common import needs 3 | from nettowel.logger import log 4 | from nettowel.exceptions import NettowelTimeoutError 5 | 6 | _module = "netmiko" 7 | 8 | try: 9 | from netmiko import ConnectHandler, SSHDetect 10 | from netmiko.ssh_dispatcher import CLASS_MAPPER_BASE as NETMIKO_DEVICE_TYPES 11 | from netmiko.base_connection import BaseConnection 12 | from netmiko.ssh_exception import NetmikoTimeoutException 13 | 14 | log.debug("Successfully imported %s", _module) 15 | NETMIKO_INSTALLED = True 16 | 17 | except ImportError: 18 | log.warning("Failed to import %s", _module) 19 | NETMIKO_INSTALLED = False 20 | 21 | 22 | def get_device_types() -> List[str]: 23 | needs(NETMIKO_INSTALLED, "Netmiko", _module) 24 | return list(NETMIKO_DEVICE_TYPES.keys()) 25 | 26 | 27 | def send_command( 28 | cmd: str, 29 | device_type: str, 30 | host: str, 31 | username: Optional[str] = None, 32 | password: Optional[str] = None, 33 | port: int = 22, 34 | secret: str = "", 35 | use_textfsm: bool = False, 36 | use_ttp: bool = False, 37 | ttp_template: Optional[str] = None, 38 | use_genie: bool = False, 39 | ssh_config_file: Optional[str] = None, 40 | use_keys: bool = False, 41 | key_file: Optional[str] = None, 42 | session_log: Optional[str] = None, 43 | ) -> str: 44 | try: 45 | device: BaseConnection = ConnectHandler( 46 | device_type=device_type, 47 | host=host, 48 | username=username, 49 | password=password, 50 | port=port, 51 | secret=secret, 52 | ssh_config_file=ssh_config_file, 53 | use_keys=use_keys, 54 | key_file=key_file, 55 | session_log=session_log, 56 | ) 57 | if not device.check_enable_mode(): 58 | device.enable() 59 | 60 | return str( 61 | device.send_command( 62 | cmd, 63 | use_textfsm=use_textfsm, 64 | use_genie=use_genie, 65 | use_ttp=use_ttp, 66 | ttp_template=ttp_template, 67 | ) 68 | ) 69 | except NetmikoTimeoutException as esc: 70 | raise NettowelTimeoutError(str(esc)) 71 | 72 | 73 | def autodetect( 74 | host: str, 75 | username: Optional[str] = None, 76 | password: Optional[str] = None, 77 | port: int = 22, 78 | secret: str = "", 79 | ssh_config_file: Optional[str] = None, 80 | use_keys: bool = False, 81 | key_file: Optional[str] = None, 82 | session_log: Optional[str] = None, 83 | ) -> str: 84 | guesser = SSHDetect( 85 | device_type="autodetect", 86 | host=host, 87 | username=username, 88 | password=password, 89 | port=port, 90 | secret=secret, 91 | ssh_config_file=ssh_config_file, 92 | use_keys=use_keys, 93 | key_file=key_file, 94 | session_log=session_log, 95 | ) 96 | return str(guesser.autodetect()) 97 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nettowel" 3 | version = "0.6.1" 4 | description = "Network Automation Collection" 5 | readme = "README.md" 6 | authors = [{ name = "ubaumann", email = "github@m.ubaumann.ch" }] 7 | license = { text = "Apache 2.0" } 8 | classifiers = [ 9 | "License :: OSI Approved :: Apache Software License", 10 | "Programming Language :: Python :: 3.10", 11 | "Programming Language :: Python :: 3.11", 12 | "Programming Language :: Python :: 3.12", 13 | "Programming Language :: Python :: 3.13", 14 | ] 15 | requires-python = ">=3.10" 16 | dependencies = [ 17 | "typer>=0.16", 18 | "rich>=12,>=13", 19 | "ruamel.yaml>=0.18", 20 | "qrcode>=8", 21 | "python-dotenv>=1", 22 | "requests>=2", 23 | ] 24 | 25 | [project.urls] 26 | Homepage = "https://github.com/InfrastructureAsCode-ch/nettowel" 27 | Documentation = "https://github.com/InfrastructureAsCode-ch/nettowel" 28 | Repository = "https://github.com/InfrastructureAsCode-ch/nettowel" 29 | Issues = "https://github.com/InfrastructureAsCode-ch/nettowel/issues" 30 | Changelog = "https://github.com/InfrastructureAsCode-ch/nettowel/releases" 31 | 32 | [project.scripts] 33 | nettowel = "nettowel.cli.main:run" 34 | nt = "nettowel.cli.main:run" 35 | 36 | [project.optional-dependencies] 37 | jinja = ["jinja2>=3", "jinja2schema>=0.1"] 38 | ttp = ["ttp>=0.9"] 39 | textfsm = ["textfsm>=1"] 40 | napalm = ["napalm>=5"] 41 | netmiko = ["netmiko>=4"] 42 | scrapli = ["scrapli>=2025.1"] 43 | nornir = [ 44 | "nornir>=3.5", 45 | "nornir-http", 46 | "nornir-jinja2", 47 | "nornir-napalm", 48 | "nornir-netmiko", 49 | "nornir-pyxl", 50 | "nornir-rich", 51 | "nornir-scrapli", 52 | "nornir-utils", 53 | ] 54 | pandas = ["pandas>=2"] 55 | tui = ["trogon>=0.6"] 56 | jsonpatch = ["jsonpatch>=1"] 57 | full = [ 58 | "jinja2", 59 | "jinja2schema", 60 | "jsonpatch", 61 | "napalm", 62 | "netmiko", 63 | "nornir", 64 | "nornir-http", 65 | "nornir-jinja2", 66 | "nornir-napalm", 67 | "nornir-netmiko", 68 | "nornir-pyxl", 69 | "nornir-rich", 70 | "nornir-scrapli", 71 | "nornir-utils", 72 | "pandas", 73 | "scrapli", 74 | "textfsm", 75 | "trogon", 76 | "ttp", 77 | ] 78 | 79 | [build-system] 80 | requires = ["uv_build>=0.8.4,<0.9.0"] 81 | build-backend = "uv_build" 82 | 83 | [dependency-groups] 84 | dev = [ 85 | "black>=24.8.0", 86 | "mypy>=1.14.1", 87 | "pytest>=8.3.5", 88 | "types-requests>=2.32.0.20241016", 89 | ] 90 | 91 | [tool.mypy] 92 | python_version = "3.13" 93 | check_untyped_defs = true 94 | disallow_any_generics = true 95 | disallow_untyped_calls = true 96 | disallow_untyped_defs = true 97 | disallow_incomplete_defs = true 98 | disallow_untyped_decorators = true 99 | ignore_errors = false 100 | ignore_missing_imports = true 101 | strict_optional = true 102 | warn_unused_configs = true 103 | warn_unused_ignores = true 104 | warn_return_any = true 105 | warn_redundant_casts = true 106 | 107 | [[tool.mypy.overrides]] 108 | module = "nornir.core" 109 | ignore_errors = true 110 | 111 | [tool.pytest.ini_options] 112 | markers = [ 113 | "jinja", 114 | "jsonpatch", 115 | "ttp", 116 | "yaml", 117 | ] 118 | -------------------------------------------------------------------------------- /src/nettowel/cli/main.py: -------------------------------------------------------------------------------- 1 | import typer 2 | 3 | from rich.panel import Panel 4 | from rich.text import Text 5 | from rich.columns import Columns 6 | from rich.markdown import Markdown 7 | from rich.console import Group 8 | from rich import print 9 | 10 | from nettowel import __version__ as version 11 | from nettowel.cli._common import get_typer_app, auto_complete_paths 12 | from nettowel.cli.ip import app as ipaddress_app 13 | from nettowel.cli.jinja import app as jinja_app 14 | from nettowel.cli.ttp import app as ttp_app 15 | from nettowel.cli.textfsm import app as textfsm_app 16 | from nettowel.cli.diff import app as diff_app 17 | from nettowel.cli.yaml import app as yaml_app 18 | from nettowel.cli.nornir import app as nornir_app 19 | from nettowel.cli.napalm import app as napalm_app 20 | from nettowel.cli.netmiko import app as netmiko_app 21 | from nettowel.cli.scrapli import app as scrapli_app 22 | from nettowel.cli.restconf import app as restconf_app 23 | from nettowel.cli.pandas import app as pandas_app 24 | from nettowel.cli.jsonpatch import app as jsonpatch_app 25 | from nettowel.cli.help import get_qrcode, HELP_MARKDOWN 26 | 27 | from nettowel.exceptions import NettowelDependencyMissing 28 | 29 | app = get_typer_app(help="Awesome collection of network automation functions") 30 | 31 | for subapp, name in [ 32 | (ipaddress_app, "ipaddress"), 33 | (jinja_app, "jinja"), 34 | (ttp_app, "ttp"), 35 | (textfsm_app, "textFSM"), 36 | (diff_app, "diff"), 37 | (yaml_app, "yaml"), 38 | (nornir_app, "nornir"), 39 | (napalm_app, "napalm"), 40 | (netmiko_app, "netmiko"), 41 | (scrapli_app, "scrapli"), 42 | (restconf_app, "restconf"), 43 | (pandas_app, "pandas"), 44 | (jsonpatch_app, "jsonpatch"), 45 | ]: 46 | app.add_typer(subapp, name=name) 47 | 48 | 49 | @app.command() 50 | def help( 51 | ctx: typer.Context, 52 | ) -> None: 53 | qrcodes = [] 54 | for title, data in [ 55 | ("GitHub NetTowel", "https://github.com/InfrastructureAsCode-ch/nettowel"), 56 | ("@InfraAsCode", "https://twitter.com/InfraAsCode/with_replies"), 57 | ("infrastructureascode.ch", "https://infrastructureascode.ch/?from=nettowel"), 58 | ]: 59 | qrcodes.append( 60 | Panel( 61 | get_qrcode(data), 62 | title=f"[yellow][link={data}]{title}[/link]", 63 | border_style="blue", 64 | ) 65 | ) 66 | qr_panels = Columns(qrcodes, equal=True) 67 | title_panel = Panel( 68 | Text(f"NetTowel {version}", style="b yellow", justify="center"), 69 | border_style="yellow", 70 | ) 71 | markdown = Markdown(HELP_MARKDOWN) 72 | top = Panel( 73 | Group(title_panel, markdown, qr_panels), 74 | title="[blue]Help", 75 | expand=False, 76 | border_style="green", 77 | ) 78 | print(top) 79 | 80 | 81 | @app.command(help="Textual/Trogon TUI") 82 | def tui(ctx: typer.Context) -> None: 83 | try: 84 | from nettowel.trogon_tui import run_trogon_tui 85 | 86 | run_trogon_tui(app=app, ctx=ctx) 87 | except NettowelDependencyMissing as exc: 88 | typer.echo(str(exc), err=True) 89 | raise typer.Exit(1) 90 | 91 | 92 | def run() -> None: 93 | app() 94 | 95 | 96 | if __name__ == "__main__": 97 | run() 98 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test nettowel 3 | on: [push,pull_request] 4 | 5 | jobs: 6 | linters: 7 | name: linters 8 | strategy: 9 | matrix: 10 | python-version: [ '3.13' ] 11 | platform: [ubuntu-latest] 12 | runs-on: ${{ matrix.platform }} 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install uv and set the python version 17 | uses: astral-sh/setup-uv@v6 18 | with: 19 | enable-cache: true 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install the project 23 | run: uv sync --locked --all-extras --dev 24 | 25 | - name: Run black 26 | run: make black 27 | - name: Run mypy 28 | run: make mypy 29 | 30 | pytest: 31 | name: Testing on Python ${{ matrix.python-version }} (${{ matrix.platform}}) 32 | defaults: 33 | run: 34 | shell: bash 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | python-version: [ '3.10', '3.11', '3.12', '3.13' ] 39 | platform: [ubuntu-latest, macos-13, windows-latest] 40 | runs-on: ${{ matrix.platform }} 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - name: Install uv and set the python version 45 | uses: astral-sh/setup-uv@v6 46 | with: 47 | enable-cache: true 48 | python-version: ${{ matrix.python-version }} 49 | 50 | - name: Install the project 51 | run: uv sync --locked --all-extras --dev 52 | 53 | - name: Run pytest 54 | run: uv run pytest -vs 55 | 56 | pytest_extras: 57 | name: Testing ${{ matrix.extra }} on Python ${{ matrix.python-version }} (${{ matrix.platform}}) 58 | defaults: 59 | run: 60 | shell: bash 61 | strategy: 62 | fail-fast: false 63 | matrix: 64 | python-version: [ '3.10', '3.11', '3.12', '3.13' ] 65 | platform: [ubuntu-latest, macos-13, windows-latest] 66 | extra: [ 'jinja', 'ttp', 'jsonpatch'] 67 | runs-on: ${{ matrix.platform }} 68 | steps: 69 | - uses: actions/checkout@v4 70 | 71 | - name: Install uv and set the python version 72 | uses: astral-sh/setup-uv@v6 73 | with: 74 | enable-cache: true 75 | python-version: ${{ matrix.python-version }} 76 | 77 | - name: Install the project 78 | run: uv sync --locked --extra ${{ matrix.extra }} 79 | 80 | - name: Run pytest 81 | run: uv run pytest -v -m ${{ matrix.extra }} 82 | 83 | release: 84 | name: Releasing to pypi 85 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') 86 | needs: [linters, pytest, pytest_extras] 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v2 90 | 91 | - name: Install uv and set the python version 92 | uses: astral-sh/setup-uv@v6 93 | with: 94 | enable-cache: true 95 | python-version: "3.13" 96 | 97 | - name: prepare release 98 | run: make fiximageurls 99 | 100 | - name: build release 101 | run: uv build 102 | 103 | - name: Publish package to PyPI 104 | uses: pypa/gh-action-pypi-publish@release/v1 105 | with: 106 | user: __token__ 107 | password: ${{ secrets.PYPI_API_TOKEN }} 108 | 109 | - uses: actions/upload-artifact@v3 110 | with: 111 | name: build 112 | path: dist/* 113 | -------------------------------------------------------------------------------- /src/nettowel/jinja.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Tuple, Dict, Union 2 | from nettowel.exceptions import NettowelDependencyMissing, NettowelSyntaxError 3 | from nettowel.logger import log 4 | from nettowel._common import needs 5 | 6 | _module = "jinja" 7 | 8 | try: 9 | from jinja2 import Environment, Undefined, exceptions 10 | 11 | log.debug("Successfully imported %s", _module) 12 | JINJA_INSTALLED = True 13 | 14 | except ImportError: 15 | log.warning("Failed to import %s", _module) 16 | JINJA_INSTALLED = False 17 | 18 | 19 | def validate_template(template: str) -> Tuple[bool, Dict[str, Union[str, int, None]]]: 20 | """Validate jinja2 template 21 | 22 | Args: 23 | template (str): jinja2 template 24 | 25 | Returns: 26 | Tuple[bool, Dict[str, Union[str, int, None]]]: (True, {}) if template is valid. (False, {"message": ..., "lineno": ..., "line": ...}) 27 | """ 28 | needs(JINJA_INSTALLED, "Jinja2", _module) 29 | try: 30 | Environment().parse(template) 31 | return True, dict() 32 | except exceptions.TemplateSyntaxError as exc: 33 | try: 34 | line = template.splitlines()[exc.lineno - 1] 35 | except IndexError: 36 | line = "" 37 | 38 | result = {"message": exc.message, "lineno": exc.lineno, "line": line} 39 | 40 | return False, result 41 | 42 | 43 | def render_template( 44 | template: str, 45 | data: Any, 46 | trim_blocks: bool = False, 47 | lstrip_blocks: bool = False, 48 | keep_trailing_newline: bool = False, 49 | ) -> str: 50 | """Rendering jinja2 template 51 | 52 | Args: 53 | template (str): Jinja template 54 | data (Any): Data to add to the rendering context. If data is not a dictionary it will be stored in one under the key 'data' 55 | trim_blocks (bool, optional): If this is set to True the first newline after a block is removed. Defaults to False. 56 | lstrip_blocks (bool, optional): If this is set to True leading spaces and tabs are stripped from the start of a line to a block. Defaults to False. 57 | keep_trailing_newline (bool, optional): Preserve the trailing newline when rendering templates. Defaults to False. 58 | 59 | Returns: 60 | str: Rendered template 61 | """ 62 | needs(JINJA_INSTALLED, "Jinja2", _module) 63 | jinja_env = Environment( 64 | trim_blocks=trim_blocks, 65 | lstrip_blocks=lstrip_blocks, 66 | keep_trailing_newline=keep_trailing_newline, 67 | # extensions=[], 68 | undefined=Undefined, 69 | ) 70 | if not isinstance(data, dict): 71 | data = {"data": data} 72 | 73 | try: 74 | return jinja_env.from_string(template).render(**data) 75 | except exceptions.TemplateSyntaxError as exc: 76 | raise NettowelSyntaxError(str(exc)) 77 | 78 | 79 | def get_variables(template: str) -> Any: 80 | """Get a list of variables with jinja2schema 81 | 82 | Args: 83 | template (str): Jinja template 84 | 85 | Raises: 86 | Exception: _description_ 87 | 88 | Returns: 89 | Any: Return Dict with JSON Schema data 90 | """ 91 | needs(JINJA_INSTALLED, "Jinja2", _module) 92 | try: 93 | from jinja2schema import infer, to_json_schema 94 | except ImportError: 95 | raise NettowelDependencyMissing("jinja2schema", _module) 96 | 97 | try: 98 | schema = infer(template) 99 | except exceptions.TemplateSyntaxError as exc: 100 | raise NettowelSyntaxError(str(exc)) 101 | return to_json_schema(schema) 102 | -------------------------------------------------------------------------------- /src/nettowel/cli/_common.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typer 3 | from typing import Any, Dict, Generator, Optional, List 4 | from dotenv import load_dotenv, find_dotenv 5 | from pathlib import Path 6 | from nettowel.yaml import load as yaml_load 7 | from nettowel.cli.logging import configure_logger, log 8 | 9 | VERBOSE_LEVEL = -1 10 | DOTENV_LOADED = False 11 | 12 | 13 | def _load_dotenv(dotenv: str) -> None: 14 | global DOTENV_LOADED 15 | if dotenv: 16 | log.debug("Loading dotenv file %s", dotenv) 17 | load_dotenv(dotenv) 18 | else: 19 | if DOTENV_LOADED: 20 | log.debug("Default dotenv file already loaded") 21 | return 22 | log.debug("Search in increasingly higher folders for dotenv file from cwd") 23 | dotenv_file = find_dotenv(usecwd=True) 24 | if dotenv_file: 25 | log.debug("Loading dotenv file %s", dotenv_file) 26 | load_dotenv(dotenv_file) 27 | DOTENV_LOADED = True 28 | 29 | 30 | def _config_logging(verbose: int) -> None: 31 | global VERBOSE_LEVEL 32 | if VERBOSE_LEVEL == -1: 33 | VERBOSE_LEVEL = verbose 34 | elif verbose == 0: 35 | return 36 | else: 37 | VERBOSE_LEVEL += verbose 38 | level = 10 * (5 - max(0, min(VERBOSE_LEVEL, 4))) 39 | configure_logger(level=level) 40 | log.debug("Set logging level to %d", level) 41 | 42 | 43 | def auto_complete_paths(incomplete: str) -> Generator[str, None, None]: 44 | incomplete_path = Path(incomplete) 45 | if incomplete_path.is_dir(): 46 | glob = incomplete_path.glob("*") 47 | else: 48 | glob = incomplete_path.parent.glob(f"{incomplete_path.stem}*") 49 | for path in glob: 50 | if path.is_dir(): 51 | yield f"{path}/" 52 | yield str(path) 53 | 54 | 55 | def callback( 56 | dotenv: typer.FileText = typer.Option( 57 | None, 58 | exists=True, 59 | file_okay=True, 60 | dir_okay=False, 61 | readable=True, 62 | resolve_path=True, 63 | help=".env configuration file. If no file is specified, an attempt is made to search the .env file from cwd.", 64 | autocompletion=auto_complete_paths, 65 | ), 66 | verbose: int = typer.Option( 67 | 0, 68 | "--verbose", 69 | "-v", 70 | count=True, 71 | help="Set de logging level. Default level is 'CRITICAL'", 72 | ), 73 | ) -> None: 74 | _config_logging(verbose) 75 | _load_dotenv(str(dotenv) if dotenv else "") 76 | 77 | 78 | def get_typer_app(help: str) -> typer.Typer: 79 | return typer.Typer(help=help, callback=callback) 80 | 81 | 82 | def get_members(obj: object, members: Optional[List[str]] = None) -> Dict[str, Any]: 83 | if members is None: 84 | members = [member for member in dir(obj) if not member.startswith("_")] 85 | return {x: getattr(obj, x) for x in members} 86 | 87 | 88 | def cleanup_dict(data: Dict[str, Any]) -> Dict[str, Any]: 89 | return {k: v for k, v in data.items() if not callable(v)} 90 | 91 | 92 | def read_text(data_file_name: typer.FileText) -> str: 93 | if data_file_name == "-": 94 | return sys.stdin.read() 95 | else: 96 | with open(data_file_name) as template_file: # type: ignore 97 | text: str = template_file.read() 98 | return text 99 | 100 | 101 | def read_yaml(data_file_name: typer.FileText) -> Any: 102 | if data_file_name == "-": 103 | return yaml_load(sys.stdin) 104 | else: 105 | with open(data_file_name) as data_file: # type: ignore 106 | return yaml_load(data_file) 107 | -------------------------------------------------------------------------------- /tests/test_jinja_cli.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typer.testing import CliRunner 3 | from nettowel.cli.jinja import app 4 | 5 | pytestmark = pytest.mark.jinja 6 | runner = CliRunner() 7 | 8 | 9 | def test_help() -> None: 10 | result = runner.invoke(app, ["--help"]) 11 | assert result.exit_code == 0 12 | assert "render" in result.stdout 13 | assert "validate" in result.stdout 14 | assert "variables" in result.stdout 15 | 16 | 17 | def test_render_hi() -> None: 18 | result = runner.invoke( 19 | app, ["render", "tests/jinja/template1.j2", "tests/jinja/data1.yaml"] 20 | ) 21 | assert result.exit_code == 0 22 | assert "Hi Joe" in result.stdout 23 | assert "How are you doing?" in result.stdout 24 | 25 | 26 | def test_render_template_notfound() -> None: 27 | result = runner.invoke(app, ["render", "notfound", "tests/jinja/data1.yaml"]) 28 | assert result.exit_code == 2 29 | assert ( 30 | "Invalid value for 'TEMPLATE': File 'notfound' does not exist" in result.stderr 31 | ) 32 | 33 | 34 | def test_render_data_notfound() -> None: 35 | result = runner.invoke(app, ["render", "tests/jinja/template1.j2", "notfound"]) 36 | assert result.exit_code == 2 37 | assert "Invalid value for 'DATA': File 'notfound' does not exist" in result.stderr 38 | 39 | 40 | def test_render_macdonald_trim_blocks() -> None: 41 | result = runner.invoke( 42 | app, 43 | [ 44 | "render", 45 | "tests/jinja/template2.j2", 46 | "tests/jinja/data2.yaml", 47 | "--trim-blocks", 48 | ], 49 | ) 50 | assert result.exit_code == 0 51 | assert "9 Here a oink, there a oink, everywhere a oink oink." in result.stdout 52 | assert "15 Old MacDonald had a farm, E I E I O." in result.stdout 53 | 54 | 55 | def test_render_interface_result_only() -> None: 56 | result = runner.invoke( 57 | app, 58 | [ 59 | "render", 60 | "tests/jinja/template3.j2", 61 | "tests/jinja/data3.yaml", 62 | "--print-result-only", 63 | ], 64 | ) 65 | assert result.exit_code == 0 66 | assert "{% for interface in interfaces %}" not in result.stdout 67 | assert "ip address 192.168.1.1 255.255.255.0" in result.stdout 68 | 69 | 70 | def test_render_hi_raw() -> None: 71 | result = runner.invoke( 72 | app, ["render", "tests/jinja/template1.j2", "tests/jinja/data1.yaml", "--raw"] 73 | ) 74 | assert result.exit_code == 0 75 | assert "Hi Joe\nHow are you doing?\n" == result.stdout 76 | 77 | 78 | def test_render_error() -> None: 79 | result = runner.invoke( 80 | app, ["render", "tests/jinja/templateFail.j2", "tests/jinja/data1.yaml"] 81 | ) 82 | assert result.exit_code == 2 83 | assert "expected token ':', got '}'" in result.stderr 84 | 85 | 86 | def test_validate_hi() -> None: 87 | result = runner.invoke(app, ["validate", "tests/jinja/data1.yaml"]) 88 | assert result.exit_code == 0 89 | assert "Template is valid" in result.stdout 90 | 91 | 92 | def test_validate_fail() -> None: 93 | result = runner.invoke(app, ["validate", "tests/jinja/templateFail.j2"]) 94 | assert result.exit_code == 1 95 | assert "Template is not valide" in result.stdout 96 | assert "{% if {{ test }} %}" in result.stdout 97 | assert "lineno = 4" in result.stdout 98 | 99 | 100 | def test_variables_template4() -> None: 101 | result = runner.invoke(app, ["variables", "tests/jinja/template4.j2"]) 102 | assert result.exit_code == 0 103 | assert "📚 Variables" in result.stdout 104 | assert "interfaces (required)" in result.stdout 105 | assert "── mylist (required)" in result.stdout 106 | 107 | 108 | def test_variables_error() -> None: 109 | result = runner.invoke(app, ["variables", "tests/jinja/templateFail.j2"]) 110 | assert result.exit_code == 2 111 | assert "expected token ':', got '}'" in result.stderr 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI versions](https://img.shields.io/pypi/pyversions/nettowel.svg)](https://pypi.python.org/pypi/nettowel/) 2 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 3 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) 4 | [![Downloads](https://pepy.tech/badge/nettowel)](https://pepy.tech/project/nettowel) 5 | 6 | # NetTowel 7 | Collection of useful network automation functions 8 | 9 | 10 | > ⚠️ `nettowel` is under heavy construction and not production ready. Feedback is highly appreciated. 11 | 12 | 13 | ## Install 14 | It is recommended to install `nettowel` with [pipx](https://pipx.pypa.io/). Therefore you have the dependencies isolated and you can use the `nettowel` or `nt` command. 15 | 16 | ```bash 17 | pipx install nettowel[full] 18 | ``` 19 | 20 | You can also install it directly from pypi 21 | 22 | ```bash 23 | pip install nettowel 24 | ``` 25 | 26 | To reduce the dependencies the extra dependencies are grouped 27 | 28 | The following groups are available (more details in the pyproject.toml): 29 | 30 | - full 31 | - jinja 32 | - ttp 33 | - textfsm 34 | - napalm 35 | - netmiko 36 | - scrapli 37 | - nornir 38 | - pandas 39 | - jsonpatch 40 | - tui 41 | 42 | ```bash 43 | pip install nettowel[jinja] 44 | pip install nettowel[full] 45 | ``` 46 | 47 | ## Install from source 48 | 49 | ``` 50 | git clone .... 51 | cd nettowel 52 | uv sync --extra full 53 | uv run nettowel --help 54 | ``` 55 | 56 | 57 | ## Help and shell auto-completion 58 | 59 | Thanks to the library [typer](https://typer.tiangolo.com/), `nettowel` comes with a nice help and autocompletion install 60 | 61 | ![help](imgs/help.png) 62 | 63 | 64 | ## Features 65 | 66 | Many features are not implemented yet and many features will come. 67 | 68 | 69 | 70 | ### Jinja2 71 | 72 | #### render 73 | 74 | ![jinja rendering 1](imgs/jinja-render-3.png) 75 | 76 | ![jinja rendering 2](imgs/jinja-render-1.png) 77 | 78 | #### validate 79 | 80 | ![jinja validate](imgs/jinja-validate.png) 81 | 82 | #### variables 83 | 84 | ![jinja variables](imgs/jinja-variables.png) 85 | 86 | 87 | ### TTP 88 | 89 | #### render 90 | 91 | ![ttp render](imgs/ttp-render.png) 92 | 93 | ### Netmiko 94 | 95 | #### cli 96 | 97 | ![netmiko cli](imgs/netmiko-cli.png) 98 | 99 | #### autodetect 100 | 101 | ![netmiko autodetect](imgs/netmiko-autodetect.png) 102 | 103 | #### device-types 104 | 105 | ![netmiko device types](imgs/netmiko-device-types.png) 106 | 107 | 108 | ### RESTCONF 109 | 110 | #### get 111 | 112 | ![restconf get](imgs/restconf-get.png) 113 | 114 | #### patch, delete 115 | 116 | ![restconf patch delete](imgs/restconf-patch-delete.png) 117 | 118 | #### post, put 119 | 120 | ![restconf post put](imgs/restconf-post-put.png) 121 | 122 | ### ipaddress 123 | 124 | #### ip-info 125 | 126 | ![ip info](imgs/ip-info.png) 127 | 128 | #### network-info 129 | 130 | ![network info](imgs/network-info.png) 131 | 132 | 133 | ### YAML 134 | 135 | #### load 136 | 137 | ![yaml load](imgs/yaml-load.png) 138 | 139 | #### dump 140 | 141 | ![yaml dump](imgs/yaml-dump.png) 142 | 143 | 144 | ### JSON Patch ([RFC 6902](http://tools.ietf.org/html/rfc6902)) 145 | 146 | #### create 147 | 148 | ![JSON Patch create](imgs/jsonpatch-create.png) 149 | 150 | #### apply 151 | 152 | ![JSON Patch apply](imgs/jsonpatch-apply.png) 153 | 154 | 155 | ### Help 156 | 157 | ![Help QRcode](imgs/nettowel-help.png) 158 | 159 | 160 | ### Settings 161 | 162 | A `dotenv` file can be used as a settings file. The file can also be provided with the option `--dotenv`. 163 | 164 | ![environment settings](imgs/env-settings.png) 165 | 166 | 167 | ### Piping 168 | 169 | ![piping](imgs/piping.png) 170 | 171 | 172 | ### TUI 173 | 174 | Using [Trogon](https://github.com/Textualize/trogon) a TUI (Terminal User Interface) can be generated to edit and run the NetTowel command. 175 | 176 | ![TUI](imgs/trogon.png) 177 | -------------------------------------------------------------------------------- /src/nettowel/cli/ttp.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typer 3 | from typing import List 4 | from rich import print_json, print 5 | 6 | from rich.json import JSON 7 | from rich.panel import Panel 8 | from rich.columns import Columns 9 | from rich.syntax import Syntax 10 | 11 | from nettowel.cli._common import get_typer_app, read_text, auto_complete_paths 12 | 13 | 14 | app = get_typer_app(help="TTP templating functions") 15 | 16 | 17 | @app.command() 18 | def render( 19 | ctx: typer.Context, 20 | data_file_name: typer.FileText = typer.Argument( 21 | ..., 22 | exists=True, 23 | file_okay=True, 24 | dir_okay=False, 25 | readable=True, 26 | resolve_path=True, 27 | allow_dash=True, 28 | metavar="DATA", 29 | help="RAW data like CLI show command outputs or configuration snippets", 30 | autocompletion=auto_complete_paths, 31 | ), 32 | template_file_name: typer.FileText = typer.Argument( 33 | ..., 34 | exists=True, 35 | file_okay=True, 36 | dir_okay=False, 37 | readable=True, 38 | resolve_path=True, 39 | allow_dash=True, 40 | metavar="TEMPLATE", 41 | help="TTP template used to get structured data from the raw data", 42 | autocompletion=auto_complete_paths, 43 | ), 44 | json: bool = typer.Option(default=False, help="json output"), 45 | only_result: bool = typer.Option(False, "--print-result-only", help="Raw output"), 46 | floating_panels: bool = typer.Option( 47 | False, 48 | "--floating-panels", 49 | help="Adjust the width of the panel to fit the content", 50 | ), 51 | ) -> None: 52 | from nettowel.ttp import render_template 53 | 54 | try: 55 | template_text = read_text(template_file_name) 56 | input_data = read_text(data_file_name) 57 | 58 | result = render_template(template=template_text, data=input_data) 59 | if json: 60 | print_json(data=result) 61 | 62 | else: 63 | result_output = JSON.from_data(result) 64 | if floating_panels: 65 | code_width = ( 66 | max( 67 | [len(x) for x in template_text.split("\n")] 68 | + [len(x) for x in result_output.text.plain.split("\n")] 69 | ) 70 | + 6 71 | ) 72 | else: 73 | code_width = None 74 | panels: List[Panel] = [] 75 | if not only_result: 76 | data_output = Syntax( 77 | input_data, 78 | "jinja", 79 | line_numbers=True, 80 | indent_guides=True, 81 | code_width=code_width, 82 | ) 83 | template_output = Syntax( 84 | template_text, 85 | "jinja", 86 | line_numbers=True, 87 | indent_guides=True, 88 | code_width=code_width, 89 | ) 90 | panels.append( 91 | Panel( 92 | template_output, title="[yellow]Template", border_style="blue" 93 | ) 94 | ) 95 | panels.append( 96 | Panel(data_output, title="[yellow]Data", border_style="blue") 97 | ) 98 | panels.append( 99 | Panel( 100 | result_output, 101 | border_style="blue", 102 | title="[yellow]Result", 103 | ) 104 | ) 105 | if not only_result and floating_panels: 106 | height = ( 107 | max( 108 | [ 109 | len(result_output.text.plain.split("\n")), 110 | len(input_data.split("\n")), 111 | len(template_text.split("\n")), 112 | ] 113 | ) 114 | + 2 115 | ) 116 | for p in panels: 117 | p.height = height 118 | print(Columns(panels, equal=True)) 119 | raise typer.Exit(0) 120 | 121 | except NotImplementedError as exc: 122 | typer.echo(exc) 123 | raise typer.Exit(1) 124 | 125 | 126 | if __name__ == "__main__": 127 | app() 128 | -------------------------------------------------------------------------------- /src/nettowel/cli/jsonpatch.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import typer 4 | from rich import print_json, print 5 | from rich.panel import Panel 6 | from rich.columns import Columns 7 | from rich.json import JSON 8 | 9 | from nettowel.cli._common import ( 10 | auto_complete_paths, 11 | read_yaml, 12 | get_typer_app, 13 | ) 14 | from nettowel.exceptions import NettowelInputError 15 | 16 | 17 | app = get_typer_app(help="JSON Patch [RFC 6902](http://tools.ietf.org/html/rfc6902)") 18 | 19 | 20 | @app.command() 21 | def create( 22 | ctx: typer.Context, 23 | src_file_name: typer.FileText = typer.Argument( 24 | ..., 25 | exists=True, 26 | file_okay=True, 27 | dir_okay=False, 28 | readable=True, 29 | resolve_path=True, 30 | allow_dash=True, 31 | metavar="src", 32 | help="Source data (YAML/JSON)", 33 | autocompletion=auto_complete_paths, 34 | ), 35 | dst_file_name: typer.FileText = typer.Argument( 36 | ..., 37 | exists=True, 38 | file_okay=True, 39 | dir_okay=False, 40 | readable=True, 41 | resolve_path=True, 42 | allow_dash=True, 43 | metavar="dst", 44 | help="Destination data (YAML/JSON)", 45 | autocompletion=auto_complete_paths, 46 | ), 47 | json: bool = typer.Option(False, "--json", help="JSON output"), 48 | raw: bool = typer.Option(False, "--raw", help="Raw result output"), 49 | only_result: bool = typer.Option( 50 | False, "--print-result-only", help="Only print the result" 51 | ), 52 | ) -> None: 53 | from nettowel import jsonpatch 54 | 55 | try: 56 | src = read_yaml(src_file_name) 57 | dst = read_yaml(dst_file_name) 58 | 59 | patch = jsonpatch.create(src=src, dst=dst) 60 | patch_str = patch.to_string() 61 | if json or raw: 62 | print_json(json=patch_str) 63 | else: 64 | panels: List[Panel] = [] 65 | if not only_result: 66 | panels.append( 67 | Panel( 68 | JSON.from_data(src), title="[yellow]Source", border_style="blue" 69 | ) 70 | ) 71 | panels.append( 72 | Panel( 73 | JSON.from_data(dst), 74 | title="[yellow]Destination", 75 | border_style="blue", 76 | ) 77 | ) 78 | panels.append( 79 | Panel(JSON(patch_str), title="[yellow]JSON Patch", border_style="blue") 80 | ) 81 | print(Columns(panels, equal=True)) 82 | raise typer.Exit(0) 83 | except NettowelInputError as exc: 84 | typer.echo("Input is not valide", err=True) 85 | typer.echo(str(exc), err=True) 86 | raise typer.Exit(3) 87 | 88 | 89 | @app.command() 90 | def apply( 91 | ctx: typer.Context, 92 | patch_file_name: typer.FileText = typer.Argument( 93 | ..., 94 | exists=True, 95 | file_okay=True, 96 | dir_okay=False, 97 | readable=True, 98 | resolve_path=True, 99 | allow_dash=True, 100 | metavar="patch", 101 | help="Patch opterations (list of mappings) (YAML/JSON)", 102 | autocompletion=auto_complete_paths, 103 | ), 104 | data_file_name: typer.FileText = typer.Argument( 105 | ..., 106 | exists=True, 107 | file_okay=True, 108 | dir_okay=False, 109 | readable=True, 110 | resolve_path=True, 111 | allow_dash=True, 112 | metavar="data", 113 | help="Data to patch (YAML/JSON)", 114 | autocompletion=auto_complete_paths, 115 | ), 116 | json: bool = typer.Option(False, "--json", help="JSON output"), 117 | raw: bool = typer.Option(False, "--raw", help="Raw result output"), 118 | only_result: bool = typer.Option( 119 | False, "--print-result-only", help="Only print the result" 120 | ), 121 | ) -> None: 122 | from nettowel import jsonpatch 123 | 124 | try: 125 | patch_data = read_yaml(patch_file_name) 126 | data_input = read_yaml(data_file_name) 127 | 128 | new_data = jsonpatch.apply(patch=patch_data, data=data_input) 129 | 130 | if json or raw: 131 | print_json(data=new_data) 132 | else: 133 | panels: List[Panel] = [] 134 | if not only_result: 135 | panels.append( 136 | Panel( 137 | JSON.from_data(patch_data), 138 | title="[yellow]Patch", 139 | border_style="blue", 140 | ) 141 | ) 142 | panels.append( 143 | Panel( 144 | JSON.from_data(data_input), 145 | title="[yellow]Input Data", 146 | border_style="blue", 147 | ) 148 | ) 149 | panels.append( 150 | Panel( 151 | JSON.from_data(data=new_data), 152 | title="[yellow]Patched Data with JSON Patch", 153 | border_style="blue", 154 | ) 155 | ) 156 | print(Columns(panels, equal=True)) 157 | raise typer.Exit(0) 158 | except NettowelInputError as exc: 159 | typer.echo("Input is not valide", err=True) 160 | typer.echo(str(exc), err=True) 161 | raise typer.Exit(3) 162 | 163 | 164 | if __name__ == "__main__": 165 | app() 166 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | nettowel@m.ubaumann.ch. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /src/nettowel/cli/netmiko.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | from click import style 3 | import typer 4 | from rich import print_json, print 5 | from rich.markdown import Markdown 6 | from rich.prompt import Prompt 7 | from rich.panel import Panel 8 | from rich.text import Text 9 | from rich.highlighter import ReprHighlighter 10 | 11 | from nettowel.cli._common import get_typer_app 12 | from nettowel.exceptions import ( 13 | NettowelException, 14 | ) 15 | 16 | app = get_typer_app(help="Netmiko functions") 17 | 18 | 19 | _highlight_words = [ 20 | ( 21 | [ 22 | "Idle", 23 | "Down", 24 | "down", 25 | "errors", 26 | "error", 27 | "collisions", 28 | "drops", 29 | "failures", 30 | "failure", 31 | ], 32 | "dark_red", 33 | ), 34 | (["never"], "orange4"), 35 | ] 36 | 37 | 38 | @app.command() 39 | def cli( 40 | ctx: typer.Context, 41 | cmd: str = typer.Argument(..., help="CLI command to execute on device"), 42 | host: str = typer.Option( 43 | ..., help="Hostname or IP address", envvar="NETTOWEL_HOST" 44 | ), 45 | device_type: str = typer.Option( 46 | ..., help="Netmiko device type", envvar="NETTOWEL_DEVICE_TYPE" 47 | ), 48 | user: str = typer.Option(None, help="Username for login", envvar="NETTOWEL_USER"), 49 | password: str = typer.Option( 50 | None, help="Login password", envvar="NETTOWEL_PASSWORD" 51 | ), 52 | port: int = typer.Option( 53 | default=22, help="Connection Port", envvar="NETTOWEL_PORT" 54 | ), 55 | secret: str = typer.Option( 56 | default="", help="Enable secret", envvar="NETTOWEL_SECRET" 57 | ), 58 | use_textfsm: bool = typer.Option( 59 | False, 60 | "--use-textfsm", 61 | help="Use textFSM to get structured data", 62 | envvar="NETTOWEL_NETMIKO_TEXTFSM", 63 | ), 64 | use_ttp: bool = typer.Option( 65 | False, 66 | "--use-ttp", 67 | help="Use TTP to get structured data", 68 | envvar="NETTOWEL_NETMIKO_TTP", 69 | ), 70 | ttp_template: str = typer.Option( 71 | None, 72 | help="Use TTP Template. Use it with the `use_ttp` parameter", 73 | envvar="NETTOWEL_NETMIKO_TTP_TEMPLATE", 74 | ), 75 | use_genie: bool = typer.Option( 76 | False, 77 | "--use-genie", 78 | help="Use Genie to get structured data", 79 | envvar="NETTOWEL_NETMIKO_GENIE", 80 | ), 81 | ssh_config_file: str = typer.Option( 82 | None, help="SSH Config file", envvar="NETTOWEL_SSH_CONFIG" 83 | ), 84 | use_keys: bool = typer.Option( 85 | False, 86 | "--use-keys", 87 | help="Use provided SSH Key. Parameter `key_file` is used.", 88 | envvar="NETTOWEL_USE_KEY", 89 | ), 90 | key_file: str = typer.Option( 91 | None, 92 | help="SSH Key file. Use it with the `key_file` parameter", 93 | envvar="NETTOWEL_KEY_FILE", 94 | ), 95 | session_log: str = typer.Option( 96 | None, help="File to store session log", envvar="NETTOWEL_SESSION_LOG" 97 | ), 98 | json: bool = typer.Option(default=False, help="json output"), 99 | raw: bool = typer.Option(default=False, help="raw output"), 100 | ) -> None: 101 | from nettowel.netmiko import send_command 102 | 103 | try: 104 | if not any([user, ssh_config_file]): 105 | user = Prompt.ask("Enter username") 106 | if not any([password, use_keys, ssh_config_file]): 107 | password = Prompt.ask(f"Enter password for user {user}", password=True) 108 | result = send_command( 109 | cmd=cmd, 110 | device_type=device_type, 111 | host=host, 112 | username=user, 113 | password=password, 114 | port=port, 115 | secret=secret, 116 | use_textfsm=use_textfsm, 117 | use_ttp=use_ttp, 118 | ttp_template=ttp_template, 119 | use_genie=use_genie, 120 | ssh_config_file=ssh_config_file, 121 | use_keys=use_keys, 122 | key_file=key_file, 123 | session_log=session_log, 124 | ) 125 | if json: 126 | print_json(data={"cmd": cmd, "result": result}) 127 | elif raw: 128 | print(result) 129 | else: 130 | output = ReprHighlighter()(Text(result)) 131 | for words, style in _highlight_words: 132 | output.highlight_words(words, style) 133 | print( 134 | Panel( 135 | output, 136 | title=f"[yellow][bold]{host}[/bold] {cmd}", 137 | border_style="blue", 138 | ) 139 | ) 140 | raise typer.Exit(0) 141 | 142 | except NettowelException as exc: 143 | typer.echo(str(exc), err=True) 144 | raise typer.Exit(1) 145 | 146 | 147 | @app.command() 148 | def device_types( 149 | ctx: typer.Context, 150 | json: bool = typer.Option(default=False, help="json output"), 151 | ) -> None: 152 | from nettowel.netmiko import get_device_types 153 | 154 | try: 155 | types = get_device_types() 156 | if json: 157 | print_json(data=types) 158 | else: 159 | output = Markdown("\n".join([f"- {x}" for x in types])) 160 | print(output) 161 | raise typer.Exit(0) 162 | 163 | except NettowelException as exc: 164 | typer.echo(str(exc), err=True) 165 | raise typer.Exit(1) 166 | 167 | 168 | @app.command() 169 | def autodetect( 170 | ctx: typer.Context, 171 | host: str = typer.Argument(..., help="Hostname or IP address"), 172 | user: str = typer.Option(None, help="Username for login", envvar="NETTOWEL_USER"), 173 | password: str = typer.Option( 174 | None, help="Login password", envvar="NETTOWEL_PASSWORD" 175 | ), 176 | port: int = typer.Option( 177 | default=22, help="Connection Port", envvar="NETTOWEL_PORT" 178 | ), 179 | secret: str = typer.Option( 180 | default="", help="Enable secret", envvar="NETTOWEL_SECRET" 181 | ), 182 | ssh_config_file: str = typer.Option( 183 | None, help="SSH Config file", envvar="NETTOWEL_SSH_CONFIG" 184 | ), 185 | use_keys: bool = typer.Option( 186 | False, 187 | "--use-keys", 188 | help="Use provided SSH Key. Parameter `key_file` is used.", 189 | envvar="NETTOWEL_USE_KEY", 190 | ), 191 | key_file: str = typer.Option( 192 | None, 193 | help="SSH Key file. Use it with the `key_file` parameter", 194 | envvar="NETTOWEL_KEY_FILE", 195 | ), 196 | session_log: str = typer.Option( 197 | None, help="File to store session log", envvar="NETTOWEL_SESSION_LOG" 198 | ), 199 | json: bool = typer.Option(default=False, help="json output"), 200 | ) -> None: 201 | from nettowel.netmiko import autodetect as netmiko_autodetect 202 | 203 | try: 204 | if not any([user, ssh_config_file]): 205 | user = Prompt.ask("Enter username") 206 | if not any([password, use_keys, ssh_config_file]): 207 | password = Prompt.ask(f"Enter password for user {user}", password=True) 208 | result = netmiko_autodetect( 209 | host=host, 210 | username=user, 211 | password=password, 212 | port=port, 213 | secret=secret, 214 | ssh_config_file=ssh_config_file, 215 | use_keys=use_keys, 216 | key_file=key_file, 217 | session_log=session_log, 218 | ) 219 | if json: 220 | print_json(data={"result": result}) 221 | else: 222 | print(result) 223 | raise typer.Exit(0) 224 | 225 | except NettowelException as exc: 226 | typer.echo(str(exc), err=True) 227 | raise typer.Exit(1) 228 | 229 | 230 | if __name__ == "__main__": 231 | app() 232 | -------------------------------------------------------------------------------- /src/nettowel/cli/jinja.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typer 3 | from typing import Any, Dict, List 4 | from rich import print_json, print 5 | 6 | from rich.panel import Panel 7 | from rich.columns import Columns 8 | from rich.console import Group 9 | from rich.syntax import Syntax 10 | from rich.tree import Tree 11 | from rich.scope import render_scope 12 | from rich.json import JSON 13 | 14 | from nettowel.exceptions import NettowelDependencyMissing, NettowelSyntaxError 15 | from nettowel.cli._common import ( 16 | get_typer_app, 17 | auto_complete_paths, 18 | read_yaml, 19 | read_text, 20 | ) 21 | 22 | app = get_typer_app(help="Templating (Jinja2) functions") 23 | 24 | 25 | def _variable_tree(data: Dict[str, Any]) -> Tree: 26 | root = Tree(":books: Variables", style="blue", guide_style="blue") 27 | 28 | types = { 29 | "boolean": ":keycap_10: boolean", 30 | "null": ":no_entry: null", 31 | "number": ":input_numbers: number", 32 | "string": ":newspaper: string", 33 | "error": "[red]Type unknown[/]", 34 | } 35 | 36 | def add(tree: Tree, data: Dict[str, Any], required: bool = True) -> Tree: 37 | property_type = data.get("type") 38 | name = data.get("title") 39 | text = ( 40 | f"[blue]{name} [i](required)[/]" 41 | if required 42 | else f"[green]{name} [i](optional)[/]" 43 | ) 44 | if property_type in ["string", "boolean", "number", "null"]: 45 | tree.add(Group(text, f'[yellow]{types[data.get("type", "error")]}[/]')) 46 | 47 | if "anyOf" in data: 48 | any_of = Tree("anyOf", style="yellow", guide_style="yellow") 49 | [ 50 | any_of.add(types[x.get("type", "error")]) 51 | for x in data.get("anyOf", dict()) 52 | ] 53 | tree.add(Group(text, any_of)) 54 | 55 | if property_type == "array": 56 | items = data.get("items", dict()) 57 | if items.get("type") == "object": 58 | color = "blue" if required else "green" 59 | tree.add( 60 | Group( 61 | text, 62 | add( 63 | Tree( 64 | ":open_book: object", style="yellow", guide_style=color 65 | ), 66 | items, 67 | ), 68 | ) 69 | ) 70 | else: 71 | any_of_array = items.get("anyOf") 72 | array = Tree("array (anyOf)", style="yellow", guide_style="yellow") 73 | [array.add(types[x.get("type", "error")]) for x in any_of_array] 74 | tree.add(Group(text, array)) 75 | 76 | if property_type == "object": 77 | for property, value in data.get("properties", dict()).items(): 78 | add(tree, value, property in data.get("required", list())) 79 | 80 | return tree 81 | 82 | for property, value in data.get("properties", dict()).items(): 83 | add(root, value, property in data.get("required", list())) 84 | return root 85 | 86 | 87 | @app.command() 88 | def render( 89 | ctx: typer.Context, 90 | template_file_name: typer.FileText = typer.Argument( 91 | ..., 92 | exists=True, 93 | file_okay=True, 94 | dir_okay=False, 95 | readable=True, 96 | resolve_path=True, 97 | allow_dash=True, 98 | metavar="TEMPLATE", 99 | help="Jinja template file", 100 | autocompletion=auto_complete_paths, 101 | ), 102 | data_file_name: typer.FileText = typer.Argument( 103 | ..., 104 | exists=True, 105 | file_okay=True, 106 | dir_okay=False, 107 | readable=True, 108 | resolve_path=True, 109 | allow_dash=True, 110 | metavar="DATA", 111 | help="Data file in YAML or JSON", 112 | autocompletion=auto_complete_paths, 113 | ), 114 | trim_blocks: bool = typer.Option( 115 | False, "--trim-blocks", help="Remove first newline after a block" 116 | ), 117 | lstrip_blocks: bool = typer.Option( 118 | False, 119 | "--lstrip-blocks", 120 | help="Stripping leading spaces and tabs from the start of a line to a block", 121 | ), 122 | keep_trailing_newline: bool = typer.Option( 123 | False, "--keep-trailing-newline", help="Preserve the trailing newline" 124 | ), 125 | json: bool = typer.Option(False, "--json", help="JSON output"), 126 | raw: bool = typer.Option(False, "--raw", help="Raw result output"), 127 | only_result: bool = typer.Option( 128 | False, "--print-result-only", help="Only print the result" 129 | ), 130 | floating_panels: bool = typer.Option( 131 | False, 132 | "--floating-panels", 133 | help="Adjust the width of the panel to fit the content", 134 | ), 135 | ) -> None: 136 | from nettowel.jinja import render_template 137 | 138 | try: 139 | template_text = read_text(template_file_name) 140 | input_data = read_yaml(data_file_name) 141 | 142 | result = render_template( 143 | template=template_text, 144 | data=input_data, 145 | trim_blocks=trim_blocks, 146 | lstrip_blocks=lstrip_blocks, 147 | keep_trailing_newline=keep_trailing_newline, 148 | ).strip() 149 | 150 | if json: 151 | return_data = { 152 | "result": result, 153 | "template": template_text, 154 | "input_data": input_data, 155 | } 156 | if only_result: 157 | print_json(data={"result": result}) 158 | else: 159 | print_json(data=return_data) 160 | elif raw: 161 | print(result) 162 | else: 163 | if floating_panels: 164 | code_width = ( 165 | max( 166 | [len(x) for x in template_text.split("\n")] 167 | + [len(x) for x in result.split("\n")] 168 | ) 169 | + 6 170 | ) 171 | else: 172 | code_width = None 173 | panels: List[Panel] = [] 174 | if not only_result: 175 | data_output = JSON.from_data(input_data) 176 | template_output = Syntax( 177 | template_text, 178 | "jinja", 179 | line_numbers=True, 180 | indent_guides=True, 181 | code_width=code_width, 182 | ) 183 | panels.append( 184 | Panel( 185 | template_output, title="[yellow]Template", border_style="blue" 186 | ) 187 | ) 188 | panels.append( 189 | Panel(data_output, title="[yellow]Data", border_style="blue") 190 | ) 191 | panels.append( 192 | Panel( 193 | Syntax( 194 | result, 195 | "yaml", 196 | line_numbers=True, 197 | indent_guides=True, 198 | code_width=code_width, 199 | ), 200 | border_style="blue", 201 | title="[yellow]Result", 202 | ) 203 | ) 204 | if not only_result and floating_panels: 205 | height = ( 206 | max( 207 | [ 208 | len(result.split("\n")), 209 | len(data_output.text.plain.split("\n")), 210 | len(template_text.split("\n")), 211 | ] 212 | ) 213 | + 2 214 | ) 215 | for p in panels: 216 | p.height = height 217 | print(Columns(panels, equal=True)) 218 | raise typer.Exit(0) 219 | 220 | except NettowelDependencyMissing as exc: 221 | typer.echo(str(exc), err=True) 222 | raise typer.Exit(1) 223 | except NettowelSyntaxError as exc: 224 | typer.echo(exc, err=True) 225 | raise typer.Exit(2) 226 | 227 | 228 | @app.command() 229 | def validate( 230 | ctx: typer.Context, 231 | template_file_name: typer.FileText = typer.Argument( 232 | ..., 233 | exists=True, 234 | file_okay=True, 235 | dir_okay=False, 236 | readable=True, 237 | resolve_path=True, 238 | allow_dash=True, 239 | metavar="TEMPLATE", 240 | help="Jinja template file", 241 | autocompletion=auto_complete_paths, 242 | ), 243 | json: bool = typer.Option(default=False, help="json output"), 244 | ) -> None: 245 | from nettowel.jinja import validate_template 246 | 247 | try: 248 | template_text = read_text(template_file_name) 249 | result, data = validate_template(template_text) 250 | if json: 251 | data["valid"] = result 252 | print_json(data=data) 253 | else: 254 | if result: 255 | print( 256 | Panel( 257 | "Template is valid :white_heavy_check_mark:", 258 | border_style="green", 259 | expand=False, 260 | ) 261 | ) 262 | else: 263 | pannel_group = Group( 264 | Panel( 265 | "Template is [red bold]not[/] valide :hankey:", 266 | border_style="red", 267 | ), 268 | f'The validation resulted in the error "{data["message"]}" in the line number {data["lineno"]}.', 269 | f'The error is probably in the following line or before or after it.\n\n{data["line"]}\n', 270 | render_scope(data, title=str(data["message"])), 271 | ) 272 | print( 273 | Panel(pannel_group, border_style="red", title="ERROR", expand=False) 274 | ) 275 | if not result: 276 | raise typer.Exit(1) 277 | raise typer.Exit(0) 278 | 279 | except NettowelDependencyMissing as exc: 280 | typer.echo(str(exc), err=True) 281 | raise typer.Exit(1) 282 | 283 | 284 | @app.command() 285 | def variables( 286 | ctx: typer.Context, 287 | template_file_name: typer.FileText = typer.Argument( 288 | ..., 289 | exists=True, 290 | file_okay=True, 291 | dir_okay=False, 292 | readable=True, 293 | resolve_path=True, 294 | allow_dash=True, 295 | metavar="TEMPLATE", 296 | help="Jinja template file", 297 | autocompletion=auto_complete_paths, 298 | ), 299 | json: bool = typer.Option(default=False, help="json output"), 300 | ) -> None: 301 | from nettowel.jinja import get_variables 302 | 303 | try: 304 | template_text = read_text(template_file_name) 305 | result = get_variables(template_text) 306 | if json: 307 | data = {"result": result} 308 | print_json(data=data) 309 | else: 310 | print(_variable_tree(result)) 311 | except NettowelDependencyMissing as exc: 312 | typer.echo(str(exc), err=True) 313 | raise typer.Exit(1) 314 | except NettowelSyntaxError as exc: 315 | typer.echo(exc, err=True) 316 | raise typer.Exit(2) 317 | 318 | 319 | if __name__ == "__main__": 320 | app() 321 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/nettowel/cli/restconf.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional 2 | import sys 3 | import typer 4 | from urllib.parse import quote 5 | from rich import print_json, print 6 | from rich.syntax import Syntax 7 | from rich.prompt import Prompt 8 | from rich.json import JSON 9 | from rich.panel import Panel 10 | 11 | from nettowel.cli._common import get_typer_app, auto_complete_paths 12 | from nettowel.exceptions import ( 13 | NettowelRestconfError, 14 | ) 15 | from nettowel.restconf import send_request 16 | 17 | app = get_typer_app(help="RESTCONF functions") 18 | 19 | 20 | def _send_request( 21 | path: str, 22 | method: str, 23 | host: str, 24 | user: str, 25 | password: str, 26 | port: int, 27 | send_xml: bool, 28 | return_xml: bool, 29 | json: bool, 30 | raw: bool, 31 | verify: bool, 32 | data_file: Optional[typer.FileText] = None, 33 | ) -> None: 34 | try: 35 | if not user: 36 | user = Prompt.ask("Enter username") 37 | if not password: 38 | password = Prompt.ask(f"Enter password for user {user}", password=True) 39 | 40 | if data_file: 41 | if data_file == "-": 42 | data = sys.stdin.read() 43 | else: 44 | with open(data_file) as f: # type: ignore 45 | data = f.read() 46 | else: 47 | data = None 48 | 49 | result = send_request( 50 | method=method, 51 | url=f"https://{host}:{port}/restconf/data/{path}", 52 | username=user, 53 | password=password, 54 | send_xml=send_xml, 55 | return_xml=return_xml, 56 | verify=verify, 57 | data=data, 58 | ) 59 | if raw: 60 | print(result) 61 | elif json: 62 | print_json(data=result) 63 | else: 64 | if return_xml: 65 | output: Union[Syntax, JSON] = Syntax( 66 | result, 67 | "xml", 68 | line_numbers=True, 69 | indent_guides=True, 70 | ) 71 | else: 72 | output = JSON.from_data(result) 73 | print( 74 | Panel( 75 | output, 76 | title=f"[yellow][bold]{method}[/bold] {path}", 77 | border_style="blue", 78 | ) 79 | ) 80 | raise typer.Exit(0) 81 | except NettowelRestconfError as exc: 82 | typer.echo(str(exc), err=True) 83 | if exc.server_msg: 84 | typer.echo(exc.server_msg, err=True) 85 | raise typer.Exit(1) 86 | 87 | 88 | @app.command() 89 | def get( 90 | ctx: typer.Context, 91 | path: str = typer.Argument( 92 | ..., help="RESTCONF path. Example: Cisco-IOS-XE-native:native/hostname" 93 | ), 94 | host: str = typer.Option( 95 | ..., help="Hostname or IP address", envvar="NETTOWEL_HOST" 96 | ), 97 | user: str = typer.Option(None, help="Username for login", envvar="NETTOWEL_USER"), 98 | password: str = typer.Option( 99 | None, help="Login password", envvar="NETTOWEL_PASSWORD" 100 | ), 101 | port: int = typer.Option( 102 | default=443, help="Connection Port", envvar="NETTOWEL_RESTCONF_PORT" 103 | ), 104 | send_xml: bool = typer.Option( 105 | False, 106 | "--send-xml", 107 | help="Send XML instead of JSON", 108 | ), 109 | return_xml: bool = typer.Option( 110 | False, 111 | "--return-xml", 112 | help="Recieve XML instead of JSON", 113 | ), 114 | verify: bool = typer.Option( 115 | False, 116 | "--no-verify", 117 | help="Ignore SSL certificate verification", 118 | envvar="NETTOWEL_VERIFY", 119 | ), 120 | json: bool = typer.Option(default=False, help="json output"), 121 | raw: bool = typer.Option(default=False, help="raw output"), 122 | ) -> None: 123 | _send_request( 124 | path=path, 125 | method="GET", 126 | host=host, 127 | user=user, 128 | password=password, 129 | port=port, 130 | send_xml=send_xml, 131 | return_xml=return_xml, 132 | json=json, 133 | raw=raw, 134 | verify=verify, 135 | ) 136 | 137 | 138 | @app.command() 139 | def delete( 140 | ctx: typer.Context, 141 | path: str = typer.Argument( 142 | ..., help="RESTCONF path. Example: Cisco-IOS-XE-native:native/hostname" 143 | ), 144 | host: str = typer.Option( 145 | ..., help="Hostname or IP address", envvar="NETTOWEL_HOST" 146 | ), 147 | user: str = typer.Option(None, help="Username for login", envvar="NETTOWEL_USER"), 148 | password: str = typer.Option( 149 | None, help="Login password", envvar="NETTOWEL_PASSWORD" 150 | ), 151 | port: int = typer.Option( 152 | default=443, help="Connection Port", envvar="NETTOWEL_RESTCONF_PORT" 153 | ), 154 | send_xml: bool = typer.Option( 155 | False, 156 | "--send-xml", 157 | help="Send XML instead of JSON", 158 | ), 159 | return_xml: bool = typer.Option( 160 | False, 161 | "--return-xml", 162 | help="Recieve XML instead of JSON", 163 | ), 164 | verify: bool = typer.Option( 165 | False, 166 | "--no-verify", 167 | help="Ignore SSL certificate verification", 168 | envvar="NETTOWEL_VERIFY", 169 | ), 170 | json: bool = typer.Option(default=False, help="json output"), 171 | raw: bool = typer.Option(default=False, help="raw output"), 172 | ) -> None: 173 | _send_request( 174 | path=path, 175 | method="DELETE", 176 | host=host, 177 | user=user, 178 | password=password, 179 | port=port, 180 | send_xml=send_xml, 181 | return_xml=return_xml, 182 | json=json, 183 | verify=verify, 184 | raw=raw, 185 | ) 186 | 187 | 188 | @app.command() 189 | def post( 190 | ctx: typer.Context, 191 | path: str = typer.Argument( 192 | ..., help="RESTCONF path. Example: Cisco-IOS-XE-native:native/hostname" 193 | ), 194 | data_file: typer.FileText = typer.Argument( 195 | ..., 196 | exists=True, 197 | file_okay=True, 198 | dir_okay=False, 199 | readable=True, 200 | resolve_path=True, 201 | allow_dash=True, 202 | metavar="DATA", 203 | help="Data to send. Use '-' to read from stdin", 204 | autocompletion=auto_complete_paths, 205 | ), 206 | host: str = typer.Option( 207 | ..., help="Hostname or IP address", envvar="NETTOWEL_HOST" 208 | ), 209 | user: str = typer.Option(None, help="Username for login", envvar="NETTOWEL_USER"), 210 | password: str = typer.Option( 211 | None, help="Login password", envvar="NETTOWEL_PASSWORD" 212 | ), 213 | port: int = typer.Option( 214 | default=443, help="Connection Port", envvar="NETTOWEL_RESTCONF_PORT" 215 | ), 216 | send_xml: bool = typer.Option( 217 | False, 218 | "--send-xml", 219 | help="Send XML instead of JSON", 220 | ), 221 | return_xml: bool = typer.Option( 222 | False, 223 | "--return-xml", 224 | help="Recieve XML instead of JSON", 225 | ), 226 | verify: bool = typer.Option( 227 | False, 228 | "--no-verify", 229 | help="Ignore SSL certificate verification", 230 | envvar="NETTOWEL_VERIFY", 231 | ), 232 | json: bool = typer.Option(default=False, help="json output"), 233 | raw: bool = typer.Option(default=False, help="raw output"), 234 | ) -> None: 235 | _send_request( 236 | path=path, 237 | method="POST", 238 | host=host, 239 | user=user, 240 | password=password, 241 | port=port, 242 | send_xml=send_xml, 243 | return_xml=return_xml, 244 | json=json, 245 | raw=raw, 246 | verify=verify, 247 | data_file=data_file, 248 | ) 249 | 250 | 251 | @app.command() 252 | def PUT( 253 | ctx: typer.Context, 254 | path: str = typer.Argument( 255 | ..., help="RESTCONF path. Example: Cisco-IOS-XE-native:native/hostname" 256 | ), 257 | data_file: typer.FileText = typer.Argument( 258 | ..., 259 | exists=True, 260 | file_okay=True, 261 | dir_okay=False, 262 | readable=True, 263 | resolve_path=True, 264 | allow_dash=True, 265 | metavar="DATA", 266 | help="Data to send. Use '-' to read from stdin", 267 | autocompletion=auto_complete_paths, 268 | ), 269 | host: str = typer.Option( 270 | ..., help="Hostname or IP address", envvar="NETTOWEL_HOST" 271 | ), 272 | user: str = typer.Option(None, help="Username for login", envvar="NETTOWEL_USER"), 273 | password: str = typer.Option( 274 | None, help="Login password", envvar="NETTOWEL_PASSWORD" 275 | ), 276 | port: int = typer.Option( 277 | default=443, help="Connection Port", envvar="NETTOWEL_RESTCONF_PORT" 278 | ), 279 | send_xml: bool = typer.Option( 280 | False, 281 | "--send-xml", 282 | help="Send XML instead of JSON", 283 | ), 284 | return_xml: bool = typer.Option( 285 | False, 286 | "--return-xml", 287 | help="Recieve XML instead of JSON", 288 | ), 289 | verify: bool = typer.Option( 290 | False, 291 | "--no-verify", 292 | help="Ignore SSL certificate verification", 293 | envvar="NETTOWEL_VERIFY", 294 | ), 295 | json: bool = typer.Option(default=False, help="json output"), 296 | raw: bool = typer.Option(default=False, help="raw output"), 297 | ) -> None: 298 | _send_request( 299 | path=path, 300 | method="PUT", 301 | host=host, 302 | user=user, 303 | password=password, 304 | port=port, 305 | send_xml=send_xml, 306 | return_xml=return_xml, 307 | json=json, 308 | raw=raw, 309 | verify=verify, 310 | data_file=data_file, 311 | ) 312 | 313 | 314 | @app.command() 315 | def patch( 316 | ctx: typer.Context, 317 | path: str = typer.Argument( 318 | ..., help="RESTCONF path. Example: Cisco-IOS-XE-native:native/hostname" 319 | ), 320 | data_file: typer.FileText = typer.Argument( 321 | ..., 322 | exists=True, 323 | file_okay=True, 324 | dir_okay=False, 325 | readable=True, 326 | resolve_path=True, 327 | allow_dash=True, 328 | metavar="DATA", 329 | help="Data to send. Use '-' to read from stdin", 330 | autocompletion=auto_complete_paths, 331 | ), 332 | host: str = typer.Option( 333 | ..., help="Hostname or IP address", envvar="NETTOWEL_HOST" 334 | ), 335 | user: str = typer.Option(None, help="Username for login", envvar="NETTOWEL_USER"), 336 | password: str = typer.Option( 337 | None, help="Login password", envvar="NETTOWEL_PASSWORD" 338 | ), 339 | port: int = typer.Option( 340 | default=443, help="Connection Port", envvar="NETTOWEL_RESTCONF_PORT" 341 | ), 342 | send_xml: bool = typer.Option( 343 | False, 344 | "--send-xml", 345 | help="Send XML instead of JSON", 346 | ), 347 | return_xml: bool = typer.Option( 348 | False, 349 | "--return-xml", 350 | help="Recieve XML instead of JSON", 351 | ), 352 | verify: bool = typer.Option( 353 | False, 354 | "--no-verify", 355 | help="Ignore SSL certificate verification", 356 | envvar="NETTOWEL_VERIFY", 357 | ), 358 | json: bool = typer.Option(default=False, help="json output"), 359 | raw: bool = typer.Option(default=False, help="raw output"), 360 | ) -> None: 361 | _send_request( 362 | path=path, 363 | method="PATCH", 364 | host=host, 365 | user=user, 366 | password=password, 367 | port=port, 368 | send_xml=send_xml, 369 | return_xml=return_xml, 370 | json=json, 371 | raw=raw, 372 | verify=verify, 373 | data_file=data_file, 374 | ) 375 | 376 | 377 | @app.command() 378 | def head( 379 | ctx: typer.Context, 380 | path: str = typer.Argument( 381 | ..., help="RESTCONF path. Example: Cisco-IOS-XE-native:native/hostname" 382 | ), 383 | host: str = typer.Option( 384 | ..., help="Hostname or IP address", envvar="NETTOWEL_HOST" 385 | ), 386 | user: str = typer.Option(None, help="Username for login", envvar="NETTOWEL_USER"), 387 | password: str = typer.Option( 388 | None, help="Login password", envvar="NETTOWEL_PASSWORD" 389 | ), 390 | port: int = typer.Option( 391 | default=443, help="Connection Port", envvar="NETTOWEL_RESTCONF_PORT" 392 | ), 393 | send_xml: bool = typer.Option( 394 | False, 395 | "--send-xml", 396 | help="Send XML instead of JSON", 397 | ), 398 | return_xml: bool = typer.Option( 399 | False, 400 | "--return-xml", 401 | help="Recieve XML instead of JSON", 402 | ), 403 | verify: bool = typer.Option( 404 | False, 405 | "--no-verify", 406 | help="Ignore SSL certificate verification", 407 | envvar="NETTOWEL_VERIFY", 408 | ), 409 | json: bool = typer.Option(default=False, help="json output"), 410 | raw: bool = typer.Option(default=False, help="raw output"), 411 | ) -> None: 412 | _send_request( 413 | path=path, 414 | method="HEAD", 415 | host=host, 416 | user=user, 417 | password=password, 418 | port=port, 419 | send_xml=send_xml, 420 | return_xml=return_xml, 421 | json=json, 422 | raw=raw, 423 | verify=verify, 424 | ) 425 | 426 | 427 | @app.command() 428 | def endcode( 429 | ctx: typer.Context, 430 | text: str = typer.Argument(..., help="Text to encode / quote"), 431 | json: bool = typer.Option(default=False, help="json output"), 432 | raw: bool = typer.Option(default=False, help="raw output"), 433 | ) -> None: 434 | result = quote(text, safe="") 435 | if json: 436 | print_json(data={"result": result}) 437 | elif raw: 438 | print(result) 439 | else: 440 | print(Panel(result, title=f"[yellow]{text}", border_style="blue")) 441 | 442 | 443 | if __name__ == "__main__": 444 | app() 445 | --------------------------------------------------------------------------------