Coverage for app/venv/lib/python3.14/site-packages/weblate/utils/lock.py: 71%

86 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7import os 

8import threading 

9from typing import TYPE_CHECKING, cast 

10 

11import sentry_sdk 

12from django.core.cache import cache 

13from filelock import FileLock, Timeout 

14 

15from weblate.utils.cache import is_redis_cache 

16from weblate.utils.errors import add_breadcrumb 

17 

18if TYPE_CHECKING: 18 ↛ 19line 18 didn't jump to line 19 because the condition on line 18 was never true

19 from types import TracebackType 

20 

21 from django_redis.cache import RedisCache 

22 from redis.lock import Lock as RedisLock 

23 

24 

25class WeblateLockError(Exception): 

26 def __init__(self, message: str, *, lock: WeblateLock) -> None: 

27 super().__init__(message) 

28 self.lock = lock 

29 

30 

31class WeblateLockTimeoutError(WeblateLockError): 

32 """Weblate lock timeout.""" 

33 

34 

35class WeblateLockNotLockedError(WeblateLockError): 

36 """Weblate lock not locked on release.""" 

37 

38 

39class WeblateLock: 

40 """Wrapper around Redis or file based lock.""" 

41 

42 _redis_lock: RedisLock 

43 _file_lock: FileLock 

44 

45 _redis_expiry_timeout = 3600 

46 

47 def __init__( 

48 self, 

49 *, 

50 lock_path: str, 

51 scope: str, 

52 key: int | str, 

53 slug: str, 

54 cache_template: str = "lock:{scope}:{key}", 

55 file_template: str = "{slug}-{scope}.lock", 

56 timeout: int = 1, 

57 origin: str | None = None, 

58 ) -> None: 

59 self._timeout = timeout 

60 self._lock_path = lock_path 

61 self._scope = scope 

62 self._key = key 

63 self._slug = slug 

64 self._origin = origin 

65 self._using_redis = is_redis_cache() 

66 self._local = threading.local() 

67 self._local.depth = 0 

68 if self._using_redis: 68 ↛ 80line 68 didn't jump to line 80 because the condition on line 68 was always true

69 # Prefer Redis locking as it works distributed 

70 self._name = self._format_template(cache_template) 

71 self._redis_lock = cast("RedisCache", cache).lock( 

72 key=self._name, 

73 blocking=True, 

74 timeout=self._redis_expiry_timeout, 

75 blocking_timeout=self._timeout, 

76 thread_local=True, 

77 ) 

78 else: 

79 # Fall back to file based locking 

80 self._name = os.path.join(lock_path, self._format_template(file_template)) 

81 self._file_lock = FileLock( 

82 self._name, 

83 timeout=self._timeout, 

84 ) 

85 

86 @property 

87 def scope(self) -> str: 

88 return self._scope 

89 

90 @property 

91 def origin(self) -> str | None: 

92 return self._origin 

93 

94 def _format_template(self, template: str) -> str: 

95 return template.format( 

96 scope=self._scope, 

97 key=self._key, 

98 slug=self._slug, 

99 ) 

100 

101 def get_error_message(self) -> str: 

102 if self.origin: 

103 return f"Lock on {self.origin} ({self.scope}) could not be acquired in {self._timeout}s" 

104 return f"Lock on {self._name} could not be acquired in {self._timeout}s" 

105 

106 def _enter_redis(self) -> None: 

107 # Make the lock reentrant 

108 if self._redis_lock.owned(): 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was never true

109 # Extend lock if already owned (nested with statements) 

110 lock_result = self._redis_lock.reacquire() 

111 else: 

112 lock_result = self._redis_lock.acquire() 

113 

114 if not lock_result: 114 ↛ 115line 114 didn't jump to line 115 because the condition on line 114 was never true

115 raise WeblateLockTimeoutError(self.get_error_message(), lock=self) 

116 

117 def _enter_file(self) -> None: 

118 # Fall back to file based locking 

119 try: 

120 self._file_lock.acquire() 

121 except Timeout as error: 

122 raise WeblateLockTimeoutError( 

123 self.get_error_message(), lock=self 

124 ) from error 

125 

126 def add_breadcrumb(self, operation: str) -> None: 

127 add_breadcrumb( 

128 category="lock", message=f"{operation} {self._name} ({self._local.depth})" 

129 ) 

130 

131 def __enter__(self) -> None: 

132 self.add_breadcrumb("enter") 

133 if not self.is_locked: 

134 self.add_breadcrumb("acquire") 

135 with sentry_sdk.start_span(op="lock.wait", name=self._name): 

136 if self._using_redis: 136 ↛ 139line 136 didn't jump to line 139 because the condition on line 136 was always true

137 self._enter_redis() 

138 else: 

139 self._enter_file() 

140 self._local.depth += 1 

141 

142 def __exit__( 

143 self, 

144 exc_type: type[BaseException] | None, 

145 exc_value: BaseException | None, 

146 traceback: TracebackType | None, 

147 ) -> None: 

148 if not self.is_locked: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 msg = f"Lock on {self._name} was not held on release" 

150 raise WeblateLockNotLockedError(msg, lock=self) 

151 

152 self.add_breadcrumb("exit") 

153 self._local.depth -= 1 

154 

155 # Release underlying lock 

156 if self._local.depth == 0: 

157 self.add_breadcrumb("release") 

158 if self._using_redis: 158 ↛ 161line 158 didn't jump to line 161 because the condition on line 158 was always true

159 self._redis_lock.release() 

160 else: 

161 self._file_lock.release() 

162 

163 @property 

164 def is_locked(self) -> bool: 

165 return self._local.depth > 0