├── 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 | [](https://pypi.python.org/pypi/nettowel/)
2 | [](https://github.com/ambv/black)
3 | [](CODE_OF_CONDUCT.md)
4 | [](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 | 
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 | 
75 |
76 | 
77 |
78 | #### validate
79 |
80 | 
81 |
82 | #### variables
83 |
84 | 
85 |
86 |
87 | ### TTP
88 |
89 | #### render
90 |
91 | 
92 |
93 | ### Netmiko
94 |
95 | #### cli
96 |
97 | 
98 |
99 | #### autodetect
100 |
101 | 
102 |
103 | #### device-types
104 |
105 | 
106 |
107 |
108 | ### RESTCONF
109 |
110 | #### get
111 |
112 | 
113 |
114 | #### patch, delete
115 |
116 | 
117 |
118 | #### post, put
119 |
120 | 
121 |
122 | ### ipaddress
123 |
124 | #### ip-info
125 |
126 | 
127 |
128 | #### network-info
129 |
130 | 
131 |
132 |
133 | ### YAML
134 |
135 | #### load
136 |
137 | 
138 |
139 | #### dump
140 |
141 | 
142 |
143 |
144 | ### JSON Patch ([RFC 6902](http://tools.ietf.org/html/rfc6902))
145 |
146 | #### create
147 |
148 | 
149 |
150 | #### apply
151 |
152 | 
153 |
154 |
155 | ### Help
156 |
157 | 
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 | 
165 |
166 |
167 | ### Piping
168 |
169 | 
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 | 
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 |
--------------------------------------------------------------------------------