Coverage for src/lektor_ng/context.py: 83%

149 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-05 16:30 +0000

1from contextlib import contextmanager 

2 

3from jinja2 import Undefined 

4from werkzeug.local import LocalProxy, LocalStack 

5 

6from lektor_ng.reporter import reporter 

7 

8_ctx_stack = LocalStack() 

9 

10 

11def url_to(*args, **kwargs): 

12 """Calculates a URL to another record.""" 

13 ctx = get_ctx() 

14 if ctx is None: 

15 raise RuntimeError("No context found") 

16 return ctx.url_to(*args, **kwargs) 

17 

18 

19def get_asset_url(asset): 

20 """Calculates the asset URL relative to the current record.""" 

21 ctx = get_ctx() 

22 if ctx is None: 

23 raise RuntimeError("No context found") 

24 asset = ctx.pad.get_asset(asset) 

25 if asset is None: 

26 return Undefined("Asset not found") 

27 return ctx.get_asset_url(asset) 

28 

29 

30@LocalProxy 

31def site_proxy(): 

32 """Returns the current pad.""" 

33 ctx = get_ctx() 

34 if ctx is None: 

35 return Undefined(hint="Cannot access the site from here", name="site") 

36 return ctx.pad 

37 

38 

39@LocalProxy 

40def config_proxy(): 

41 """Returns the current config.""" 

42 return site_proxy.db.config 

43 

44 

45def get_ctx(): 

46 """Returns the current context.""" 

47 return _ctx_stack.top 

48 

49 

50def get_locale(default="en_US"): 

51 """Returns the current locale.""" 

52 ctx = get_ctx() 

53 if ctx is not None: 

54 rv = ctx.locale 

55 if rv is not None: 

56 return rv 

57 return ctx.pad.db.config.site_locale 

58 return default 

59 

60 

61class Context: 

62 """The context is a thread local object that provides the system with 

63 general information about in which state it is. The context is created 

64 whenever a source is processed and can be accessed by template engine and 

65 other things. 

66 

67 It's considered read and write and also accumulates changes that happen 

68 during processing of the object. 

69 """ 

70 

71 def __init__(self, artifact=None, pad=None): 

72 if pad is None: 

73 if artifact is None: 

74 raise TypeError("Either artifact or pad is needed to construct a context.") 

75 pad = artifact.build_state.pad 

76 

77 if artifact is not None: 

78 self.artifact = artifact 

79 self.source = artifact.source_obj 

80 self.build_state = self.artifact.build_state 

81 else: 

82 self.artifact = None 

83 self.source = None 

84 self.build_state = None 

85 

86 self.exc_info = None 

87 

88 self.pad = pad 

89 

90 # Processing information 

91 self.referenced_dependencies = set() 

92 self.referenced_virtual_dependencies = set() 

93 self.sub_artifacts = [] 

94 

95 self.flow_block_render_stack = [] 

96 

97 self._forced_base_url = None 

98 self._resolving_url = False 

99 

100 # General cache system where other things can put their temporary 

101 # stuff in. 

102 self.cache = {} 

103 

104 self._dependency_collectors = [] 

105 

106 @property 

107 def env(self): 

108 """The environment of the context.""" 

109 return self.pad.db.env 

110 

111 @property 

112 def record(self): 

113 """If the source is a record it will be available here.""" 

114 rv = self.source 

115 if rv is not None and rv.source_classification == "record": 

116 return rv 

117 return None 

118 

119 @property 

120 def locale(self): 

121 """Returns the current locale if it's available, otherwise `None`. 

122 This does not fall back to the site locale. 

123 """ 

124 source = self.source 

125 if source is not None: 

126 alt_cfg = self.pad.db.config["ALTERNATIVES"].get(source.alt) 

127 if alt_cfg: 

128 return alt_cfg["locale"] 

129 return None 

130 

131 def push(self): 

132 _ctx_stack.push(self) 

133 

134 @staticmethod 

135 def pop(): 

136 _ctx_stack.pop() 

137 

138 def __enter__(self): 

139 self.push() 

140 return self 

141 

142 def __exit__(self, exc_type, exc_value, tb): 

143 self.pop() 

144 

145 @property 

146 def base_url(self): 

147 """The URL path for the current context.""" 

148 if self._forced_base_url: 

149 return self._forced_base_url 

150 if self.source is not None: 

151 return self.source.url_path 

152 return "/" 

153 

154 def url_to( 

155 self, 

156 path, 

157 alt=None, 

158 absolute=None, 

159 external=None, 

160 resolve=None, 

161 strict_resolve=None, 

162 ): 

163 """Returns a URL to another path.""" 

164 if self.source is None: 

165 raise RuntimeError("Can only generate paths to other pages if the context has a source document set.") 

166 return self.source.url_to( 

167 path, 

168 alt=alt, 

169 base_url=self.base_url, 

170 absolute=absolute, 

171 external=external, 

172 resolve=resolve, 

173 strict_resolve=strict_resolve, 

174 ) 

175 

176 def get_asset_url(self, asset): 

177 """Calculates the asset URL relative to the current record.""" 

178 if self.source is None: 

179 raise RuntimeError("Can only generate paths to assets if the context has a source document set.") 

180 asset_url = self.source.url_to("!" + asset.url_path) 

181 info = self.build_state.get_file_info(asset.source_filename) 

182 self.record_dependency(asset.source_filename) 

183 return f"{asset_url}?h={info.checksum[:8]}" 

184 

185 def sub_artifact(self, *args, **kwargs): 

186 """Decorator version of :func:`add_sub_artifact`.""" 

187 

188 def decorator(f): 

189 self.add_sub_artifact(*args, build_func=f, **kwargs) 

190 return f 

191 

192 return decorator 

193 

194 def add_sub_artifact( 

195 self, 

196 artifact_name, 

197 build_func=None, 

198 sources=None, 

199 source_obj=None, 

200 config_hash=None, 

201 ): 

202 """Sometimes it can happen that while building an artifact another 

203 artifact needs building. This function is generally used to record 

204 this request. 

205 """ 

206 if self.build_state is None: 

207 raise TypeError( 

208 "The context does not have a build state which means that artifact declaration is not possible." 

209 ) 

210 aft = self.build_state.new_artifact( 

211 artifact_name=artifact_name, 

212 sources=sources, 

213 source_obj=source_obj, 

214 config_hash=config_hash, 

215 ) 

216 self.sub_artifacts.append((aft, build_func)) 

217 reporter.report_sub_artifact(aft) 

218 

219 def record_dependency(self, filename, affects_url=None): 

220 """Records a dependency from processing. 

221 

222 If ``affects_url`` is set to ``False`` the dependency will be ignored if 

223 we are in the process of resolving a URL. 

224 """ 

225 if self._resolving_url and affects_url is False: 

226 return 

227 self.referenced_dependencies.add(filename) 

228 for coll in self._dependency_collectors: 

229 coll(filename) 

230 

231 def record_virtual_dependency(self, virtual_source): 

232 """Records a dependency from processing.""" 

233 self.referenced_virtual_dependencies.add(virtual_source) 

234 for coll in self._dependency_collectors: 

235 coll(virtual_source) 

236 

237 @contextmanager 

238 def gather_dependencies(self, func): 

239 """For the duration of the `with` block the provided function will be 

240 invoked for all dependencies encountered. 

241 """ 

242 self._dependency_collectors.append(func) 

243 try: 

244 yield 

245 finally: 

246 self._dependency_collectors.pop() 

247 

248 @contextmanager 

249 def changed_base_url(self, value): 

250 """Temporarily overrides the URL path of the context.""" 

251 old = self._forced_base_url 

252 self._forced_base_url = value 

253 try: 

254 yield 

255 finally: 

256 self._forced_base_url = old 

257 

258 

259@contextmanager 

260def ignore_url_unaffecting_dependencies(value=True): 

261 """Ignore dependencies which do not affect URL resolution within context.""" 

262 ctx = get_ctx() 

263 if ctx is not None: 

264 old = ctx._resolving_url 

265 ctx._resolving_url = value 

266 try: 

267 yield 

268 finally: 

269 if ctx is not None: 

270 ctx._resolving_url = old