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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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)"""
File without changes