Coverage for /usr/local/lib/python3.12/site-packages/prefect/utilities/templating.py: 15%

164 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 13:38 +0000

1import enum 1a

2import os 1a

3import re 1a

4from typing import ( 1a

5 TYPE_CHECKING, 

6 Any, 

7 Callable, 

8 Literal, 

9 NamedTuple, 

10 Optional, 

11 TypeVar, 

12 Union, 

13 cast, 

14 overload, 

15) 

16 

17from prefect.client.utilities import inject_client 1a

18from prefect.logging.loggers import get_logger 1a

19from prefect.utilities.annotations import NotSet 1a

20from prefect.utilities.collections import get_from_dict 1a

21 

22if TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true1a

23 import logging 

24 

25 from prefect.client.orchestration import PrefectClient 

26 

27 

28T = TypeVar("T", str, int, float, bool, dict[Any, Any], list[Any], None) 1a

29 

30PLACEHOLDER_CAPTURE_REGEX = re.compile(r"({{\s*([\w\.\-\[\]$]+)\s*}})") 1a

31BLOCK_DOCUMENT_PLACEHOLDER_PREFIX = "prefect.blocks." 1a

32VARIABLE_PLACEHOLDER_PREFIX = "prefect.variables." 1a

33ENV_VAR_PLACEHOLDER_PREFIX = "$" 1a

34 

35 

36logger: "logging.Logger" = get_logger("utilities.templating") 1a

37 

38 

39class PlaceholderType(enum.Enum): 1a

40 STANDARD = "standard" 1a

41 BLOCK_DOCUMENT = "block_document" 1a

42 VARIABLE = "variable" 1a

43 ENV_VAR = "env_var" 1a

44 

45 

46class Placeholder(NamedTuple): 1a

47 full_match: str 1a

48 name: str 1a

49 type: PlaceholderType 1a

50 

51 

52def determine_placeholder_type(name: str) -> PlaceholderType: 1a

53 """ 

54 Determines the type of a placeholder based on its name. 

55 

56 Args: 

57 name: The name of the placeholder 

58 

59 Returns: 

60 The type of the placeholder 

61 """ 

62 if name.startswith(BLOCK_DOCUMENT_PLACEHOLDER_PREFIX): 

63 return PlaceholderType.BLOCK_DOCUMENT 

64 elif name.startswith(VARIABLE_PLACEHOLDER_PREFIX): 

65 return PlaceholderType.VARIABLE 

66 elif name.startswith(ENV_VAR_PLACEHOLDER_PREFIX): 

67 return PlaceholderType.ENV_VAR 

68 else: 

69 return PlaceholderType.STANDARD 

70 

71 

72def find_placeholders(template: T) -> set[Placeholder]: 1a

73 """ 

74 Finds all placeholders in a template. 

75 

76 Args: 

77 template: template to discover placeholders in 

78 

79 Returns: 

80 A set of all placeholders in the template 

81 """ 

82 seed: set[Placeholder] = set() 

83 if isinstance(template, (int, float, bool)): 

84 return seed 

85 if isinstance(template, str): 

86 result = PLACEHOLDER_CAPTURE_REGEX.findall(template) 

87 return { 

88 Placeholder(full_match, name, determine_placeholder_type(name)) 

89 for full_match, name in result 

90 } 

91 elif isinstance(template, dict): 

92 return seed.union(*[find_placeholders(value) for value in template.values()]) 

93 elif isinstance(template, list): 

94 return seed.union(*[find_placeholders(item) for item in template]) 

95 else: 

96 raise ValueError(f"Unexpected type: {type(template)}") 

97 

98 

99@overload 1a

100def apply_values( 100 ↛ exitline 100 didn't return from function 'apply_values' because 1a

101 template: T, 

102 values: dict[str, Any], 

103 remove_notset: Literal[True] = True, 

104 warn_on_notset: bool = False, 

105) -> T: ... 

106 

107 

108@overload 1a

109def apply_values( 109 ↛ exitline 109 didn't return from function 'apply_values' because 1a

110 template: T, 

111 values: dict[str, Any], 

112 remove_notset: Literal[False] = False, 

113 warn_on_notset: bool = False, 

114) -> Union[T, type[NotSet]]: ... 

115 

116 

117@overload 1a

118def apply_values( 118 ↛ exitline 118 didn't return from function 'apply_values' because 1a

119 template: T, 

120 values: dict[str, Any], 

121 remove_notset: bool = False, 

122 warn_on_notset: bool = False, 

123) -> Union[T, type[NotSet]]: ... 

124 

125 

126def apply_values( 1a

127 template: T, 

128 values: dict[str, Any], 

129 remove_notset: bool = True, 

130 warn_on_notset: bool = False, 

131) -> Union[T, type[NotSet]]: 

132 """ 

133 Replaces placeholders in a template with values from a supplied dictionary. 

134 

135 Will recursively replace placeholders in dictionaries and lists. 

136 

137 If a value has no placeholders, it will be returned unchanged. 

138 

139 If a template contains only a single placeholder, the placeholder will be 

140 fully replaced with the value. 

141 

142 If a template contains text before or after a placeholder or there are 

143 multiple placeholders, the placeholders will be replaced with the 

144 corresponding variable values. 

145 

146 If a template contains a placeholder that is not in `values`, NotSet will 

147 be returned to signify that no placeholder replacement occurred. If 

148 `template` is a dictionary that contains a key with a value of NotSet, 

149 the key will be removed in the return value unless `remove_notset` is set to False. 

150 

151 Args: 

152 template: template to discover and replace values in 

153 values: The values to apply to placeholders in the template 

154 remove_notset: If True, remove keys with an unset value 

155 warn_on_notset: If True, warn when a placeholder is not found in `values` 

156 

157 Returns: 

158 The template with the values applied 

159 """ 

160 if template in (NotSet, None) or isinstance(template, (int, float)): 

161 return template 

162 if isinstance(template, str): 

163 placeholders = find_placeholders(template) 

164 if not placeholders: 

165 # If there are no values, we can just use the template 

166 return template 

167 elif ( 

168 len(placeholders) == 1 

169 and list(placeholders)[0].full_match == template 

170 and list(placeholders)[0].type is PlaceholderType.STANDARD 

171 ): 

172 # If there is only one variable with no surrounding text, 

173 # we can replace it. If there is no variable value, we 

174 # return NotSet to indicate that the value should not be included. 

175 value = get_from_dict(values, list(placeholders)[0].name, NotSet) 

176 if value is NotSet and warn_on_notset: 

177 logger.warning( 

178 f"Value for placeholder {list(placeholders)[0].name!r} not found in provided values. Please ensure that " 

179 "the placeholder is spelled correctly and that the corresponding value is provided.", 

180 ) 

181 return value 

182 else: 

183 for full_match, name, placeholder_type in placeholders: 

184 if placeholder_type is PlaceholderType.STANDARD: 

185 value = get_from_dict(values, name, NotSet) 

186 elif placeholder_type is PlaceholderType.ENV_VAR: 

187 name = name.lstrip(ENV_VAR_PLACEHOLDER_PREFIX) 

188 value = os.environ.get(name, NotSet) 

189 else: 

190 continue 

191 

192 if value is NotSet: 

193 if warn_on_notset: 

194 logger.warning( 

195 f"Value for placeholder {full_match!r} not found in provided values. Please ensure that " 

196 "the placeholder is spelled correctly and that the corresponding value is provided.", 

197 ) 

198 if remove_notset: 

199 template = template.replace(full_match, "") 

200 else: 

201 template = template.replace(full_match, str(value)) 

202 

203 return template 

204 elif isinstance(template, dict): 

205 updated_template: dict[str, Any] = {} 

206 for key, value in template.items(): 

207 updated_value = apply_values( 

208 value, 

209 values, 

210 remove_notset=remove_notset, 

211 warn_on_notset=warn_on_notset, 

212 ) 

213 if updated_value is not NotSet: 

214 updated_template[key] = updated_value 

215 elif not remove_notset: 

216 updated_template[key] = value 

217 

218 return cast(T, updated_template) 

219 elif isinstance(template, list): 

220 updated_list: list[Any] = [] 

221 for value in template: 

222 updated_value = apply_values( 

223 value, 

224 values, 

225 remove_notset=remove_notset, 

226 warn_on_notset=warn_on_notset, 

227 ) 

228 if updated_value is not NotSet: 

229 updated_list.append(updated_value) 

230 return cast(T, updated_list) 

231 else: 

232 raise ValueError(f"Unexpected template type {type(template).__name__!r}") 

233 

234 

235@inject_client 1a

236async def resolve_block_document_references( 1a

237 template: T, 

238 client: Optional["PrefectClient"] = None, 

239 value_transformer: Optional[Callable[[str, Any], Any]] = None, 

240) -> Union[T, dict[str, Any]]: 

241 """ 

242 Resolve block document references in a template by replacing each reference with 

243 its value or the return value of the transformer function if provided. 

244 

245 Recursively searches for block document references in dictionaries and lists. 

246 

247 Identifies block document references by the as dictionary with the following 

248 structure: 

249 ``` 

250 { 

251 "$ref": { 

252 "block_document_id": <block_document_id> 

253 } 

254 } 

255 ``` 

256 where `<block_document_id>` is the ID of the block document to resolve. 

257 

258 Once the block document is retrieved from the API, the data of the block document 

259 is used to replace the reference. 

260 

261 Accessing Values: 

262 ----------------- 

263 To access different values in a block document, use dot notation combined with the block document's prefix, slug, and block name. 

264 

265 For a block document with the structure: 

266 ```json 

267 { 

268 "value": { 

269 "key": { 

270 "nested-key": "nested-value" 

271 }, 

272 "list": [ 

273 {"list-key": "list-value"}, 

274 1, 

275 2 

276 ] 

277 } 

278 } 

279 ``` 

280 examples of value resolution are as follows: 

281 

282 1. Accessing a nested dictionary: 

283 Format: `prefect.blocks.<block_type_slug>.<block_document_name>.value.key` 

284 Example: Returns `{"nested-key": "nested-value"}` 

285 

286 2. Accessing a specific nested value: 

287 Format: `prefect.blocks.<block_type_slug>.<block_document_name>.value.key.nested-key` 

288 Example: Returns `"nested-value"` 

289 

290 3. Accessing a list element's key-value: 

291 Format: `prefect.blocks.<block_type_slug>.<block_document_name>.value.list[0].list-key` 

292 Example: Returns `"list-value"` 

293 

294 Default Resolution for System Blocks: 

295 ------------------------------------- 

296 For system blocks, which only contain a `value` attribute, this attribute is resolved by default. 

297 

298 Args: 

299 template: The template to resolve block documents in 

300 value_transformer: A function that takes the block placeholder and the block value and returns replacement text for the template 

301 

302 Returns: 

303 The template with block documents resolved 

304 """ 

305 if TYPE_CHECKING: 

306 # The @inject_client decorator takes care of providing the client, but 

307 # the function signature must mark it as optional to callers. 

308 assert client is not None 

309 

310 if isinstance(template, dict): 

311 block_document_id = template.get("$ref", {}).get("block_document_id") 

312 if block_document_id: 

313 block_document = await client.read_block_document(block_document_id) 

314 return block_document.data 

315 updated_template: dict[str, Any] = {} 

316 for key, value in template.items(): 

317 updated_value = await resolve_block_document_references( 

318 value, value_transformer=value_transformer, client=client 

319 ) 

320 updated_template[key] = updated_value 

321 return updated_template 

322 elif isinstance(template, list): 

323 return [ 

324 await resolve_block_document_references( 

325 item, value_transformer=value_transformer, client=client 

326 ) 

327 for item in template 

328 ] 

329 elif isinstance(template, str): 

330 placeholders = find_placeholders(template) 

331 has_block_document_placeholder = any( 

332 placeholder.type is PlaceholderType.BLOCK_DOCUMENT 

333 for placeholder in placeholders 

334 ) 

335 if not (placeholders and has_block_document_placeholder): 

336 return template 

337 elif ( 

338 len(placeholders) == 1 

339 and list(placeholders)[0].full_match == template 

340 and list(placeholders)[0].type is PlaceholderType.BLOCK_DOCUMENT 

341 ): 

342 # value_keypath will be a list containing a dot path if additional 

343 # attributes are accessed and an empty list otherwise. 

344 [placeholder] = placeholders 

345 parts = placeholder.name.replace( 

346 BLOCK_DOCUMENT_PLACEHOLDER_PREFIX, "" 

347 ).split(".", 2) 

348 block_type_slug, block_document_name, *value_keypath = parts 

349 block_document = await client.read_block_document_by_name( 

350 name=block_document_name, block_type_slug=block_type_slug 

351 ) 

352 data = block_document.data 

353 value: Union[T, dict[str, Any]] = data 

354 

355 # resolving system blocks to their data for backwards compatibility 

356 if len(data) == 1 and "value" in data: 

357 # only resolve the value if the keypath is not already pointing to "value" 

358 if not (value_keypath and value_keypath[0].startswith("value")): 

359 data = value = value["value"] 

360 

361 # resolving keypath/block attributes 

362 if value_keypath: 

363 from_dict: Any = get_from_dict(data, value_keypath[0], default=NotSet) 

364 if from_dict is NotSet: 

365 raise ValueError( 

366 f"Invalid template: {template!r}. Could not resolve the" 

367 " keypath in the block document data." 

368 ) 

369 value = from_dict 

370 

371 if value_transformer: 

372 value = value_transformer(placeholder.full_match, value) 

373 

374 return value 

375 else: 

376 raise ValueError( 

377 f"Invalid template: {template!r}. Only a single block placeholder is" 

378 " allowed in a string and no surrounding text is allowed." 

379 ) 

380 

381 return template 

382 

383 

384@inject_client 1a

385async def resolve_variables(template: T, client: Optional["PrefectClient"] = None) -> T: 1a

386 """ 

387 Resolve variables in a template by replacing each variable placeholder with the 

388 value of the variable. 

389 

390 Recursively searches for variable placeholders in dictionaries and lists. 

391 

392 Strips variable placeholders if the variable is not found. 

393 

394 Args: 

395 template: The template to resolve variables in 

396 

397 Returns: 

398 The template with variables resolved 

399 """ 

400 if TYPE_CHECKING: 

401 # The @inject_client decorator takes care of providing the client, but 

402 # the function signature must mark it as optional to callers. 

403 assert client is not None 

404 

405 if isinstance(template, str): 

406 placeholders = find_placeholders(template) 

407 has_variable_placeholder = any( 

408 placeholder.type is PlaceholderType.VARIABLE for placeholder in placeholders 

409 ) 

410 if not placeholders or not has_variable_placeholder: 

411 # If there are no values, we can just use the template 

412 return template 

413 elif ( 

414 len(placeholders) == 1 

415 and list(placeholders)[0].full_match == template 

416 and list(placeholders)[0].type is PlaceholderType.VARIABLE 

417 ): 

418 variable_name = list(placeholders)[0].name.replace( 

419 VARIABLE_PLACEHOLDER_PREFIX, "" 

420 ) 

421 variable = await client.read_variable_by_name(name=variable_name) 

422 if variable is None: 

423 return "" 

424 else: 

425 return cast(T, variable.value) 

426 else: 

427 for full_match, name, placeholder_type in placeholders: 

428 if placeholder_type is PlaceholderType.VARIABLE: 

429 variable_name = name.replace(VARIABLE_PLACEHOLDER_PREFIX, "") 

430 variable = await client.read_variable_by_name(name=variable_name) 

431 if variable is None: 

432 template = template.replace(full_match, "") 

433 else: 

434 template = template.replace(full_match, str(variable.value)) 

435 return template 

436 elif isinstance(template, dict): 

437 return { 

438 key: await resolve_variables(value, client=client) 

439 for key, value in template.items() 

440 } 

441 elif isinstance(template, list): 

442 return [await resolve_variables(item, client=client) for item in template] 

443 else: 

444 return template