Skip to content

Commit 2081b5a

Browse files
author
Fred Ross
committed
Merge pull request #39 from splunk/fross-feature/ace_issues
Got everything working with Ace.
2 parents e63d9a8 + 2ddae26 commit 2081b5a

File tree

11 files changed

+215
-54
lines changed

11 files changed

+215
-54
lines changed

splunklib/binding.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,7 @@ def _abspath(self, path_segment,
696696
c._abspath('/a/b c/d') == '/a/b%20c/d'
697697
c._abspath('apps/local/search') == \
698698
'/servicesNS/boris/search/apps/local/search'
699-
c._abspath('apps/local/search', sharing='systmem') == \
699+
c._abspath('apps/local/search', sharing='system') == \
700700
'/servicesNS/nobody/system/apps/local/search'
701701
url = c.authority + c._abspath('apps/local/sharing')
702702
"""
@@ -722,8 +722,9 @@ def _abspath(self, path_segment,
722722

723723
oname = "-" if ns.owner is None else ns.owner
724724
aname = "-" if ns.app is None else ns.app
725-
return UrlEncoded("/servicesNS/%s/%s/%s" % (oname, aname, path_segment),
725+
path = UrlEncoded("/servicesNS/%s/%s/%s" % (oname, aname, path_segment),
726726
skip_encode=skip_encode)
727+
return path
727728

728729
def connect(**kwargs):
729730
"""Return an authenticated ``Context`` object.

splunklib/client.py

Lines changed: 140 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,41 @@
110110
class IncomparableException(Exception):
111111
pass
112112

113+
class JobNotReadyException(Exception):
114+
pass
115+
116+
class AmbiguousReferenceException(ValueError):
117+
pass
118+
119+
def trailing(template, *targets):
120+
"""Substring of *template* following all *targets*.
121+
122+
Most easily explained by example::
123+
124+
template = "this is a test of the bunnies."
125+
trailing(template, "is", "est", "the") == \
126+
" bunnies"
127+
128+
Each target is matched successively in the string, and the string
129+
remaining after the last target is returned. If one of the targets
130+
fails to match, a ValueError is raised.
131+
132+
:param template: Template to extract a trailing string from.
133+
:type template: string
134+
:param targets: Strings to successively match in *template*.
135+
:type targets: strings
136+
:returns: Trailing string after all targets are matched.
137+
:rtype: string
138+
:raises ValueError: when one of the targets does not match.
139+
"""
140+
s = template
141+
for t in targets:
142+
n = s.find(t)
143+
if n == -1:
144+
raise ValueError("Target " + t + " not found in template.")
145+
s = s[n + len(t):]
146+
return s
147+
113148
# Filter the given state content record according to the given arg list.
114149
def _filter_content(content, *args):
115150
if len(args) > 0:
@@ -458,7 +493,8 @@ def get(self, path_segment="", owner=None, app=None, sharing=None, **query):
458493
if path_segment.startswith('/'):
459494
path = path_segment
460495
else:
461-
path = self.path + path_segment
496+
path = self.service._abspath(self.path + path_segment, owner=owner,
497+
app=app, sharing=sharing)
462498
# ^-- This was "%s%s" % (self.path, path_segment).
463499
# That doesn't work, because self.path may be UrlEncoded.
464500
return self.service.get(path,
@@ -513,7 +549,8 @@ def post(self, path_segment="", owner=None, app=None, sharing=None, **query):
513549
if path_segment.startswith('/'):
514550
path = path_segment
515551
else:
516-
path = self.path + path_segment
552+
path = self.service._abspath(self.path + path_segment, owner=owner,
553+
app=app, sharing=sharing)
517554
return self.service.post(path,
518555
owner=owner, app=app, sharing=sharing,
519556
**query)
@@ -584,7 +621,8 @@ class Entity(Endpoint):
584621
def __init__(self, service, path, **kwargs):
585622
Endpoint.__init__(self, service, path)
586623
self._state = None
587-
self.refresh(kwargs.get('state', None)) # "Prefresh"
624+
if not kwargs.get('skip_refresh', False):
625+
self.refresh(kwargs.get('state', None)) # "Prefresh"
588626

589627
def __eq__(self, other):
590628
"""Raises IncomparableException.
@@ -844,7 +882,6 @@ def __init__(self, service, path, item=Entity):
844882
self.item = item # Item accessor
845883
self.null_count = -1
846884

847-
848885
def __contains__(self, name):
849886
"""Is there at least one entry called *name* in this collection?
850887
@@ -856,6 +893,8 @@ def __contains__(self, name):
856893
return True
857894
except KeyError:
858895
return False
896+
except AmbiguousReferenceException:
897+
return True
859898

860899
def __getitem__(self, key):
861900
"""Fetch an item named *key* from this collection.
@@ -915,7 +954,9 @@ def __getitem__(self, key):
915954
response = self.get(key, owner=ns.owner, app=ns.app)
916955
entries = self._load_list(response)
917956
if len(entries) > 1:
918-
raise ValueError("Found multiple entities named '%s'; please specify a namespace." % key)
957+
raise AmbiguousReferenceException("Found multiple entities named '%s'; please specify a namespace." % key)
958+
elif len(entries) == 0:
959+
raise KeyError(key)
919960
else:
920961
return entries[0]
921962
except HTTPError as he:
@@ -968,15 +1009,24 @@ def _entity_path(self, state):
9681009
"""Calculate the path to an entity to be returned.
9691010
9701011
*state* should be the dictionary returned by
971-
:func:`_parse_atom_entry`.
1012+
:func:`_parse_atom_entry`. :func:`_entity_path` extracts the
1013+
link to this entity from *state*, and strips all the namespace
1014+
prefixes from it to leave only the relative path of the entity
1015+
itself, sans namespace.
9721016
9731017
:rtype: string
9741018
:returns: an absolute path
9751019
"""
9761020
# This has been factored out so that it can be easily
9771021
# overloaded by Configurations, which has to switch its
9781022
# entities' endpoints from its own properties/ to configs/.
979-
return urllib.unquote(state.links.alternate)
1023+
raw_path = urllib.unquote(state.links.alternate)
1024+
if 'servicesNS/' in raw_path:
1025+
return trailing(raw_path, 'servicesNS/', '/', '/')
1026+
elif 'services/' in raw_path:
1027+
return trailing(raw_path, 'services/')
1028+
else:
1029+
return raw_path
9801030

9811031
def _load_list(self, response):
9821032
"""Converts *response* to a list of entities.
@@ -1070,7 +1120,7 @@ def create(self, name, **params):
10701120
state = _parse_atom_entry(entry)
10711121
entity = self.item(
10721122
self.service,
1073-
urllib.unquote(state.links.alternate),
1123+
self._entity_path(state),
10741124
state=state)
10751125
return entity
10761126

@@ -1268,7 +1318,7 @@ def _entity_path(self, state):
12681318
# Overridden to make all the ConfigurationFile objects
12691319
# returned refer to the configs/ path instead of the
12701320
# properties/ path used by Configrations.
1271-
return self.service._abspath(PATH_CONF % state['title'])
1321+
return PATH_CONF % state['title']
12721322

12731323

12741324
class Stanza(Entity):
@@ -1353,6 +1403,20 @@ def clean(self, timeout=60):
13531403
raise OperationError, "Operation timed out."
13541404
return self
13551405

1406+
def disable(self):
1407+
"""Disables this index."""
1408+
# Starting in Ace, we have to do this with specific sharing,
1409+
# unlike most other entities.
1410+
self.post("disable", sharing="system")
1411+
return self
1412+
1413+
def enable(self):
1414+
"""Enables this index."""
1415+
# Starting in Ace, we have to reenable this with a specific
1416+
# sharing unlike most other entities.
1417+
self.post("enable", sharing="system")
1418+
return self
1419+
13561420
def roll_hot_buckets(self):
13571421
"""Performs rolling hot buckets for this index."""
13581422
self.post("roll-hot-buckets")
@@ -1451,6 +1515,15 @@ def __getitem__(self, key):
14511515
else:
14521516
raise KeyError(key)
14531517

1518+
def __contains__(self, key):
1519+
try:
1520+
self.__getitem__(key)
1521+
return True
1522+
except KeyError:
1523+
return False
1524+
except AmbiguousReferenceException:
1525+
return True
1526+
14541527

14551528
def create(self, kind, name, **kwargs):
14561529
"""Creates an input of a specific kind in this collection, with any
@@ -1549,7 +1622,8 @@ def oneshot(self, **kwargs):
15491622
class Job(Entity):
15501623
"""This class represents a search job."""
15511624
def __init__(self, service, path, **kwargs):
1552-
Entity.__init__(self, service, path, **kwargs)
1625+
Entity.__init__(self, service, path, skip_refresh=True, **kwargs)
1626+
self._isReady = False
15531627

15541628
# The Job entry record is returned at the root of the response
15551629
def _load_atom_entry(self, response):
@@ -1581,6 +1655,28 @@ def finalize(self):
15811655
self.post("control", action="finalize")
15821656
return self
15831657

1658+
def isDone(self):
1659+
"""Has this job finished running on the server yet?
1660+
1661+
:returns: boolean
1662+
"""
1663+
if (not self.isReady()):
1664+
return False
1665+
return self['isDone'] == '1'
1666+
1667+
def isReady(self):
1668+
"""Is this job queryable on the server yet?
1669+
1670+
:returns: boolean
1671+
"""
1672+
try:
1673+
self.refresh()
1674+
self._isReady = True
1675+
return self._isReady
1676+
except JobNotReadyException:
1677+
self._isReady = False
1678+
return False
1679+
15841680
@property
15851681
def name(self):
15861682
"""Returns the name of the search job."""
@@ -1591,20 +1687,35 @@ def pause(self):
15911687
self.post("control", action="pause")
15921688
return self
15931689

1594-
def read(self):
1595-
"""Returns the job's current state record, corresponding to the
1596-
current state of the server-side resource."""
1597-
# If the search job is newly created, it is possible that we will
1598-
# get 204s (No Content) until the job is ready to respond.
1599-
count = 0
1600-
while count < 10:
1690+
def refresh(self, state=None):
1691+
"""Refresh the state of this entity.
1692+
1693+
If *state* is provided, load it as the new state for this
1694+
entity. Otherwise, make a roundtrip to the server (by calling
1695+
the :meth:`read` method of self) to fetch an updated state,
1696+
plus at most two additional round trips if autologin is
1697+
enabled.
1698+
1699+
**Example**::
1700+
1701+
import splunklib.client as client
1702+
s = client.connect(...)
1703+
search = s.jobs.create('search index=_internal | head 1')
1704+
search.refresh()
1705+
"""
1706+
if state is not None:
1707+
self._state = state
1708+
else:
16011709
response = self.get()
1602-
if response.status == 204:
1603-
sleep(1)
1604-
count += 1
1605-
continue
1606-
return self._load_state(response)
1607-
raise OperationError, "Operation timed out."
1710+
if response.status == 204:
1711+
self._isReady = False
1712+
raise JobNotReadyException()
1713+
else:
1714+
self._isReady = True
1715+
raw_state = self._load_state(response)
1716+
raw_state['links'] = dict([(k, urllib.unquote(v)) for k,v in raw_state['links'].iteritems()])
1717+
self._state = raw_state
1718+
return self
16081719

16091720
def results(self, timeout=None, wait_time=1, **query_params):
16101721
"""Fetch search results as an InputStream IO handle.
@@ -1802,7 +1913,7 @@ def export(self, query, **params):
18021913
to two for create followed by preview), plus at most two more
18031914
if autologin is turned on.
18041915
1805-
:raises SyntaxError: on invalid queries.
1916+
:raises ValueError: on invalid queries.
18061917
18071918
:param query: Splunk search language query to run
18081919
:type query: ``str``
@@ -1814,8 +1925,8 @@ def export(self, query, **params):
18141925
try:
18151926
return self.post(path_segment="export", search=query, **params).body
18161927
except HTTPError as he:
1817-
if he.status == 400 and 'Search operation' in str(he):
1818-
raise SyntaxError(str(he))
1928+
if he.status == 400:
1929+
raise ValueError(str(he))
18191930
else:
18201931
raise
18211932

@@ -1841,7 +1952,7 @@ def oneshot(self, query, **params):
18411952
to two for create followed by results), plus at most two more
18421953
if autologin is turned on.
18431954
1844-
:raises SyntaxError: on invalid queries.
1955+
:raises ValueError: on invalid queries.
18451956
18461957
:param query: Splunk search language query to run
18471958
:type query: ``str``
@@ -1853,8 +1964,8 @@ def oneshot(self, query, **params):
18531964
try:
18541965
return self.post(search=query, exec_mode="oneshot", **params).body
18551966
except HTTPError as he:
1856-
if he.status == 400 and 'Search operation' in str(he):
1857-
raise SyntaxError(str(he))
1967+
if he.status == 400:
1968+
raise ValueError(str(he))
18581969
else:
18591970
raise
18601971

tests/test_app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,19 @@ def check_app(self, app):
2828
def test_read(self):
2929
service = client.connect(**self.opts.kwargs)
3030

31+
disabled_app = 'sdk-test-app-disabled'
32+
if (disabled_app in service.apps):
33+
service.apps.delete(disabled_app)
34+
disabled = service.apps.create(disabled_app)
35+
disabled.disable()
36+
3137
for app in service.apps:
3238
self.check_app(app)
3339
app.refresh()
3440
self.check_app(app)
3541

42+
service.apps.delete(disabled_app)
43+
3644
def test_crud(self):
3745
service = client.connect(**self.opts.kwargs)
3846

tests/test_collection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def check_iterable(self, collection, count):
7777
seen = 0
7878
for item in collection:
7979
seen += 1
80-
print item.name
80+
item.name
8181
self.assertEqual(seen, count)
8282

8383
def test_apps(self):

tests/test_event_type.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,15 @@ def test_crud(self):
5656
kwargs['priority'] = 3
5757
event_type.update(**kwargs)
5858
event_type.refresh()
59+
testlib.restart(service)
5960
self.check_content(event_type, **kwargs)
6061

6162
event_type.enable()
6263
event_type.refresh()
6364
self.check_content(event_type, disabled=0)
6465

6566
event_types.delete('sdk-test')
66-
self.assertFalse('sdk-teset' in event_types)
67+
self.assertFalse('sdk-test' in event_types)
6768

6869
if __name__ == "__main__":
6970
testlib.main()

0 commit comments

Comments
 (0)