Skip to content

Commit a50cb1b

Browse files
authored
Merge pull request #30 from InfrastructureAsCode-ch/develop
Release 0.6.0 with JSON Path support
2 parents 27c6918 + 9175b48 commit a50cb1b

17 files changed

+1535
-533
lines changed

.github/workflows/main.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
strategy:
4949
fail-fast: false
5050
matrix:
51-
python-version: [ '3.8', '3.9', '3.10' ]
51+
python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12' ]
5252
platform: [ubuntu-latest, macOS-latest, windows-latest]
5353
runs-on: ${{ matrix.platform }}
5454
steps:
@@ -88,9 +88,9 @@ jobs:
8888
strategy:
8989
fail-fast: false
9090
matrix:
91-
python-version: [ '3.8', '3.9', '3.10' ]
91+
python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12' ]
9292
platform: [ubuntu-latest, macOS-latest, windows-latest]
93-
extra: [ 'jinja', 'ttp']
93+
extra: [ 'jinja', 'ttp', 'jsonpatch']
9494
runs-on: ${{ matrix.platform }}
9595
steps:
9696
- uses: actions/checkout@v2

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ Collection of useful network automation functions
1111
1212

1313
## 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.
1415

15-
You can install it directly from pypi
16+
```bash
17+
pipx install nettowel[full]
18+
```
19+
20+
You can also install it directly from pypi
1621

1722
```bash
1823
pip install nettowel
@@ -31,6 +36,7 @@ The following groups are available (more details in the pyproject.toml):
3136
- scrapli
3237
- nornir
3338
- pandas
39+
- jsonpatch
3440
- tui
3541

3642
```bash
@@ -134,6 +140,18 @@ Many features are not implemented yet and many features will come.
134140

135141
![yaml dump](imgs/yaml-dump.png)
136142

143+
144+
### JSON Patch ([RFC 6902](http://tools.ietf.org/html/rfc6902))
145+
146+
#### create
147+
148+
![JSON Patch create](imgs/jsonpatch-create.png)
149+
150+
#### apply
151+
152+
![JSON Patch apply](imgs/jsonpatch-apply.png)
153+
154+
137155
### Help
138156

139157
![Help QRcode](imgs/nettowel-help.png)

imgs/jsonpatch-apply.png

39.8 KB
Loading

imgs/jsonpatch-create.png

35.6 KB
Loading

nettowel/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.5.1" # From Makefile
1+
__version__ = "0.6.0" # From Makefile

nettowel/cli/jsonpatch.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
from nettowel import jsonpatch
17+
18+
19+
app = get_typer_app(help="JSON Patch [RFC 6902](http://tools.ietf.org/html/rfc6902)")
20+
21+
22+
@app.command()
23+
def create(
24+
ctx: typer.Context,
25+
src_file_name: typer.FileText = typer.Argument(
26+
...,
27+
exists=True,
28+
file_okay=True,
29+
dir_okay=False,
30+
readable=True,
31+
resolve_path=True,
32+
allow_dash=True,
33+
metavar="src",
34+
help="Source data (YAML/JSON)",
35+
autocompletion=auto_complete_paths,
36+
),
37+
dst_file_name: typer.FileText = typer.Argument(
38+
...,
39+
exists=True,
40+
file_okay=True,
41+
dir_okay=False,
42+
readable=True,
43+
resolve_path=True,
44+
allow_dash=True,
45+
metavar="dst",
46+
help="Destination data (YAML/JSON)",
47+
autocompletion=auto_complete_paths,
48+
),
49+
json: bool = typer.Option(False, "--json", help="JSON output"),
50+
raw: bool = typer.Option(False, "--raw", help="Raw result output"),
51+
only_result: bool = typer.Option(
52+
False, "--print-result-only", help="Only print the result"
53+
),
54+
) -> None:
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]Destrination",
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+
try:
123+
patch_data = read_yaml(patch_file_name)
124+
data_input = read_yaml(data_file_name)
125+
126+
new_data = jsonpatch.apply(patch=patch_data, data=data_input)
127+
128+
if json or raw:
129+
print_json(data=new_data)
130+
else:
131+
panels: List[Panel] = []
132+
if not only_result:
133+
panels.append(
134+
Panel(
135+
JSON.from_data(patch_data),
136+
title="[yellow]Patch",
137+
border_style="blue",
138+
)
139+
)
140+
panels.append(
141+
Panel(
142+
JSON.from_data(data_input),
143+
title="[yellow]Input Data",
144+
border_style="blue",
145+
)
146+
)
147+
panels.append(
148+
Panel(
149+
JSON.from_data(data=new_data),
150+
title="[yellow]Patched Data with JSON Patch",
151+
border_style="blue",
152+
)
153+
)
154+
print(Columns(panels, equal=True))
155+
raise typer.Exit(0)
156+
except NettowelInputError as exc:
157+
typer.echo("Input is not valide", err=True)
158+
typer.echo(str(exc), err=True)
159+
raise typer.Exit(3)
160+
161+
162+
if __name__ == "__main__":
163+
app()

nettowel/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from nettowel.cli.scrapli import app as scrapli_app
2222
from nettowel.cli.restconf import app as restconf_app
2323
from nettowel.cli.pandas import app as pandas_app
24+
from nettowel.cli.jsonpatch import app as jsonpatch_app
2425
from nettowel.cli.help import get_qrcode, HELP_MARKDOWN
2526

2627
from nettowel.exceptions import NettowelDependencyMissing
@@ -40,6 +41,7 @@
4041
(scrapli_app, "scrapli"),
4142
(restconf_app, "restconf"),
4243
(pandas_app, "pandas"),
44+
(jsonpatch_app, "jsonpatch"),
4345
]:
4446
app.add_typer(subapp, name=name)
4547

nettowel/exceptions.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from typing import Any
22

33

4-
class NettowelException(Exception):
5-
...
4+
class NettowelException(Exception): ...
65

76

87
class NettowelDependencyMissing(NettowelException):
@@ -18,16 +17,13 @@ def __init__(
1817
super().__init__(self.msg)
1918

2019

21-
class NettowelSyntaxError(NettowelException):
22-
...
20+
class NettowelSyntaxError(NettowelException): ...
2321

2422

25-
class NettowelTimeoutError(NettowelException):
26-
...
23+
class NettowelTimeoutError(NettowelException): ...
2724

2825

29-
class NettowelUsageError(NettowelException):
30-
...
26+
class NettowelUsageError(NettowelException): ...
3127

3228

3329
class NettowelRestconfError(NettowelException):
@@ -41,5 +37,4 @@ def __init__(
4137
super().__init__(self.error_str)
4238

4339

44-
class NettowelInputError(NettowelException):
45-
...
40+
class NettowelInputError(NettowelException): ...

nettowel/jsonpatch.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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

nettowel/restconf.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,16 @@ def send_request(
5252
except requests.exceptions.ConnectionError as exc:
5353
raise NettowelRestconfError(str(exc), None)
5454
except requests.exceptions.RequestException as exc:
55-
if exc.request:
55+
exc_response: Any = None
56+
if exc.request is not None and exc.response is not None:
5657
if not exc.response.text:
57-
response = None
58+
exc_response = None
5859
else:
5960
if return_xml:
6061
return exc.response.text
6162
try:
62-
response = exc.response.json()
63+
exc_response = exc.response.json()
6364
except json.decoder.JSONDecodeError:
64-
response = exc.response.text
65-
else:
66-
response = None
67-
log.debug(response)
68-
raise NettowelRestconfError(str(exc), response)
65+
exc_response = exc.response.text
66+
log.debug(exc_response)
67+
raise NettowelRestconfError(str(exc), exc_response)

0 commit comments

Comments
 (0)