python-nso-client 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-nso-client
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: James Harr <jharr@internet2.edu>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: structlog>=25.2.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Python NSO Library
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Developing
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
# Build
|
|
22
|
+
uv build
|
|
23
|
+
```
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
src/nso_client/__init__.py,sha256=kfYMhYyu7ibhzLdu-zCaT0lKWCWsG6C4kw7IAuUbKKQ,26190
|
|
2
|
+
src/nso_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
python_nso_client-0.0.0.dist-info/METADATA,sha256=GMRPBQBRdUiJzW07TcxxwOkox_M53tVc3V11gto9pl8,355
|
|
4
|
+
python_nso_client-0.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
python_nso_client-0.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility library to make talking with NSO slightly simpler
|
|
3
|
+
|
|
4
|
+
It's a thin wrapper around `requests` that handles some common parameters
|
|
5
|
+
a user would want to pass and encodes correctly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import ssl
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Dict, List, Mapping, Optional, Union
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import structlog
|
|
19
|
+
from httpx import BasicAuth
|
|
20
|
+
from structlog.stdlib import BoundLogger
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"NSOClient",
|
|
24
|
+
"Patch",
|
|
25
|
+
"PatchResult",
|
|
26
|
+
"RestConfError",
|
|
27
|
+
"NotFoundError",
|
|
28
|
+
"AccessDeniedError",
|
|
29
|
+
"BadRequestError",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NSOCommitMode(Enum):
|
|
34
|
+
NORMAL = "normal"
|
|
35
|
+
DRY_RUN = "dry-run"
|
|
36
|
+
NO_DEPLOY = "no-deploy"
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def _missing_(cls, value: str):
|
|
40
|
+
raise Exception(
|
|
41
|
+
f"{value} is not a valid {cls.__name__}"
|
|
42
|
+
f"valid options are {cls._member_names_}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NSOClient:
|
|
47
|
+
"""
|
|
48
|
+
Lite wrapper that instantiates an NSO connector object
|
|
49
|
+
with secrets from environment variables.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
session: httpx.Client
|
|
53
|
+
base_url: str
|
|
54
|
+
log: BoundLogger
|
|
55
|
+
commit_kwargs: Dict[str, str]
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
base_url: str,
|
|
60
|
+
auth: BasicAuth = None,
|
|
61
|
+
verify: bool | ssl.SSLContext = True,
|
|
62
|
+
restconf_path: str = "/restconf/data",
|
|
63
|
+
logger: BoundLogger = None,
|
|
64
|
+
**commit_kwargs,
|
|
65
|
+
):
|
|
66
|
+
"""
|
|
67
|
+
Create a light weight NSO Client
|
|
68
|
+
|
|
69
|
+
base_url: NSO server, IE: https://nso.foo.bar.com
|
|
70
|
+
auth: Authentication information, typically AuthBasic
|
|
71
|
+
verify_ssl: Verify SSL or not
|
|
72
|
+
restconf_path: Restconf base path, this is almost always /restconf/data
|
|
73
|
+
logger: Logger to use for queries
|
|
74
|
+
commit_kwargs: Additional parameters to pass to any given commit
|
|
75
|
+
"""
|
|
76
|
+
self.session = httpx.Client(verify=verify)
|
|
77
|
+
self.session.auth = auth
|
|
78
|
+
self.session.headers["Accept"] = "application/yang-data+json"
|
|
79
|
+
self.session.headers["Content-Type"] = "application/yang-data+json"
|
|
80
|
+
self.base_url = base_url + restconf_path
|
|
81
|
+
if not logger:
|
|
82
|
+
logger = structlog.get_logger()
|
|
83
|
+
self.log = logger.bind(lib="NSOClient")
|
|
84
|
+
self.commit_kwargs = commit_kwargs
|
|
85
|
+
|
|
86
|
+
def get(
|
|
87
|
+
self,
|
|
88
|
+
path: str,
|
|
89
|
+
*path_params: list[str],
|
|
90
|
+
**kwargs,
|
|
91
|
+
) -> Union[list, dict]:
|
|
92
|
+
"""
|
|
93
|
+
Retrieve RestConf data at path.
|
|
94
|
+
|
|
95
|
+
path: A restconf path. Example: /common:infrastructure/prefix-set:prefix-set={}
|
|
96
|
+
path_params: Values to insert into `{}` in the URL. Values will be appropriately
|
|
97
|
+
URL encoded, so "Foo/Bar" will be encoded to "Foo%2FBar"
|
|
98
|
+
kwargs: Query keyword arguments, see query_params()
|
|
99
|
+
|
|
100
|
+
Returns: JSON Payload, or raises an exception if an error occurred
|
|
101
|
+
Raises: RestConfError or a sub-class
|
|
102
|
+
"""
|
|
103
|
+
url = self._make_url(path, path_params)
|
|
104
|
+
query_params = self.query_params(**kwargs)
|
|
105
|
+
response = self.session.get(url, params=query_params)
|
|
106
|
+
self.log.debug(
|
|
107
|
+
"NSO Query",
|
|
108
|
+
method="GET",
|
|
109
|
+
url=url,
|
|
110
|
+
params=query_params,
|
|
111
|
+
status_code=response.status_code,
|
|
112
|
+
ok=response.ok,
|
|
113
|
+
error_text=None if response.ok else response.text,
|
|
114
|
+
response_len=len(response.content),
|
|
115
|
+
elapsed_time=response.elapsed.total_seconds(),
|
|
116
|
+
)
|
|
117
|
+
# Special case for .get() - if we get a 404, just return None instead
|
|
118
|
+
# of raising a NotFound exception
|
|
119
|
+
if response.status_code == 404:
|
|
120
|
+
return None
|
|
121
|
+
return self._parse_response(response)
|
|
122
|
+
|
|
123
|
+
def put(
|
|
124
|
+
self,
|
|
125
|
+
path: str,
|
|
126
|
+
*path_params: list[str],
|
|
127
|
+
payload: dict[str, str],
|
|
128
|
+
**kwargs,
|
|
129
|
+
) -> Union[Mapping, DryRunResult]:
|
|
130
|
+
"""
|
|
131
|
+
PUT data at a given path
|
|
132
|
+
|
|
133
|
+
path: A restconf path. Example: /common:infrastructure/prefix-set:prefix-set={}
|
|
134
|
+
path_params: Values to insert into `{}` in the URL. Values will be appropriately
|
|
135
|
+
URL encoded, so "Foo/Bar" will be encoded to "Foo%2FBar"
|
|
136
|
+
kwargs: Query keyword arguments, see query_params()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
Returns: JSON Payload, or raises an exception if an error occurred
|
|
140
|
+
Raises: RestConfError or a sub-class
|
|
141
|
+
"""
|
|
142
|
+
url = self._make_url(path, path_params)
|
|
143
|
+
query_params = self.query_params(**kwargs)
|
|
144
|
+
response = self.session.put(url, params=query_params, json=payload)
|
|
145
|
+
self.log.info(
|
|
146
|
+
"NSO Commit",
|
|
147
|
+
method="PUT",
|
|
148
|
+
url=url,
|
|
149
|
+
params=query_params,
|
|
150
|
+
payload=payload,
|
|
151
|
+
status_code=response.status_code,
|
|
152
|
+
ok=response.ok,
|
|
153
|
+
error_text=None if response.ok else response.text,
|
|
154
|
+
response_len=len(response.content),
|
|
155
|
+
elapsed_time=response.elapsed.total_seconds(),
|
|
156
|
+
)
|
|
157
|
+
return self._parse_response(response)
|
|
158
|
+
|
|
159
|
+
def delete(
|
|
160
|
+
self,
|
|
161
|
+
path: str,
|
|
162
|
+
*path_params: list[str],
|
|
163
|
+
**kwargs,
|
|
164
|
+
) -> Union[Mapping, DryRunResult]:
|
|
165
|
+
"""
|
|
166
|
+
DELETE data to a given path
|
|
167
|
+
|
|
168
|
+
path: A restconf path. Example: /ncs:devices/device/check-sync
|
|
169
|
+
path_params: Values to insert into `{}` in the URL. Values will be appropriately
|
|
170
|
+
URL encoded, so "Foo/Bar" will be encoded to "Foo%2FBar"
|
|
171
|
+
kwargs: Query keyword arguments, see query_params()
|
|
172
|
+
|
|
173
|
+
Returns: None, dict if dry_run is used
|
|
174
|
+
Raises: RestConfError or a sub-class
|
|
175
|
+
"""
|
|
176
|
+
url = self._make_url(path, path_params)
|
|
177
|
+
query_params = self.query_params(**kwargs)
|
|
178
|
+
response = self.session.delete(url, params=query_params)
|
|
179
|
+
self.log.info(
|
|
180
|
+
"NSO Commit",
|
|
181
|
+
method="DELETE",
|
|
182
|
+
url=url,
|
|
183
|
+
params=query_params,
|
|
184
|
+
status_code=response.status_code,
|
|
185
|
+
ok=response.ok,
|
|
186
|
+
error_text=None if response.ok else response.text,
|
|
187
|
+
response_len=len(response.content),
|
|
188
|
+
elapsed_time=response.elapsed.total_seconds(),
|
|
189
|
+
)
|
|
190
|
+
return self._parse_response(response)
|
|
191
|
+
|
|
192
|
+
def post(
|
|
193
|
+
self,
|
|
194
|
+
path: str,
|
|
195
|
+
*path_params: list[str],
|
|
196
|
+
payload: dict[str, str],
|
|
197
|
+
**kwargs,
|
|
198
|
+
):
|
|
199
|
+
"""
|
|
200
|
+
POST data to a given path (usually to call an action)
|
|
201
|
+
|
|
202
|
+
path: A restconf path. Example: /ncs:devices/device/check-sync
|
|
203
|
+
path_params: Values to insert into `{}` in the URL. Values will be appropriately
|
|
204
|
+
URL encoded, so "Foo/Bar" will be encoded to "Foo%2FBar"
|
|
205
|
+
|
|
206
|
+
Returns: JSON Payload, or raises an exception if an error occurred
|
|
207
|
+
Raises: RestConfError or a sub-class
|
|
208
|
+
"""
|
|
209
|
+
url = self._make_url(path, path_params)
|
|
210
|
+
query_params = self.query_params(**kwargs)
|
|
211
|
+
response = self.session.post(url, json=payload, params=query_params)
|
|
212
|
+
self.log.info(
|
|
213
|
+
"NSO Commit/Call",
|
|
214
|
+
method="POST",
|
|
215
|
+
url=url,
|
|
216
|
+
params=query_params,
|
|
217
|
+
status_code=response.status_code,
|
|
218
|
+
ok=response.ok,
|
|
219
|
+
error_text=None if response.ok else response.text,
|
|
220
|
+
response_len=len(response.content),
|
|
221
|
+
elapsed_time=response.elapsed.total_seconds(),
|
|
222
|
+
)
|
|
223
|
+
return self._parse_response(response)
|
|
224
|
+
|
|
225
|
+
def patch(
|
|
226
|
+
self,
|
|
227
|
+
path: str,
|
|
228
|
+
*path_params: list[str],
|
|
229
|
+
payload: dict[str, str],
|
|
230
|
+
style: PatchType,
|
|
231
|
+
ignore_codes=(),
|
|
232
|
+
**kwargs,
|
|
233
|
+
) -> Union[Mapping, DryRunResult]:
|
|
234
|
+
"""
|
|
235
|
+
PATCH data to a given path
|
|
236
|
+
|
|
237
|
+
path: A restconf path. Example: /ncs:services/pdp:pdp=PDP-1
|
|
238
|
+
path_params: Values to insert into `{}` in the URL. Values will be appropriately
|
|
239
|
+
URL encoded, so "Foo/Bar" will be encoded to "Foo%2FBar"
|
|
240
|
+
payload: JSON data
|
|
241
|
+
style: PatchType.PLAIN or PatchType.YANG_PATCH
|
|
242
|
+
ignore_codes: Defaults to empty, but can be a list of codes to not treat
|
|
243
|
+
as an error and avoid raising an exception. Typically used
|
|
244
|
+
internally for 409 errors with a YANG-Patch
|
|
245
|
+
kwargs: Query keyword arguments, see query_params()
|
|
246
|
+
|
|
247
|
+
Returns: JSON Payload, or raises an exception if an error occurred
|
|
248
|
+
Raises: RestConfError or a sub-class
|
|
249
|
+
|
|
250
|
+
A PatchType.PLAIN makes this call function similar to a
|
|
251
|
+
MERGE operation where existing fields are updated if they exist.
|
|
252
|
+
To see an example PATCH operation, see rfc8040 section 4.6
|
|
253
|
+
|
|
254
|
+
A PatchType.YANG_PATCH can be include a number of different operations,
|
|
255
|
+
such as create/replace/merge/delete on different locations. Using the `yang_patch`
|
|
256
|
+
is recommended since it takes care of a lot of boilerplate. To
|
|
257
|
+
see an example YANG-Patch, see IETF draft-iet-netconf-yang-patch-14 appendix D.1.5.
|
|
258
|
+
|
|
259
|
+
References
|
|
260
|
+
- https://datatracker.ietf.org/doc/html/rfc8040#section-4.6
|
|
261
|
+
- https://datatracker.ietf.org/doc/html/draft-ietf-netconf-yang-patch-14#appendix-D.1.5
|
|
262
|
+
"""
|
|
263
|
+
url = self._make_url(path, path_params)
|
|
264
|
+
query_params = self.query_params(**kwargs)
|
|
265
|
+
|
|
266
|
+
# If using YANG-Patch, change content-type
|
|
267
|
+
headers = {}
|
|
268
|
+
if style == PatchType.YANG_PATCH:
|
|
269
|
+
headers["Content-Type"] = "application/yang-patch+json"
|
|
270
|
+
|
|
271
|
+
response = self.session.patch(
|
|
272
|
+
url,
|
|
273
|
+
headers=headers,
|
|
274
|
+
json=payload,
|
|
275
|
+
params=query_params,
|
|
276
|
+
)
|
|
277
|
+
self.log.info(
|
|
278
|
+
"NSO Commit",
|
|
279
|
+
method="PATCH",
|
|
280
|
+
url=url,
|
|
281
|
+
params=query_params,
|
|
282
|
+
style=style,
|
|
283
|
+
payload=payload,
|
|
284
|
+
status_code=response.status_code,
|
|
285
|
+
ok=response.ok,
|
|
286
|
+
error_text=None if response.ok else response.text,
|
|
287
|
+
response_len=len(response.content),
|
|
288
|
+
elapsed_time=response.elapsed.total_seconds(),
|
|
289
|
+
)
|
|
290
|
+
return self._parse_response(response, ignore_codes)
|
|
291
|
+
|
|
292
|
+
def yang_patch(self, path: str, *path_params: list[str]) -> Patch:
|
|
293
|
+
"""Prepare a request using the YANG-Patch.
|
|
294
|
+
|
|
295
|
+
Example usage:
|
|
296
|
+
nso = NSOClient(...)
|
|
297
|
+
p = nso.yang_patch("/tailf-ncs:services/pdp:pdp={}", "MY-PDP-1")
|
|
298
|
+
p.replace("/admin-state", payload={"pdp:admin-state": "in-service})
|
|
299
|
+
p.delete("/lag-id")
|
|
300
|
+
p.commit().raise_if_error()
|
|
301
|
+
"""
|
|
302
|
+
return Patch(
|
|
303
|
+
nso=self,
|
|
304
|
+
path=path,
|
|
305
|
+
path_params=path_params,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _parse_response(
|
|
309
|
+
self,
|
|
310
|
+
response: httpx.Response,
|
|
311
|
+
ignore_codes: List[int] = [],
|
|
312
|
+
) -> Union[Mapping, DryRunResult]:
|
|
313
|
+
self._raise_if_error(response, ignore_codes)
|
|
314
|
+
if len(response.content) == 0:
|
|
315
|
+
return None
|
|
316
|
+
rv = response.json()
|
|
317
|
+
if DryRunResult.is_dry_run(rv):
|
|
318
|
+
return DryRunResult(rv)
|
|
319
|
+
return rv
|
|
320
|
+
|
|
321
|
+
def query_params(
|
|
322
|
+
self,
|
|
323
|
+
**kwargs,
|
|
324
|
+
) -> Dict[str, str]:
|
|
325
|
+
"""Generate query parameters based on kwargs passed into
|
|
326
|
+
get/put/delete/post/patch calls. Note that these get merged
|
|
327
|
+
with kwargs passed into NSOClient
|
|
328
|
+
|
|
329
|
+
content: "config", "non-config", or None (default, show both)
|
|
330
|
+
fields: List[str] of fields to return (default none, show everything)
|
|
331
|
+
unhide: List[str] of groups to unhide (default, nothing)
|
|
332
|
+
|
|
333
|
+
dry_run: "cli", "native", None (default, do not dry-run)
|
|
334
|
+
no_deploy: inhibit service call actions (default no)
|
|
335
|
+
"""
|
|
336
|
+
# Merge in defaults, preferring call parameters
|
|
337
|
+
kwargs = self.commit_kwargs | kwargs
|
|
338
|
+
# Generate query-string parameters to pass to requests.Request
|
|
339
|
+
query_params = {}
|
|
340
|
+
if unhide := kwargs.get("unhide", []):
|
|
341
|
+
query_params["unhide"] = ",".join(unhide)
|
|
342
|
+
if "dry_run" in kwargs:
|
|
343
|
+
query_params["dry-run"] = kwargs["dry_run"]
|
|
344
|
+
if content := kwargs.get("content", None):
|
|
345
|
+
query_params["content"] = content
|
|
346
|
+
if "no_deploy" in kwargs:
|
|
347
|
+
query_params["no-deploy"] = ""
|
|
348
|
+
if fields := kwargs.get("fields", []):
|
|
349
|
+
query_params["fields"] = ";".join(fields)
|
|
350
|
+
return query_params
|
|
351
|
+
|
|
352
|
+
def _make_url(self, path, args):
|
|
353
|
+
"""Substitute {} items in path with url encoded values"""
|
|
354
|
+
url = self.base_url + path.format(
|
|
355
|
+
*[urllib.parse.quote(str(p), safe="") for p in args]
|
|
356
|
+
)
|
|
357
|
+
return url
|
|
358
|
+
|
|
359
|
+
def _raise_if_error(
|
|
360
|
+
self, response: httpx.Response, ignore_codes: List[int] = []
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Raises the appropriate exception if there is one"""
|
|
363
|
+
|
|
364
|
+
if response.ok:
|
|
365
|
+
# no error
|
|
366
|
+
return
|
|
367
|
+
elif response.status_code in ignore_codes:
|
|
368
|
+
# no error as defined by caller
|
|
369
|
+
return
|
|
370
|
+
elif response.status_code == 404:
|
|
371
|
+
raise NotFoundError(response)
|
|
372
|
+
elif response.status_code == 401:
|
|
373
|
+
raise AccessDeniedError(response)
|
|
374
|
+
elif (
|
|
375
|
+
response.status_code == 400
|
|
376
|
+
and "ietf-yang-patch:yang-patch-status" in response.text
|
|
377
|
+
):
|
|
378
|
+
raise YangPatchError(response)
|
|
379
|
+
elif response.status_code == 400:
|
|
380
|
+
raise BadRequestError(response)
|
|
381
|
+
else:
|
|
382
|
+
# Unknown request error
|
|
383
|
+
raise RestConfError(response)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class PatchType(Enum):
|
|
387
|
+
PLAIN = "plain"
|
|
388
|
+
YANG_PATCH = "yang-patch"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class InsertWhere(Enum):
|
|
392
|
+
FIRST = "first"
|
|
393
|
+
LAST = "last"
|
|
394
|
+
BEFORE = "before"
|
|
395
|
+
AFTER = "after"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class Patch:
|
|
399
|
+
"""A batch of operations to be submitted as a YANG PATCH"""
|
|
400
|
+
|
|
401
|
+
nso: NSOClient
|
|
402
|
+
path: str
|
|
403
|
+
path_params: List[str]
|
|
404
|
+
unhide: List[str]
|
|
405
|
+
edits = List[Mapping]
|
|
406
|
+
|
|
407
|
+
def __init__(
|
|
408
|
+
self,
|
|
409
|
+
nso: NSOClient,
|
|
410
|
+
path: str,
|
|
411
|
+
path_params: List[str] = [],
|
|
412
|
+
unhide: List[str] = [],
|
|
413
|
+
):
|
|
414
|
+
self.nso = nso
|
|
415
|
+
self.path = path
|
|
416
|
+
self.path_params = path_params
|
|
417
|
+
self.unhide = []
|
|
418
|
+
self.unhide.extend(unhide)
|
|
419
|
+
self.edits = []
|
|
420
|
+
|
|
421
|
+
def _format_path(self, path, args):
|
|
422
|
+
"""Substitute {} items in path with url encoded values
|
|
423
|
+
|
|
424
|
+
This differs from NSOClient._format_path in that it doesn't prepend a base URL
|
|
425
|
+
"""
|
|
426
|
+
return path.format(*[urllib.parse.quote(str(p), safe="") for p in args])
|
|
427
|
+
|
|
428
|
+
def create(
|
|
429
|
+
self,
|
|
430
|
+
path: str,
|
|
431
|
+
*path_params: list[str],
|
|
432
|
+
value: dict[str, str],
|
|
433
|
+
edit_id: str = None,
|
|
434
|
+
) -> str:
|
|
435
|
+
"""Create an element at a path, fail if it this path already exists
|
|
436
|
+
path: Target path to modify, may include {}
|
|
437
|
+
path_params: Values to fill into {} part of the format
|
|
438
|
+
value: Value to create, note that for leaf values you will likely need to provide
|
|
439
|
+
provide a dictionary instead of just the leaf-value.
|
|
440
|
+
edit_id: Optional, specify an edit ID instead of the default
|
|
441
|
+
|
|
442
|
+
return: Returns the edit-id
|
|
443
|
+
"""
|
|
444
|
+
p = self._format_path(path, path_params)
|
|
445
|
+
edit_id = edit_id or p
|
|
446
|
+
self.edits.append(
|
|
447
|
+
{
|
|
448
|
+
"edit-id": edit_id,
|
|
449
|
+
"operation": "create",
|
|
450
|
+
"target": p,
|
|
451
|
+
"value": value,
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
return edit_id
|
|
455
|
+
|
|
456
|
+
def delete(
|
|
457
|
+
self,
|
|
458
|
+
path: str,
|
|
459
|
+
*path_params: list[str],
|
|
460
|
+
edit_id: str = None,
|
|
461
|
+
):
|
|
462
|
+
"""Delete YANG data, fail if it does not exist
|
|
463
|
+
path: Target path to modify, may include {}
|
|
464
|
+
path_params: Values to fill into {} part of the format
|
|
465
|
+
edit_id: Optional, specify an edit ID instead of the default
|
|
466
|
+
|
|
467
|
+
return: Returns the edit-id
|
|
468
|
+
"""
|
|
469
|
+
p = self._format_path(path, path_params)
|
|
470
|
+
edit_id = edit_id or p
|
|
471
|
+
self.edits.append(
|
|
472
|
+
{
|
|
473
|
+
"edit-id": edit_id,
|
|
474
|
+
"operation": "delete",
|
|
475
|
+
"target": p,
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
return edit_id
|
|
479
|
+
|
|
480
|
+
def insert(
|
|
481
|
+
self,
|
|
482
|
+
path: str,
|
|
483
|
+
*path_params: list[str],
|
|
484
|
+
where: InsertWhere,
|
|
485
|
+
point: str = None,
|
|
486
|
+
value: dict[str, str],
|
|
487
|
+
edit_id: str = None,
|
|
488
|
+
):
|
|
489
|
+
"""Create an element at a path, inserting before/after an existing element. Only valid for user-ordered items
|
|
490
|
+
|
|
491
|
+
path: Target path to modify, may include {}
|
|
492
|
+
path_params: Values to fill into {} part of the format
|
|
493
|
+
point: insert relative-to (needed if using BEFORE/AFTER)
|
|
494
|
+
where: FIRST/LAST/BEFORE/AFTER
|
|
495
|
+
value: Value to create, note that for leaf values you will likely need to provide
|
|
496
|
+
provide a dictionary instead of just the leaf-value.
|
|
497
|
+
edit_id: Optional, specify an edit ID instead of the default
|
|
498
|
+
|
|
499
|
+
return: Returns the edit-id
|
|
500
|
+
"""
|
|
501
|
+
p = self._format_path(path, path_params)
|
|
502
|
+
edit_id = edit_id or p
|
|
503
|
+
op = {
|
|
504
|
+
"edit-id": edit_id,
|
|
505
|
+
"operation": "insert",
|
|
506
|
+
"target": p,
|
|
507
|
+
"where": where.value,
|
|
508
|
+
"value": value,
|
|
509
|
+
}
|
|
510
|
+
if where in (InsertWhere.BEFORE, InsertWhere.AFTER):
|
|
511
|
+
op["point"] = point
|
|
512
|
+
|
|
513
|
+
self.edits.append(op)
|
|
514
|
+
return edit_id
|
|
515
|
+
|
|
516
|
+
def merge(
|
|
517
|
+
self,
|
|
518
|
+
path: str,
|
|
519
|
+
*path_params: list[str],
|
|
520
|
+
value: dict[str, str],
|
|
521
|
+
edit_id: str = None,
|
|
522
|
+
):
|
|
523
|
+
"""Merge data into an existing path
|
|
524
|
+
|
|
525
|
+
path: Target path to modify, may include {}
|
|
526
|
+
path_params: Values to fill into {} part of the format
|
|
527
|
+
value: Value(s) to merge in, note that for leaf values you will likely need to provide
|
|
528
|
+
provide a dictionary instead of just the leaf-value.
|
|
529
|
+
edit_id: Optional, specify an edit ID instead of the default
|
|
530
|
+
|
|
531
|
+
return: Returns the edit-id
|
|
532
|
+
"""
|
|
533
|
+
p = self._format_path(path, path_params)
|
|
534
|
+
edit_id = edit_id or p
|
|
535
|
+
self.edits.append(
|
|
536
|
+
{
|
|
537
|
+
"edit-id": edit_id,
|
|
538
|
+
"operation": "merge",
|
|
539
|
+
"target": p,
|
|
540
|
+
"value": value,
|
|
541
|
+
}
|
|
542
|
+
)
|
|
543
|
+
return edit_id
|
|
544
|
+
|
|
545
|
+
def move(
|
|
546
|
+
self,
|
|
547
|
+
path: str,
|
|
548
|
+
*path_params: list[str],
|
|
549
|
+
where: InsertWhere,
|
|
550
|
+
point: str = None,
|
|
551
|
+
edit_id: str = None,
|
|
552
|
+
):
|
|
553
|
+
"""Move an existing element, only valid for user-ordered lists
|
|
554
|
+
|
|
555
|
+
path: Target path to modify, may include {}
|
|
556
|
+
path_params: Values to fill into {} part of the format
|
|
557
|
+
point: insert relative-to (needed if using BEFORE/AFTER)
|
|
558
|
+
where: FIRST/LAST/BEFORE/AFTERlikely need to provide
|
|
559
|
+
provide a dictionary instead of just the leaf-value.
|
|
560
|
+
edit_id: Optional, specify an edit ID instead of the default
|
|
561
|
+
|
|
562
|
+
return: Returns the edit-id
|
|
563
|
+
"""
|
|
564
|
+
p = self._format_path(path, path_params)
|
|
565
|
+
edit_id = edit_id or p
|
|
566
|
+
op = {
|
|
567
|
+
"edit-id": edit_id,
|
|
568
|
+
"operation": "move",
|
|
569
|
+
"target": p,
|
|
570
|
+
"where": where.value,
|
|
571
|
+
}
|
|
572
|
+
if where in (InsertWhere.BEFORE, InsertWhere.AFTER):
|
|
573
|
+
op["point"] = point
|
|
574
|
+
|
|
575
|
+
self.edits.append(op)
|
|
576
|
+
return edit_id
|
|
577
|
+
|
|
578
|
+
def replace(
|
|
579
|
+
self,
|
|
580
|
+
path: str,
|
|
581
|
+
*path_params: list[str],
|
|
582
|
+
value: dict[str, str],
|
|
583
|
+
edit_id: str = None,
|
|
584
|
+
):
|
|
585
|
+
pass
|
|
586
|
+
|
|
587
|
+
"""Delete YANG data, succeed if it's already gone
|
|
588
|
+
path: Target path to modify, may include {}
|
|
589
|
+
path_params: Values to fill into {} part of the format
|
|
590
|
+
edit_id: Optional, specify an edit ID instead of the default
|
|
591
|
+
|
|
592
|
+
return: Returns the edit-id
|
|
593
|
+
"""
|
|
594
|
+
p = self._format_path(path, path_params)
|
|
595
|
+
edit_id = edit_id or p
|
|
596
|
+
self.edits.append(
|
|
597
|
+
{
|
|
598
|
+
"edit-id": edit_id,
|
|
599
|
+
"operation": "replace",
|
|
600
|
+
"target": p,
|
|
601
|
+
"value": value,
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
return edit_id
|
|
605
|
+
|
|
606
|
+
def commit(
|
|
607
|
+
self,
|
|
608
|
+
patch_id: str = None,
|
|
609
|
+
comment: str = None,
|
|
610
|
+
**kwargs,
|
|
611
|
+
) -> Union[PatchResult, DryRunResult]:
|
|
612
|
+
"""Execute the prepared patch. Patch is executed atomically
|
|
613
|
+
|
|
614
|
+
patch_id: Specify patch-id (not needed)
|
|
615
|
+
comment: Optional, Comment to pass to the RestConf server
|
|
616
|
+
kwargs: Query keyword arguments, see query_params()
|
|
617
|
+
"""
|
|
618
|
+
payload = {
|
|
619
|
+
"patch-id": patch_id or self.path,
|
|
620
|
+
"edit": self.edits,
|
|
621
|
+
}
|
|
622
|
+
if comment:
|
|
623
|
+
payload["comment"] = comment
|
|
624
|
+
resp = self.nso.patch(
|
|
625
|
+
self.path,
|
|
626
|
+
*self.path_params,
|
|
627
|
+
payload={"ietf-yang-patch:yang-patch": payload},
|
|
628
|
+
style=PatchType.YANG_PATCH,
|
|
629
|
+
unhide=self.unhide,
|
|
630
|
+
ignore_codes=(409,),
|
|
631
|
+
**kwargs,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
# Dry-run response
|
|
635
|
+
if isinstance(resp, DryRunResult):
|
|
636
|
+
return resp
|
|
637
|
+
|
|
638
|
+
# Standard response
|
|
639
|
+
elif "ietf-yang-patch:yang-patch-status" in resp:
|
|
640
|
+
resp = resp["ietf-yang-patch:yang-patch-status"]
|
|
641
|
+
edits = resp.get("edit-status", {}).get("edits", [])
|
|
642
|
+
return PatchResult(
|
|
643
|
+
patch_id=resp["patch-id"],
|
|
644
|
+
edits={e["edit-id"] for e in edits},
|
|
645
|
+
ok=("ok" in resp),
|
|
646
|
+
)
|
|
647
|
+
else:
|
|
648
|
+
raise NotImplementedError(
|
|
649
|
+
f"Unable to detect response format; keys={list(resp.keys())}"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
class PatchError(Exception):
|
|
654
|
+
pass
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@dataclass
|
|
658
|
+
class PatchResult:
|
|
659
|
+
"""Result of a Patch operation"""
|
|
660
|
+
|
|
661
|
+
patch_id: str
|
|
662
|
+
edits: Dict[str, Dict]
|
|
663
|
+
ok: bool
|
|
664
|
+
|
|
665
|
+
def raise_if_error(self):
|
|
666
|
+
if not self.ok:
|
|
667
|
+
raise PatchError(self)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class DryRunResult:
|
|
671
|
+
"""
|
|
672
|
+
result.dry_run # => DryRunType.CLI
|
|
673
|
+
result.changes # => {"local-node": "..."}
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
class DryRunType(Enum):
|
|
677
|
+
CLI = "cli"
|
|
678
|
+
XML = "xml"
|
|
679
|
+
NATIVE = "NATIVE"
|
|
680
|
+
|
|
681
|
+
dry_run: DryRunType
|
|
682
|
+
changes: Optional[Dict[str, str]] # "local-node" -> "data"
|
|
683
|
+
|
|
684
|
+
def __init__(self, response: Mapping):
|
|
685
|
+
"""Construct a response from a RestConf response"""
|
|
686
|
+
response = response["dry-run-result"]
|
|
687
|
+
changes = {}
|
|
688
|
+
if "cli" in response:
|
|
689
|
+
self.dry_run = self.DryRunType.CLI
|
|
690
|
+
changes = response["cli"]
|
|
691
|
+
elif "result-xml" in response:
|
|
692
|
+
self.dry_run = self.DryRunType.XML
|
|
693
|
+
changes = response["result-xml"]
|
|
694
|
+
elif "native" in response:
|
|
695
|
+
self.dry_run = self.DryRunType.CLI
|
|
696
|
+
changes = response["native"]
|
|
697
|
+
else:
|
|
698
|
+
raise NotImplementedError(
|
|
699
|
+
f"Not sure how to interpret dry-run with keys {list(response.keys())}"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# Simplify the changes structure
|
|
703
|
+
self.changes = {k: v["data"] for k, v in changes.items()}
|
|
704
|
+
|
|
705
|
+
def __str__(self) -> str:
|
|
706
|
+
if self.changes == {}:
|
|
707
|
+
return ""
|
|
708
|
+
# elif tuple(self.changes.keys()) == ("local-node",):
|
|
709
|
+
# return self.changes["local-node"]
|
|
710
|
+
else:
|
|
711
|
+
return "\n".join([f"{node}: {data}" for node, data in self.changes.items()])
|
|
712
|
+
|
|
713
|
+
@classmethod
|
|
714
|
+
def is_dry_run(cls, response) -> bool:
|
|
715
|
+
return "dry-run-result" in response
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# Response errors
|
|
719
|
+
class RestConfError(Exception):
|
|
720
|
+
"""General RestConf Error
|
|
721
|
+
|
|
722
|
+
error_type, error_tag, and error_message fields will be parsed
|
|
723
|
+
if at all possible
|
|
724
|
+
|
|
725
|
+
Protocol Reference:
|
|
726
|
+
- https://github.com/YangModels/yang/blob/main/standard/ietf/RFC/ietf-restconf%402017-01-26.yang#L126-L203
|
|
727
|
+
"""
|
|
728
|
+
|
|
729
|
+
error_type: str = None
|
|
730
|
+
error_tag: str = None
|
|
731
|
+
error_message: str = None
|
|
732
|
+
response: httpx.Response
|
|
733
|
+
|
|
734
|
+
def __init__(self, response: httpx.Response):
|
|
735
|
+
super().__init__()
|
|
736
|
+
logger = structlog.get_logger().bind(
|
|
737
|
+
response_text=response.text,
|
|
738
|
+
response_code=response.status_code,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
self.response = response
|
|
742
|
+
try:
|
|
743
|
+
response_json: dict[str, any] = response.json()
|
|
744
|
+
|
|
745
|
+
if yp_err := response_json.get("ietf-yang-patch:yang-patch-status", None):
|
|
746
|
+
# YangPatch error
|
|
747
|
+
edits = yp_err["edit-status"]["edit"]
|
|
748
|
+
err = edits[0]["errors"]["error"][0]
|
|
749
|
+
self.error_type = err["error-tag"]
|
|
750
|
+
self.error_message = err["error-message"]
|
|
751
|
+
|
|
752
|
+
elif rc_err := response_json.get("ietf-restconf:errors", None):
|
|
753
|
+
# General restconf error
|
|
754
|
+
err = rc_err["error"][0]
|
|
755
|
+
self.error_type = err["error-type"]
|
|
756
|
+
self.tag = err["error-tag"]
|
|
757
|
+
self.error_message = err.get("error-message", None)
|
|
758
|
+
|
|
759
|
+
else:
|
|
760
|
+
# Other un-recognized error
|
|
761
|
+
logger.error("Could not interpret error from NSO")
|
|
762
|
+
self.error_type = "unknown-error"
|
|
763
|
+
self.error_message = response.text
|
|
764
|
+
|
|
765
|
+
except (KeyError, IndexError, json.JSONDecodeError) as e:
|
|
766
|
+
# Likely caused by the response error not being an error
|
|
767
|
+
logger.exception(
|
|
768
|
+
"Problem interpreting RestConfError",
|
|
769
|
+
exception=e,
|
|
770
|
+
)
|
|
771
|
+
self.error_type = "unknown-error"
|
|
772
|
+
self.error_message = response.text
|
|
773
|
+
|
|
774
|
+
def __str__(self) -> str:
|
|
775
|
+
# Note, we may want to consider including these in the future
|
|
776
|
+
# self.response.url
|
|
777
|
+
# self.response.request.body
|
|
778
|
+
return f"{self.error_type}: {self.error_message}"
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
class NotFoundError(RestConfError):
|
|
782
|
+
"""Path syntax is valid, but no object present at path (404)"""
|
|
783
|
+
|
|
784
|
+
pass
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
class AccessDeniedError(RestConfError):
|
|
788
|
+
"""Authentication information is invalid or not authorized to access resource (401)"""
|
|
789
|
+
|
|
790
|
+
pass
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
class YangPatchError(RestConfError):
|
|
794
|
+
"""YangPatch failed (400)"""
|
|
795
|
+
|
|
796
|
+
pass
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
class BadRequestError(RestConfError):
|
|
800
|
+
"""General error with a request (400)"""
|
src/nso_client/py.typed
ADDED
|
File without changes
|