diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3e49331..ca9b290 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -90,7 +90,7 @@ jobs: matrix: python-version: [ '3.8', '3.9', '3.10' ] platform: [ubuntu-latest, macOS-latest, windows-latest] - extra: [ 'jinja', 'ttp'] + extra: [ 'jinja', 'ttp', 'jsonpatch'] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index cc898be..95564bf 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The following groups are available (more details in the pyproject.toml): - scrapli - nornir - pandas +- jsonpatch - tui ```bash @@ -134,6 +135,18 @@ Many features are not implemented yet and many features will come. ![yaml dump](imgs/yaml-dump.png) + +### JSON Patch ([RFC 6902](http://tools.ietf.org/html/rfc6902)) + +#### create + +![JSON Patch create](imgs/jsonpatch-create.png) + +#### apply + +![JSON Patch apply](imgs/jsonpatch-apply.png) + + ### Help ![Help QRcode](imgs/nettowel-help.png) diff --git a/imgs/jsonpatch-apply.png b/imgs/jsonpatch-apply.png new file mode 100644 index 0000000..b1fa7c3 Binary files /dev/null and b/imgs/jsonpatch-apply.png differ diff --git a/imgs/jsonpatch-create.png b/imgs/jsonpatch-create.png new file mode 100644 index 0000000..d7a975f Binary files /dev/null and b/imgs/jsonpatch-create.png differ diff --git a/nettowel/cli/jsonpatch.py b/nettowel/cli/jsonpatch.py new file mode 100644 index 0000000..c64c767 --- /dev/null +++ b/nettowel/cli/jsonpatch.py @@ -0,0 +1,163 @@ +from typing import List + +import typer +from rich import print_json, print +from rich.panel import Panel +from rich.columns import Columns +from rich.json import JSON + +from nettowel.cli._common import ( + auto_complete_paths, + read_yaml, + get_typer_app, +) +from nettowel.exceptions import NettowelInputError + +from nettowel import jsonpatch + + +app = get_typer_app(help="JSON Patch [RFC 6902](http://tools.ietf.org/html/rfc6902)") + + +@app.command() +def create( + ctx: typer.Context, + src_file_name: typer.FileText = typer.Argument( + ..., + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + allow_dash=True, + metavar="src", + help="Source data (YAML/JSON)", + autocompletion=auto_complete_paths, + ), + dst_file_name: typer.FileText = typer.Argument( + ..., + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + allow_dash=True, + metavar="dst", + help="Destination data (YAML/JSON)", + autocompletion=auto_complete_paths, + ), + json: bool = typer.Option(False, "--json", help="JSON output"), + raw: bool = typer.Option(False, "--raw", help="Raw result output"), + only_result: bool = typer.Option( + False, "--print-result-only", help="Only print the result" + ), +) -> None: + try: + src = read_yaml(src_file_name) + dst = read_yaml(dst_file_name) + + patch = jsonpatch.create(src=src, dst=dst) + patch_str = patch.to_string() + if json or raw: + print_json(json=patch_str) + else: + panels: List[Panel] = [] + if not only_result: + panels.append( + Panel( + JSON.from_data(src), title="[yellow]Source", border_style="blue" + ) + ) + panels.append( + Panel( + JSON.from_data(dst), + title="[yellow]Destrination", + border_style="blue", + ) + ) + panels.append( + Panel(JSON(patch_str), title="[yellow]JSON Patch", border_style="blue") + ) + print(Columns(panels, equal=True)) + raise typer.Exit(0) + except NettowelInputError as exc: + typer.echo("Input is not valide", err=True) + typer.echo(str(exc), err=True) + raise typer.Exit(3) + + +@app.command() +def apply( + ctx: typer.Context, + patch_file_name: typer.FileText = typer.Argument( + ..., + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + allow_dash=True, + metavar="patch", + help="Patch opterations (list of mappings) (YAML/JSON)", + autocompletion=auto_complete_paths, + ), + data_file_name: typer.FileText = typer.Argument( + ..., + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + allow_dash=True, + metavar="data", + help="Data to patch (YAML/JSON)", + autocompletion=auto_complete_paths, + ), + json: bool = typer.Option(False, "--json", help="JSON output"), + raw: bool = typer.Option(False, "--raw", help="Raw result output"), + only_result: bool = typer.Option( + False, "--print-result-only", help="Only print the result" + ), +) -> None: + try: + patch_data = read_yaml(patch_file_name) + data_input = read_yaml(data_file_name) + + new_data = jsonpatch.apply(patch=patch_data, data=data_input) + + if json or raw: + print_json(data=new_data) + else: + panels: List[Panel] = [] + if not only_result: + panels.append( + Panel( + JSON.from_data(patch_data), + title="[yellow]Patch", + border_style="blue", + ) + ) + panels.append( + Panel( + JSON.from_data(data_input), + title="[yellow]Input Data", + border_style="blue", + ) + ) + panels.append( + Panel( + JSON.from_data(data=new_data), + title="[yellow]Patched Data with JSON Patch", + border_style="blue", + ) + ) + print(Columns(panels, equal=True)) + raise typer.Exit(0) + except NettowelInputError as exc: + typer.echo("Input is not valide", err=True) + typer.echo(str(exc), err=True) + raise typer.Exit(3) + + +if __name__ == "__main__": + app() diff --git a/nettowel/cli/main.py b/nettowel/cli/main.py index 77499a1..bca9b70 100644 --- a/nettowel/cli/main.py +++ b/nettowel/cli/main.py @@ -21,6 +21,7 @@ from nettowel.cli.scrapli import app as scrapli_app from nettowel.cli.restconf import app as restconf_app from nettowel.cli.pandas import app as pandas_app +from nettowel.cli.jsonpatch import app as jsonpatch_app from nettowel.cli.help import get_qrcode, HELP_MARKDOWN from nettowel.exceptions import NettowelDependencyMissing @@ -40,6 +41,7 @@ (scrapli_app, "scrapli"), (restconf_app, "restconf"), (pandas_app, "pandas"), + (jsonpatch_app, "jsonpatch"), ]: app.add_typer(subapp, name=name) diff --git a/nettowel/jsonpatch.py b/nettowel/jsonpatch.py new file mode 100644 index 0000000..4e90229 --- /dev/null +++ b/nettowel/jsonpatch.py @@ -0,0 +1,46 @@ +from typing import Any, List, Dict +from nettowel.logger import log +from nettowel._common import needs + +_module = "jsonpatch" + +try: + from jsonpatch import JsonPatch + + log.debug("Successfully imported %s", _module) + JSONPATCH_INSTALLED = True + +except ImportError: + log.warning("Failed to import %s", _module) + JSONPATCH_INSTALLED = False + + +def create(src: Any, dst: Any) -> "JsonPatch": + """Create a JSON patch [RFC 6902](http://tools.ietf.org/html/rfc6902) + + Args: + src (any): Data source datastructure + dst (any): Data destination datastructure + + Returns: + JsonPatch: JsonPatch object containing the patch + """ + needs(JSONPATCH_INSTALLED, "jsonpatch", _module) + patch = JsonPatch.from_diff(src=src, dst=dst) + return patch + + +def apply(patch: List[Dict[str, Any]], data: Any) -> Any: + """Apply a JSON patch [RFC 6902](http://tools.ietf.org/html/rfc6902) + + Args: + patch (list[dict]): List of patch instructions + data (any): Data to apply the patch onto + + Returns: + result: Updated data object + """ + needs(JSONPATCH_INSTALLED, "jsonpatch", _module) + jp = JsonPatch(patch) + result = jp.apply(data) + return result diff --git a/poetry.lock b/poetry.lock index 07efb1a..4925d99 100644 --- a/poetry.lock +++ b/poetry.lock @@ -523,6 +523,31 @@ files = [ [package.dependencies] Jinja2 = ">=2.2" +[[package]] +name = "jsonpatch" +version = "1.33" +description = "Apply JSON-Patches (RFC 6902)" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" +files = [ + {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, + {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, +] + +[package.dependencies] +jsonpointer = ">=1.9" + +[[package]] +name = "jsonpointer" +version = "2.4" +description = "Identify specific nodes in a JSON document (RFC 6901)" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" +files = [ + {file = "jsonpointer-2.4-py2.py3-none-any.whl", hash = "sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a"}, + {file = "jsonpointer-2.4.tar.gz", hash = "sha256:585cee82b70211fa9e6043b7bb89db6e1aa49524340dde8ad6b63206ea689d88"}, +] + [[package]] name = "junos-eznc" version = "2.7.0" @@ -1880,13 +1905,13 @@ six = "*" [[package]] name = "textual" -version = "0.56.0" +version = "0.56.1" description = "Modern Text User Interface framework" optional = true python-versions = "<4.0,>=3.8" files = [ - {file = "textual-0.56.0-py3-none-any.whl", hash = "sha256:c57202e135e9b06ad0c73c12d2d5828f8f46eff98e87db848c9c309b57c77e40"}, - {file = "textual-0.56.0.tar.gz", hash = "sha256:8782e286d96a0350be432e634bacf718e8c67392b8e70683df836e5e33e3055a"}, + {file = "textual-0.56.1-py3-none-any.whl", hash = "sha256:0394b6e94ab1a0a0e9522d004adfb9fa40a925086aad3fb37c9d6d165b0a0cb7"}, + {file = "textual-0.56.1.tar.gz", hash = "sha256:598f3dd694b37036fcd41e1a22895ac449c972563d182c50839a17057da10040"}, ] [package.dependencies] @@ -2087,8 +2112,9 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.link testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [extras] -full = ["Jinja2", "jinja2schema", "napalm", "netmiko", "nornir", "nornir-http", "nornir-jinja2", "nornir-napalm", "nornir-netmiko", "nornir-pyxl", "nornir-rich", "nornir-scrapli", "nornir-utils", "pandas", "scrapli", "textfsm", "trogon", "ttp"] +full = ["Jinja2", "jinja2schema", "jsonpatch", "napalm", "netmiko", "nornir", "nornir-http", "nornir-jinja2", "nornir-napalm", "nornir-netmiko", "nornir-pyxl", "nornir-rich", "nornir-scrapli", "nornir-utils", "pandas", "scrapli", "textfsm", "trogon", "ttp"] jinja = ["Jinja2", "jinja2schema"] +jsonpatch = ["jsonpatch"] napalm = ["napalm"] netmiko = ["netmiko"] nornir = ["nornir", "nornir-http", "nornir-jinja2", "nornir-napalm", "nornir-netmiko", "nornir-pyxl", "nornir-rich", "nornir-scrapli", "nornir-utils"] @@ -2101,4 +2127,4 @@ tui = ["trogon"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "6ada1c72d9f41032ec506f623e6a9f3309595ac95376bfc5af412de928dc12c7" +content-hash = "2955d0ca9d8b456449b99a99773f74e692cef15d3fb5c0f74a81017c21e1d4db" diff --git a/pyproject.toml b/pyproject.toml index 223307f..3f718bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ nornir-netmiko = {version = "^1.0.0", optional = true} nornir-rich = {version = "^0.1", optional = true} pandas = {version = "^2", optional = true} trogon = {version = "^0.5.0", optional = true} +jsonpatch = {version = "^1.33", optional = true} + [tool.poetry.dev-dependencies] black = "^22|^23|^24" @@ -56,6 +58,7 @@ scrapli = ["scrapli"] nornir = ["nornir", "nornir-napalm", "nornir-scrapli", "nornir-utils", "nornir-jinja2", "nornir-pyxl", "nornir-http", "nornir-netmiko", "nornir-rich"] pandas = ["pandas"] tui = ["trogon"] +jsonpatch = ["jsonpatch"] full = [ "Jinja2", "jinja2schema", @@ -74,7 +77,8 @@ full = [ "nornir-netmiko", "nornir-rich", "pandas", - "trogon" + "trogon", + "jsonpatch" ] [tool.poetry.scripts] diff --git a/tests/jsonpatch/data1.yaml b/tests/jsonpatch/data1.yaml new file mode 100644 index 0000000..856ed2d --- /dev/null +++ b/tests/jsonpatch/data1.yaml @@ -0,0 +1,7 @@ +interfaces: + - name: Loopback0 + addr: 10.10.10.10 + mask: 255.255.255.255 + - name: Ethernet0/1 + addr: 192.168.1.1 + mask: 255.255.255.0 diff --git a/tests/jsonpatch/data2.yaml b/tests/jsonpatch/data2.yaml new file mode 100644 index 0000000..ba464ad --- /dev/null +++ b/tests/jsonpatch/data2.yaml @@ -0,0 +1,7 @@ +interfaces: + - name: Loopback0 + addr: 10.10.10.10 + mask: 255.255.255.255 + - name: Ethernet0/1 + addr: 192.168.1.1 + mask: 255.255.255.192 diff --git a/tests/jsonpatch/data_patch.json b/tests/jsonpatch/data_patch.json new file mode 100644 index 0000000..e9ceaa5 --- /dev/null +++ b/tests/jsonpatch/data_patch.json @@ -0,0 +1,7 @@ +[ + { + "op": "replace", + "path": "/interfaces/1/mask", + "value": "255.255.255.192" + } +] diff --git a/tests/test_jsonpatch.py b/tests/test_jsonpatch.py new file mode 100644 index 0000000..c5fdf9e --- /dev/null +++ b/tests/test_jsonpatch.py @@ -0,0 +1,62 @@ +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner +from nettowel.cli.jsonpatch import app + +pytestmark = pytest.mark.jsonpatch +runner = CliRunner(mix_stderr=False) + + +def test_help() -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "apply" in result.stdout + assert "create" in result.stdout + + +def test_create() -> None: + result = runner.invoke( + app, ["create", "tests/jsonpatch/data1.yaml", "tests/jsonpatch/data2.yaml"] + ) + assert result.exit_code == 0 + assert "replace" in result.stdout + assert "/interfaces/1/mask" in result.stdout + assert "255.255.255.192" in result.stdout + + +def test_create_raw() -> None: + result = runner.invoke( + app, + ["create", "tests/jsonpatch/data1.yaml", "tests/jsonpatch/data2.yaml", "--raw"], + ) + assert result.exit_code == 0 + result = json.loads(result.stdout) + with Path("tests/jsonpatch/data_patch.json").open() as f: + expected = json.load(f) + assert result == expected + + +def test_apply() -> None: + result = runner.invoke( + app, ["apply", "tests/jsonpatch/data_patch.json", "tests/jsonpatch/data1.yaml"] + ) + assert result.exit_code == 0 + assert "255.255.255.192" in result.stdout + + +def test_apply_raw() -> None: + result = runner.invoke( + app, + [ + "apply", + "tests/jsonpatch/data_patch.json", + "tests/jsonpatch/data1.yaml", + "--raw", + ], + ) + assert result.exit_code == 0 + result = json.loads(result.stdout) + assert isinstance(result, dict) + assert result["interfaces"][1]["mask"] == "255.255.255.192"