Skip to content

Commit fd977be

Browse files
author
Itay Neeman
committed
Add KV Store support to the Python SDK
This change adds KV Store support to the Python SDK, which includes two main pieces: 1. A couple of small fixes in binding.py, specifically around allowing us to make POST requests which are not encoded with application/x-www-form-urlencoded. 2. A set of classes in client.py to make it easy to access the KV Store API, e.g. for accessing configuration, data, etc. Finally, it also adds tests to verify these fixes, and an example to show how to use it.
1 parent f92d8ef commit fd977be

File tree

9 files changed

+587
-2
lines changed

9 files changed

+587
-2
lines changed

docs/client.rst

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ splunklib.client
6868
:members: create, export, itemmeta, oneshot
6969
:inherited-members:
7070

71+
.. autoclass:: KVStoreCollection
72+
:members: data, update_index, update_field
73+
:inherited-members:
74+
75+
.. autoclass:: KVStoreCollectionData
76+
:members: query, query_by_id, insert, delete, delete_by_id, update, batch_save
77+
:inherited-members:
78+
79+
.. autoclass:: KVStoreCollections
80+
:members: create
81+
:inherited-members:
82+
7183
.. autoclass:: Loggers
7284
:members: itemmeta
7385
:inherited-members:
@@ -110,7 +122,7 @@ splunklib.client
110122
:inherited-members:
111123

112124
.. autoclass:: Service
113-
:members: apps, confs, capabilities, event_types, fired_alerts, indexes, info, inputs, job, jobs, loggers, messages, modular_input_kinds, parse, restart, restart_required, roles, search, saved_searches, settings, splunk_version, storage_passwords, users
125+
:members: apps, confs, capabilities, event_types, fired_alerts, indexes, info, inputs, job, jobs, kvstore, loggers, messages, modular_input_kinds, parse, restart, restart_required, roles, search, saved_searches, settings, splunk_version, storage_passwords, users
114126
:inherited-members:
115127

116128
.. autoclass:: Settings

docs/index.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ For more information, see the `Splunk Developer Portal <http://dev.splunk.com/vi
6969

7070
:class:`~splunklib.client.Jobs` class
7171

72+
:class:`~splunklib.client.KVStoreCollection` class
73+
74+
:class:`~splunklib.client.KVStoreCollectionData` class
75+
76+
:class:`~splunklib.client.KVStoreCollections` class
77+
7278
:class:`~splunklib.client.Loggers` class
7379

7480
:class:`~splunklib.client.Message` class

examples/kvstore.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env python
2+
#
3+
# Copyright 2011-2015 Splunk, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"): you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
"""A command line utility for interacting with Splunk KV Store Collections."""
18+
19+
import sys, os, json
20+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
21+
22+
from splunklib.client import connect
23+
24+
try:
25+
from utils import parse
26+
except ImportError:
27+
raise Exception("Add the SDK repository to your PYTHONPATH to run the examples "
28+
"(e.g., export PYTHONPATH=~/splunk-sdk-python.")
29+
30+
def main():
31+
opts = parse(sys.argv[1:], {}, ".splunkrc")
32+
opts.kwargs["owner"] = "nobody"
33+
opts.kwargs["app"] = "search"
34+
service = connect(**opts.kwargs)
35+
36+
print "KV Store Collections:"
37+
for collection in service.kvstore:
38+
print " %s" % collection.name
39+
40+
# Let's delete a collection if it already exists, and then create it
41+
collection_name = "example_collection"
42+
if collection_name in service.kvstore:
43+
service.kvstore.delete(collection_name)
44+
45+
# Let's create it and then make sure it exists
46+
service.kvstore.create(collection_name)
47+
collection = service.kvstore[collection_name]
48+
49+
# Let's make sure it doesn't have any data
50+
print "Should be empty: %s" % json.dumps(collection.data.query())
51+
52+
# Let's add some data
53+
collection.data.insert(json.dumps({"_key": "item1", "somekey": 1, "otherkey": "foo"}))
54+
collection.data.insert(json.dumps({"_key": "item2", "somekey": 2, "otherkey": "foo"}))
55+
collection.data.insert(json.dumps({"somekey": 3, "otherkey": "bar"}))
56+
57+
# Let's make sure it has the data we just entered
58+
print "Should have our data: %s" % json.dumps(collection.data.query(), indent=1)
59+
60+
# Let's run some queries
61+
print "Should return item1: %s" % json.dumps(collection.data.query_by_id("item1"), indent=1)
62+
63+
query = json.dumps({"otherkey": "foo"})
64+
print "Should return item1 and item2: %s" % json.dumps(collection.data.query(query=query), indent=1)
65+
66+
query = json.dumps({"otherkey": "bar"})
67+
print "Should return third item with auto-generated _key: %s" % json.dumps(collection.data.query(query=query), indent=1)
68+
69+
# Let's delete the collection
70+
collection.delete()
71+
72+
if __name__ == "__main__":
73+
main()
74+
75+

splunklib/binding.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1168,10 +1168,16 @@ def post(self, url, headers=None, **kwargs):
11681168
:rtype: ``dict``
11691169
"""
11701170
if headers is None: headers = []
1171-
headers.append(("Content-Type", "application/x-www-form-urlencoded")),
1171+
11721172
# We handle GET-style arguments and an unstructured body. This is here
11731173
# to support the receivers/stream endpoint.
11741174
if 'body' in kwargs:
1175+
# We only use application/x-www-form-urlencoded if there is no other
1176+
# Content-Type header present. This can happen in cases where we
1177+
# send requests as application/json, e.g. for KV Store.
1178+
if len(filter(lambda x: x[0].lower() == "content-type", headers)) == 0:
1179+
headers.append(("Content-Type", "application/x-www-form-urlencoded"))
1180+
11751181
body = kwargs.pop('body')
11761182
if len(kwargs) > 0:
11771183
url = url + UrlEncoded('?' + _encode(**kwargs), skip_encode=True)

splunklib/client.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,14 @@ def splunk_version(self):
653653
self._splunk_version = tuple([int(p) for p in self.info['version'].split('.')])
654654
return self._splunk_version
655655

656+
@property
657+
def kvstore(self):
658+
"""Returns the collection of KV Store collections.
659+
660+
:return: A :class:`KVStoreCollections` collection of :class:`KVStoreCollection` entities.
661+
"""
662+
return KVStoreCollections(self)
663+
656664
@property
657665
def users(self):
658666
"""Returns the collection of users.
@@ -3518,3 +3526,194 @@ def package(self):
35183526
def updateInfo(self):
35193527
"""Returns any update information that is available for the app."""
35203528
return self._run_action("update")
3529+
3530+
class KVStoreCollections(Collection):
3531+
def __init__(self, service):
3532+
Collection.__init__(self, service, 'storage/collections/config', item=KVStoreCollection)
3533+
3534+
def create(self, name, indexes = {}, fields = {}, **kwargs):
3535+
"""Creates a KV Store Collection.
3536+
3537+
:param name: name of collection to create
3538+
:type name: ``string``
3539+
:param indexes: dictionary of index definitions
3540+
:type indexes: ``dict``
3541+
:param fields: dictionary of field definitions
3542+
:type fields: ``dict``
3543+
:param kwargs: a dictionary of additional parameters specifying indexes and field definitions
3544+
:type kwargs: ``dict``
3545+
3546+
:return: Result of POST request
3547+
"""
3548+
for k, v in indexes.iteritems():
3549+
if isinstance(v, dict):
3550+
v = json.dumps(v)
3551+
kwargs['index.' + k] = v
3552+
for k, v in fields.iteritems():
3553+
kwargs['field.' + k] = v
3554+
return self.post(name=name, **kwargs)
3555+
3556+
class KVStoreCollection(Entity):
3557+
@property
3558+
def data(self):
3559+
"""Returns data object for this Collection.
3560+
3561+
:rtype: :class:`KVStoreData`
3562+
"""
3563+
return KVStoreCollectionData(self)
3564+
3565+
def update_index(self, name, value):
3566+
"""Changes the definition of a KV Store index.
3567+
3568+
:param name: name of index to change
3569+
:type name: ``string``
3570+
:param value: new index definition
3571+
:type value: ``dict`` or ``string``
3572+
3573+
:return: Result of POST request
3574+
"""
3575+
kwargs = {}
3576+
kwargs['index.' + name] = value if isinstance(value, basestring) else json.dumps(value)
3577+
return self.post(**kwargs)
3578+
3579+
def update_field(self, name, value):
3580+
"""Changes the definition of a KV Store field.
3581+
3582+
:param name: name of field to change
3583+
:type name: ``string``
3584+
:param value: new field definition
3585+
:type value: ``string``
3586+
3587+
:return: Result of POST request
3588+
"""
3589+
kwargs = {}
3590+
kwargs['field.' + name] = value
3591+
return self.post(**kwargs)
3592+
3593+
class KVStoreCollectionData(object):
3594+
"""This class represents the data endpoint for a KVStoreCollection.
3595+
3596+
Retrieve using :meth:`KVStoreCollection.data`
3597+
"""
3598+
JSON_HEADER = [('Content-Type', 'application/json')]
3599+
3600+
def __init__(self, collection):
3601+
self.service = collection.service
3602+
self.collection = collection
3603+
self.owner, self.app, self.sharing = collection._proper_namespace()
3604+
self.path = 'storage/collections/data/' + UrlEncoded(self.collection.name) + '/'
3605+
3606+
def _get(self, url, **kwargs):
3607+
return self.service.get(self.path + url, owner=self.owner, app=self.app, sharing=self.sharing, **kwargs)
3608+
3609+
def _post(self, url, **kwargs):
3610+
return self.service.post(self.path + url, owner=self.owner, app=self.app, sharing=self.sharing, **kwargs)
3611+
3612+
def _delete(self, url, **kwargs):
3613+
return self.service.delete(self.path + url, owner=self.owner, app=self.app, sharing=self.sharing, **kwargs)
3614+
3615+
def query(self, **query):
3616+
"""
3617+
Gets the results of query, with optional parameters sort, limit, skip, and fields.
3618+
3619+
:param query: Optional parameters. Valid options are sort, limit, skip, and fields
3620+
:type query: ``dict``
3621+
3622+
:return: Array of documents retrieved by query.
3623+
:rtype: ``array``
3624+
"""
3625+
return json.loads(self._get('', **query).body.read())
3626+
3627+
def query_by_id(self, id):
3628+
"""
3629+
Returns object with _id = id.
3630+
3631+
:param id: Value for ID. If not a string will be coerced to string.
3632+
:type id: ``string``
3633+
3634+
:return: Document with id
3635+
:rtype: ``dict``
3636+
"""
3637+
return json.loads(self._get(UrlEncoded(str(id))).body.read())
3638+
3639+
def insert(self, data):
3640+
"""
3641+
Inserts item into this collection. An _id field will be generated if not assigned in the data.
3642+
3643+
:param data: Document to insert
3644+
:type data: ``string``
3645+
3646+
:return: _id of inserted object
3647+
:rtype: ``dict``
3648+
"""
3649+
return json.loads(self._post('', headers=KVStoreCollectionData.JSON_HEADER, body=data).body.read())
3650+
3651+
def delete(self, query=None):
3652+
"""
3653+
Deletes all data in collection if query is absent. Otherwise, deletes all data matched by query.
3654+
3655+
:param query: Query to select documents to delete
3656+
:type query: ``string``
3657+
3658+
:return: Result of DELETE request
3659+
"""
3660+
return self._delete('', **({'query': query}) if query else {})
3661+
3662+
def delete_by_id(self, id):
3663+
"""
3664+
Deletes document that has _id = id.
3665+
3666+
:param id: id of document to delete
3667+
:type id: ``string``
3668+
3669+
:return: Result of DELETE request
3670+
"""
3671+
return self._delete(UrlEncoded(str(id)))
3672+
3673+
def update(self, id, data):
3674+
"""
3675+
Replaces document with _id = id with data.
3676+
3677+
:param id: _id of document to update
3678+
:type id: ``string``
3679+
:param data: the new document to insert
3680+
:type data: ``string``
3681+
3682+
:return: id of replaced document
3683+
:rtype: ``dict``
3684+
"""
3685+
return json.loads(self._post(UrlEncoded(str(id)), headers=KVStoreCollectionData.JSON_HEADER, body=data).body.read())
3686+
3687+
def batch_find(self, *dbqueries):
3688+
"""
3689+
Returns array of results from queries dbqueries.
3690+
3691+
:param dbqueries: Array of individual queries as dictionaries
3692+
:type dbqueries: ``array`` of ``dict``
3693+
3694+
:return: Results of each query
3695+
:rtype: ``array`` of ``array``
3696+
"""
3697+
if len(dbqueries) < 1:
3698+
raise Exception('Must have at least one query.')
3699+
3700+
data = json.dumps(dbqueries)
3701+
3702+
return json.loads(self._post('batch_find', headers=KVStoreCollectionData.JSON_HEADER, body=data).body.read())
3703+
3704+
def batch_save(self, *documents):
3705+
"""
3706+
Inserts or updates every document specified in documents.
3707+
3708+
:param documents: Array of documents to save as dictionaries
3709+
:type documents: ``array`` of ``dict``
3710+
3711+
:return: Results of update operation as overall stats
3712+
:rtype: ``dict``
3713+
"""
3714+
if len(documents) < 1:
3715+
raise Exception('Must have at least one document.')
3716+
3717+
data = json.dumps(documents)
3718+
3719+
return json.loads(self._post('batch_save', headers=KVStoreCollectionData.JSON_HEADER, body=data).body.read())

tests/test_examples.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ def test_job(self):
182182
"job.py",
183183
"job.py list",
184184
"job.py list @0")
185+
186+
def test_kvstore(self):
187+
self.check_commands(
188+
"kvstore.py --help",
189+
"kvstore.py")
185190

186191
def test_loggers(self):
187192
self.check_commands(

0 commit comments

Comments
 (0)