Skip to content

Commit dfb7630

Browse files
committed
lockable_resources: add LockedResourceReservation context manager and various ResourceSelectors
1 parent 59ea3b3 commit dfb7630

File tree

2 files changed

+271
-2
lines changed

2 files changed

+271
-2
lines changed

jenkinsapi/lockable_resources.py

Lines changed: 214 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import logging
2-
from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, TypedDict
2+
import time
3+
from typing import (
4+
TYPE_CHECKING,
5+
Dict,
6+
Iterator,
7+
List,
8+
Mapping,
9+
Optional,
10+
TypedDict,
11+
)
12+
13+
from requests import Response
314

415
from jenkinsapi.custom_exceptions import JenkinsAPIException
516
from jenkinsapi.jenkinsbase import JenkinsBase
617
from jenkinsapi.utils.requester import Requester
7-
from requests import Response
818

919
if TYPE_CHECKING:
1020
from jenkinsapi.jenkins import Jenkins
@@ -184,3 +194,205 @@ def reserve(self, name: str) -> None:
184194

185195
def unreserve(self, name: str) -> None:
186196
self._make_resource_request("unreserve", name)
197+
198+
def try_reserve(
199+
self,
200+
selector: "ResourceSelector",
201+
) -> Optional[str]:
202+
"""
203+
Try to reserve a resource that matches the given condition
204+
205+
:return: the name of the reserved resource on success
206+
:return: None if all resources are busy
207+
"""
208+
for resource_name in selector.select(self):
209+
resource = self[resource_name]
210+
# if server reported that the resource is not free
211+
# don't try to reserve it
212+
if not resource.is_free():
213+
continue
214+
# if server reported that the resource is free
215+
# it might have been reserved since the last poll
216+
try:
217+
resource.reserve()
218+
except ResourceLockedError:
219+
continue
220+
return resource.name
221+
return None
222+
223+
DEFAULT_WAIT_SLEEP_PERIOD = 5
224+
DEFAULT_WAIT_TIMEOUT_PERIOD = 3600
225+
226+
def wait_reserve(
227+
self,
228+
selector: "ResourceSelector",
229+
sleep_period: float = DEFAULT_WAIT_SLEEP_PERIOD,
230+
timeout: float = DEFAULT_WAIT_TIMEOUT_PERIOD,
231+
) -> str:
232+
"""
233+
Reserve a resource that matches the given condition
234+
235+
:return: the name of the reserved resource
236+
:raise TimeoutError: if no resource could be reserved
237+
"""
238+
start_time = time.time()
239+
while True:
240+
result = self.try_reserve(selector)
241+
if result is not None:
242+
return result
243+
if time.time() - start_time > timeout:
244+
raise TimeoutError("Timed out waiting for resource")
245+
logger.info(
246+
"No free resources matching %r, sleep %.3f seconds",
247+
selector,
248+
sleep_period,
249+
)
250+
self.poll()
251+
time.sleep(sleep_period)
252+
253+
def reservation_by_label(
254+
self,
255+
label: str,
256+
sleep_period: float = DEFAULT_WAIT_SLEEP_PERIOD,
257+
timeout: float = DEFAULT_WAIT_TIMEOUT_PERIOD,
258+
) -> "LockedResourceReservation":
259+
return LockedResourceReservation(
260+
self,
261+
ResourceLabelSelector(label),
262+
sleep_period=sleep_period,
263+
timeout=timeout,
264+
)
265+
266+
def reservation_by_name(
267+
self,
268+
name: str,
269+
sleep_period: float = DEFAULT_WAIT_SLEEP_PERIOD,
270+
timeout: float = DEFAULT_WAIT_TIMEOUT_PERIOD,
271+
) -> "LockedResourceReservation":
272+
return LockedResourceReservation(
273+
self,
274+
ResourceNameSelector(name),
275+
sleep_period=sleep_period,
276+
timeout=timeout,
277+
)
278+
279+
def reservation_by_name_list(
280+
self,
281+
name_list: List[str],
282+
sleep_period: float = DEFAULT_WAIT_SLEEP_PERIOD,
283+
timeout: float = DEFAULT_WAIT_TIMEOUT_PERIOD,
284+
) -> "LockedResourceReservation":
285+
return LockedResourceReservation(
286+
self,
287+
ResourceNameListSelector(name_list),
288+
sleep_period=sleep_period,
289+
timeout=timeout,
290+
)
291+
292+
293+
class ResourceSelector:
294+
"""Base class for which iterates acceptable resources for a reservation"""
295+
296+
def select(self, lockable_resources: LockableResources) -> Iterator[str]:
297+
raise NotImplementedError("Subclasses must implement __call__ method")
298+
299+
300+
class ResourceNameSelector(ResourceSelector):
301+
def __init__(self, name: str):
302+
self.name = name
303+
304+
def select(self, lockable_resources: LockableResources) -> Iterator[str]:
305+
yield self.name
306+
307+
def __repr__(self) -> str:
308+
return f"{self.__class__.__name__}({self.name!r})"
309+
310+
311+
class ResourceNameListSelector(ResourceSelector):
312+
def __init__(self, name_list: List[str]):
313+
self.name_list = name_list
314+
315+
def select(self, lockable_resources: LockableResources) -> Iterator[str]:
316+
return iter(self.name_list)
317+
318+
def __repr__(self) -> str:
319+
return f"{self.__class__.__name__}({self.name_list!r})"
320+
321+
322+
class ResourceLabelSelector(ResourceSelector):
323+
def __init__(self, label: str):
324+
self.label = label
325+
326+
def __call__(self, resource: LockableResource) -> bool:
327+
return self.label in resource.data.get("labels", [])
328+
329+
def select(self, lockable_resources: LockableResources) -> Iterator[str]:
330+
for resource in lockable_resources.values():
331+
if self.label in resource.data["labelsAsList"]:
332+
yield resource.name
333+
334+
def __repr__(self) -> str:
335+
return f"{self.__class__.__name__}({self.label!r})"
336+
337+
338+
class LockedResourceReservation:
339+
"""
340+
Context manager for locking a Jenkins resource
341+
342+
Creating this object does not lock the resource, it is only locked and
343+
unlocked on :meth:`__enter__` and :meth:`__exit__` methods.
344+
345+
Example::
346+
347+
reservation: LockedResourceReservation = init_reservation()
348+
# .. possibly much later ...
349+
print("Resource will be locked ...")
350+
with reservation as locked_resource:
351+
name = locked_resource.locked_resource_name
352+
print(f"Resource currently locked: {name}")
353+
print("Resource no longer locked")
354+
355+
If resources are busy this will retry until it will eventually succeed or
356+
raise a `TimeoutError`.
357+
"""
358+
359+
_locked_resource_name: Optional[str] = None
360+
locked: bool = False
361+
362+
def __init__(
363+
self,
364+
api: LockableResources,
365+
selector: ResourceSelector,
366+
sleep_period: float = LockableResources.DEFAULT_WAIT_SLEEP_PERIOD,
367+
timeout: float = LockableResources.DEFAULT_WAIT_TIMEOUT_PERIOD,
368+
):
369+
self.api = api
370+
self.selector = selector
371+
self.sleep_period = sleep_period
372+
self.timeout = timeout
373+
374+
@property
375+
def locked_resource_name(self) -> str:
376+
"""
377+
Return the name of the locked resource
378+
379+
This throws an error if the resource is not currently locked.
380+
"""
381+
if self._locked_resource_name is None:
382+
raise RuntimeError("Resource not locked")
383+
return self._locked_resource_name
384+
385+
def __enter__(self) -> "LockedResourceReservation":
386+
"""Acquire a lock for the specified label."""
387+
if self._locked_resource_name is not None:
388+
raise RuntimeError("Lock already acquired")
389+
self._locked_resource_name = self.api.wait_reserve(
390+
self.selector, sleep_period=self.sleep_period, timeout=self.timeout
391+
)
392+
self.locked = True
393+
return self
394+
395+
def __exit__(self, *a) -> None:
396+
if self._locked_resource_name is not None:
397+
self.api.unreserve(self._locked_resource_name)
398+
self._locked_resource_name = None

jenkinsapi_tests/systests/test_lockable_resources.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ def test_lock_name() -> str:
3737
return "locktest"
3838

3939

40+
@pytest.fixture
41+
def test_lock_name2() -> str:
42+
return "locktest2"
43+
44+
45+
@pytest.fixture
46+
def test_lock_label() -> str:
47+
return "locktest"
48+
49+
4050
@pytest.fixture(scope="function")
4151
def lockable_resources(
4252
jenkins_admin_admin: Jenkins,
@@ -101,3 +111,50 @@ def test_reserve_unreserve_nopoll(
101111
assert lockable_resources.is_reserved(rn) is True
102112
lockable_resources.poll()
103113
assert lockable_resources.is_reserved(rn) is False
114+
115+
116+
def test_reservation_by_name(
117+
lockable_resources: LockableResources,
118+
test_lock_name: str,
119+
):
120+
reservation = lockable_resources.reservation_by_name(test_lock_name)
121+
assert lockable_resources.is_free(test_lock_name)
122+
with reservation:
123+
assert reservation.locked_resource_name == test_lock_name
124+
assert lockable_resources.is_free(test_lock_name) is False
125+
assert lockable_resources.is_free(test_lock_name)
126+
name = None
127+
with pytest.raises(RuntimeError):
128+
name = reservation.locked_resource_name
129+
assert name is None
130+
131+
132+
def test_reservation_by_name_list(
133+
lockable_resources: LockableResources,
134+
test_lock_name: str,
135+
test_lock_name2: str,
136+
):
137+
name_list = [test_lock_name, test_lock_name2]
138+
r1 = lockable_resources.reservation_by_name_list(name_list)
139+
assert lockable_resources.is_free(name_list[0])
140+
assert lockable_resources.is_free(name_list[1])
141+
with lockable_resources.reservation_by_name_list(name_list) as r1:
142+
assert r1.locked_resource_name == name_list[0]
143+
with lockable_resources.reservation_by_name_list(name_list) as r2:
144+
assert r2.locked_resource_name == name_list[1]
145+
assert lockable_resources.is_free(name_list[1]) is False
146+
assert lockable_resources.is_free(name_list[1])
147+
assert lockable_resources.is_free(name_list[0])
148+
assert lockable_resources.is_free(name_list[1])
149+
150+
151+
def test_reservation_by_label(
152+
lockable_resources: LockableResources,
153+
test_lock_label: str,
154+
):
155+
res = lockable_resources.reservation_by_label(test_lock_label)
156+
with res:
157+
locked_resource = lockable_resources[res.locked_resource_name]
158+
assert locked_resource.is_free() is False
159+
assert test_lock_label in locked_resource.data["labelsAsList"]
160+
assert locked_resource.is_free() is True

0 commit comments

Comments
 (0)