Skip to content

Commit 9a791d4

Browse files
committed
Copy XRClient as XEClient
1 parent ac971e1 commit 9a791d4

File tree

3 files changed

+348
-3
lines changed

3 files changed

+348
-3
lines changed

src/cisco_gnmi/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .client import Client
2828
from .xr import XRClient
2929
from .nx import NXClient
30+
from .xe import XEClient
3031
from .builder import ClientBuilder
3132

32-
__version__ = "1.0.1"
33+
__version__ = "1.0.2"

src/cisco_gnmi/builder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import logging
2727

2828
import grpc
29-
from . import Client, XRClient, NXClient
29+
from . import Client, XRClient, NXClient, XEClient
3030
from .auth import CiscoAuthPlugin
3131
from .util import gen_target_netloc, get_cert_from_target, get_cn_from_cert
3232

@@ -74,7 +74,7 @@ class ClientBuilder(object):
7474
>>> print(capabilities)
7575
"""
7676

77-
os_class_map = {None: Client, "IOS XR": XRClient, "NX-OS": NXClient}
77+
os_class_map = {None: Client, "IOS XR": XRClient, "NX-OS": NXClient, "IOS-XE": XEClient}
7878

7979
def __init__(self, target):
8080
"""Initializes the builder, most initialization is done via set_* methods.

src/cisco_gnmi/xe.py

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
"""Copyright 2019 Cisco Systems
2+
All rights reserved.
3+
4+
Redistribution and use in source and binary forms, with or without
5+
modification, are permitted provided that the following conditions are
6+
met:
7+
8+
* Redistributions of source code must retain the above copyright
9+
notice, this list of conditions and the following disclaimer.
10+
11+
The contents of this file are licensed under the Apache License, Version 2.0
12+
(the "License"); you may not use this file except in compliance with the
13+
License. You may obtain a copy of the License at
14+
15+
http://www.apache.org/licenses/LICENSE-2.0
16+
17+
Unless required by applicable law or agreed to in writing, software
18+
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
19+
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
20+
License for the specific language governing permissions and limitations under
21+
the License.
22+
"""
23+
24+
"""Wrapper for IOS XE to simplify usage of gNMI implementation."""
25+
26+
import json
27+
import logging
28+
29+
from six import string_types
30+
from .client import Client, proto, util
31+
32+
33+
class XEClient(Client):
34+
"""IOS XE-specific wrapper for gNMI functionality.
35+
36+
Returns direct responses from base Client methods.
37+
38+
Methods
39+
-------
40+
delete_xpaths(...)
41+
Convenience wrapper for set() which constructs Paths from XPaths for deletion.
42+
get_xpaths(...)
43+
Convenience wrapper for get() which helps construct get requests for specified xpaths.
44+
set_json(...)
45+
Convenience wrapper for set() which assumes model-based JSON payloads.
46+
subscribe_xpaths(...)
47+
Convenience wrapper for subscribe() which helps construct subscriptions for specified xpaths.
48+
49+
Examples
50+
--------
51+
>>> from cisco_gnmi import ClientBuilder
52+
>>> client = ClientBuilder('127.0.0.1:9339').set_os(
53+
... 'IOS XE'
54+
... ).set_secure_from_file(
55+
... 'client.crt',
56+
... 'client.key',
57+
... 'rootCA.pem'
58+
... ).set_ssl_target_override().set_call_authentication(
59+
... 'admin',
60+
... 'its_a_secret'
61+
... ).construct()
62+
>>> capabilities = client.capabilities()
63+
>>> print(capabilities)
64+
...
65+
>>> get_response = client.get_xpaths('interfaces/interface')
66+
>>> print(get_response)
67+
...
68+
>>> subscribe_response = client.subscribe_xpaths('interfaces/interface')
69+
>>> for message in subscribe_response: print(message)
70+
...
71+
>>> config = '{"Cisco-IOS-XR-shellutil-cfg:host-names": [{"host-name": "gnmi_test"}]}'
72+
>>> set_response = client.set_json(config)
73+
>>> print(set_response)
74+
...
75+
>>> delete_response = client.delete_xpaths('Cisco-IOS-XR-shellutil-cfg:host-names/host-name')
76+
"""
77+
78+
def delete_xpaths(self, xpaths, prefix=None):
79+
"""A convenience wrapper for set() which constructs Paths from supplied xpaths
80+
to be passed to set() as the delete parameter.
81+
82+
Parameters
83+
----------
84+
xpaths : iterable of str
85+
XPaths to specify to be deleted.
86+
If prefix is specified these strings are assumed to be the suffixes.
87+
prefix : str
88+
The XPath prefix to apply to all XPaths for deletion.
89+
90+
Returns
91+
-------
92+
set()
93+
"""
94+
if isinstance(xpaths, string_types):
95+
xpaths = [xpaths]
96+
paths = []
97+
for xpath in xpaths:
98+
if prefix:
99+
if prefix.endswith("/") and xpath.startswith("/"):
100+
xpath = "{prefix}{xpath}".format(
101+
prefix=prefix[:-1], xpath=xpath[1:]
102+
)
103+
elif prefix.endswith("/") or xpath.startswith("/"):
104+
xpath = "{prefix}{xpath}".format(prefix=prefix, xpath=xpath)
105+
else:
106+
xpath = "{prefix}/{xpath}".format(prefix=prefix, xpath=xpath)
107+
paths.append(self.parse_xpath_to_gnmi_path(xpath))
108+
return self.set(deletes=paths)
109+
110+
def set_json(self, update_json_configs=None, replace_json_configs=None, ietf=True):
111+
"""A convenience wrapper for set() which assumes JSON payloads and constructs desired messages.
112+
All parameters are optional, but at least one must be present.
113+
114+
This method expects JSON in the same format as what you might send via the native gRPC interface
115+
with a fully modeled configuration which is then parsed to meet the gNMI implementation.
116+
117+
Parameters
118+
----------
119+
update_json_configs : iterable of JSON configurations, optional
120+
JSON configs to apply as updates.
121+
replace_json_configs : iterable of JSON configurations, optional
122+
JSON configs to apply as replacements.
123+
ietf : bool, optional
124+
Use JSON_IETF vs JSON.
125+
126+
Returns
127+
-------
128+
set()
129+
"""
130+
if not any([update_json_configs, replace_json_configs]):
131+
raise Exception("Must supply at least one set of configurations to method!")
132+
133+
def check_configs(name, configs):
134+
if isinstance(name, string_types):
135+
logging.debug("Handling %s as JSON string.", name)
136+
try:
137+
configs = json.loads(configs)
138+
except:
139+
raise Exception("{name} is invalid JSON!".format(name=name))
140+
configs = [configs]
141+
elif isinstance(name, dict):
142+
logging.debug("Handling %s as already serialized JSON object.", name)
143+
configs = [configs]
144+
elif not isinstance(configs, (list, set)):
145+
raise Exception(
146+
"{name} must be an iterable of configs!".format(name=name)
147+
)
148+
return configs
149+
150+
def create_updates(name, configs):
151+
if not configs:
152+
return None
153+
configs = check_configs(name, configs)
154+
updates = []
155+
for config in configs:
156+
if not isinstance(config, dict):
157+
raise Exception("config must be a JSON object!")
158+
if len(config.keys()) > 1:
159+
raise Exception("config should only target one YANG module!")
160+
top_element = next(iter(config.keys()))
161+
top_element_split = top_element.split(":")
162+
if len(top_element_split) < 2:
163+
raise Exception(
164+
"Top level config element {} should be module prefixed!".format(
165+
top_element
166+
)
167+
)
168+
if len(top_element_split) > 2:
169+
raise Exception(
170+
"Top level config element {} appears malformed!".format(
171+
top_element
172+
)
173+
)
174+
origin = top_element_split[0]
175+
element = top_element_split[1]
176+
config = config.pop(top_element)
177+
update = proto.gnmi_pb2.Update()
178+
update.path.CopyFrom(self.parse_xpath_to_gnmi_path(element, origin))
179+
if ietf:
180+
update.val.json_ietf_val = json.dumps(config).encode("utf-8")
181+
else:
182+
update.val.json_val = json.dumps(config).encode("utf-8")
183+
updates.append(update)
184+
return updates
185+
186+
updates = create_updates("update_json_configs", update_json_configs)
187+
replaces = create_updates("replace_json_configs", replace_json_configs)
188+
return self.set(updates=updates, replaces=replaces)
189+
190+
def get_xpaths(self, xpaths, data_type="ALL", encoding="JSON_IETF"):
191+
"""A convenience wrapper for get() which forms proto.gnmi_pb2.Path from supplied xpaths.
192+
193+
Parameters
194+
----------
195+
xpaths : iterable of str or str
196+
An iterable of XPath strings to request data of
197+
If simply a str, wraps as a list for convenience
198+
data_type : proto.gnmi_pb2.GetRequest.DataType, optional
199+
A direct value or key from the GetRequest.DataType enum
200+
[ALL, CONFIG, STATE, OPERATIONAL]
201+
encoding : proto.gnmi_pb2.GetRequest.Encoding, optional
202+
A direct value or key from the Encoding enum
203+
[JSON, BYTES, PROTO, ASCII, JSON_IETF]
204+
205+
Returns
206+
-------
207+
get()
208+
"""
209+
gnmi_path = None
210+
if isinstance(xpaths, (list, set)):
211+
gnmi_path = map(self.parse_xpath_to_gnmi_path, set(xpaths))
212+
elif isinstance(xpaths, string_types):
213+
gnmi_path = [self.parse_xpath_to_gnmi_path(xpaths)]
214+
else:
215+
raise Exception(
216+
"xpaths must be a single xpath string or iterable of xpath strings!"
217+
)
218+
return self.get(gnmi_path, data_type=data_type, encoding=encoding)
219+
220+
def subscribe_xpaths(
221+
self,
222+
xpath_subscriptions,
223+
request_mode="STREAM",
224+
sub_mode="SAMPLE",
225+
encoding="PROTO",
226+
sample_interval=Client._NS_IN_S * 10,
227+
suppress_redundant=False,
228+
heartbeat_interval=None,
229+
):
230+
"""A convenience wrapper of subscribe() which aids in building of SubscriptionRequest
231+
with request as subscribe SubscriptionList. This method accepts an iterable of simply xpath strings,
232+
dictionaries with Subscription attributes for more granularity, or already built Subscription
233+
objects and builds the SubscriptionList. Fields not supplied will be defaulted with the default arguments
234+
to the method.
235+
236+
Generates a single SubscribeRequest.
237+
238+
Parameters
239+
----------
240+
xpath_subscriptions : str or iterable of str, dict, Subscription
241+
An iterable which is parsed to form the Subscriptions in the SubscriptionList to be passed
242+
to SubscriptionRequest. Strings are parsed as XPaths and defaulted with the default arguments,
243+
dictionaries are treated as dicts of args to pass to the Subscribe init, and Subscription is
244+
treated as simply a pre-made Subscription.
245+
request_mode : proto.gnmi_pb2.SubscriptionList.Mode, optional
246+
Indicates whether STREAM to stream from target,
247+
ONCE to stream once (like a get),
248+
POLL to respond to POLL.
249+
[STREAM, ONCE, POLL]
250+
sub_mode : proto.gnmi_pb2.SubscriptionMode, optional
251+
The default SubscriptionMode on a per Subscription basis in the SubscriptionList.
252+
TARGET_DEFINED indicates that the target (like device/destination) should stream
253+
information however it knows best. This instructs the target to decide between ON_CHANGE
254+
or SAMPLE - e.g. the device gNMI server may understand that we only need RIB updates
255+
as an ON_CHANGE basis as opposed to SAMPLE, and we don't have to explicitly state our
256+
desired behavior.
257+
ON_CHANGE only streams updates when changes occur.
258+
SAMPLE will stream the subscription at a regular cadence/interval.
259+
[TARGET_DEFINED, ON_CHANGE, SAMPLE]
260+
encoding : proto.gnmi_pb2.Encoding, optional
261+
A member of the proto.gnmi_pb2.Encoding enum specifying desired encoding of returned data
262+
[JSON, BYTES, PROTO, ASCII, JSON_IETF]
263+
sample_interval : int, optional
264+
Default nanoseconds for sample to occur.
265+
Defaults to 10 seconds.
266+
suppress_redundant : bool, optional
267+
Indicates whether values that have not changed should be sent in a SAMPLE subscription.
268+
heartbeat_interval : int, optional
269+
Specifies the maximum allowable silent period in nanoseconds when
270+
suppress_redundant is in use. The target should send a value at least once
271+
in the period specified.
272+
273+
Returns
274+
-------
275+
subscribe()
276+
"""
277+
subscription_list = proto.gnmi_pb2.SubscriptionList()
278+
subscription_list.mode = util.validate_proto_enum(
279+
"mode",
280+
request_mode,
281+
"SubscriptionList.Mode",
282+
proto.gnmi_pb2.SubscriptionList.Mode,
283+
)
284+
subscription_list.encoding = util.validate_proto_enum(
285+
"encoding", encoding, "Encoding", proto.gnmi_pb2.Encoding
286+
)
287+
if isinstance(xpath_subscriptions, string_types):
288+
xpath_subscriptions = [xpath_subscriptions]
289+
for xpath_subscription in xpath_subscriptions:
290+
subscription = None
291+
if isinstance(xpath_subscription, string_types):
292+
subscription = proto.gnmi_pb2.Subscription()
293+
subscription.path.CopyFrom(
294+
self.parse_xpath_to_gnmi_path(xpath_subscription)
295+
)
296+
subscription.mode = util.validate_proto_enum(
297+
"sub_mode",
298+
sub_mode,
299+
"SubscriptionMode",
300+
proto.gnmi_pb2.SubscriptionMode,
301+
)
302+
subscription.sample_interval = sample_interval
303+
subscription.suppress_redundant = suppress_redundant
304+
if heartbeat_interval:
305+
subscription.heartbeat_interval = heartbeat_interval
306+
elif isinstance(xpath_subscription, dict):
307+
path = self.parse_xpath_to_gnmi_path(xpath_subscription["path"])
308+
arg_dict = {
309+
"path": path,
310+
"mode": sub_mode,
311+
"sample_interval": sample_interval,
312+
"suppress_redundant": suppress_redundant,
313+
}
314+
if heartbeat_interval:
315+
arg_dict["heartbeat_interval"] = heartbeat_interval
316+
arg_dict.update(xpath_subscription)
317+
if "mode" in arg_dict:
318+
arg_dict["mode"] = util.validate_proto_enum(
319+
"sub_mode",
320+
arg_dict["mode"],
321+
"SubscriptionMode",
322+
proto.gnmi_pb2.SubscriptionMode,
323+
)
324+
subscription = proto.gnmi_pb2.Subscription(**arg_dict)
325+
elif isinstance(xpath_subscription, proto.gnmi_pb2.Subscription):
326+
subscription = xpath_subscription
327+
else:
328+
raise Exception("xpath in list must be xpath or dict/Path!")
329+
subscription_list.subscription.append(subscription)
330+
return self.subscribe([subscription_list])
331+
332+
def parse_xpath_to_gnmi_path(self, xpath, origin=None):
333+
"""No origin specified implies openconfig
334+
Otherwise origin is expected to be the module name
335+
"""
336+
if origin is None:
337+
# naive but effective
338+
if xpath.startswith("openconfig") or ":" not in xpath:
339+
# openconfig
340+
origin = None
341+
else:
342+
# module name
343+
origin = xpath.split(":")[0]
344+
return super(XRClient, self).parse_xpath_to_gnmi_path(xpath, origin)

0 commit comments

Comments
 (0)