ATLAS Offline Software
Loading...
Searching...
No Matches
AutogenDocumentation.py
Go to the documentation of this file.
1# Copyright (C) 2002-2026 CERN for the benefit of the ATLAS collaboration
2#
3# @author Baptiste Ravina
4"""
5Core methods to extract options information from ConfigBlock classes and merge with
6output variables metadata from a YAML file.
7"""
8
9import inspect
10import yaml
11import re
12from AnaAlgorithm.Logging import logging
13from typing import Any, Dict, List, Type, Optional, Union
14
15from AthenaCommon.Utils.unixtools import find_datafile
16
17logger = logging.getLogger("AutogenDocumentation")
18
19def load_output_variables(yaml_filepath: str) -> Dict[str, List[Dict[str, Any]]]:
20 """
21 Load output variables metadata from YAML file.
22
23 Expected YAML format:
24 BlockClassName:
25 - name: variable_name
26 description: Variable description
27 toggled_by: Optional condition description
28
29 Args:
30 yaml_filepath: Path to the YAML file
31
32 Returns:
33 Dictionary mapping block class names to their output variables
34 """
35 with open(yaml_filepath, "r") as f:
36 data = yaml.safe_load(f)
37 return data if data else {}
38
39
40def extract_block_options(block_class: Type) -> Dict[str, Any]:
41 """
42 Extract options information from a ConfigBlock subclass.
43
44 Args:
45 block_class: A class that inherits from ConfigBlock
46
47 Returns:
48 A dictionary containing the class name and its options
49 """
50 # Create a temporary instance to access the options
51 instance = block_class()
52
53 # Get the options dictionary
54 options_dict = instance.getOptions()
55
56 # Extract information for each option
57 options_list = []
58 for option_name, option_obj in options_dict.items():
59 # skip some specific options
60 if option_name in ["groupName", "propertyOverrides", "ignoreDependencies"]:
61 continue
62 option_info = {
63 "label": option_name,
64 "type": option_obj.type.__name__ if option_obj.type is not None else "None",
65 "default": option_obj.default,
66 "info": option_obj.info,
67 "required": option_obj.required,
68 "noneAction": option_obj.noneAction,
69 "physicalUnit": interpret_physical_unit(option_obj.info),
70 "meta": option_obj.meta,
71 }
72 # Check if this option has expert mode settings
73 if (
74 hasattr(instance, "_expertModeSettings")
75 and option_name in instance._expertModeSettings
76 ):
77 expert_rule = instance._expertModeSettings[option_name]
78 if not isinstance(expert_rule, list):
79 expert_rule = [expert_rule]
80 else:
81 expert_rule = None
82 option_info["expertMode"] = expert_rule
83 options_list.append(option_info)
84
85 return {
86 "class": block_class.__name__,
87 "module": block_class.__module__,
88 "docstring": inspect.getdoc(block_class),
89 "options": options_list,
90 }
91
92
93def interpret_physical_unit(info: str) -> Optional[str]:
94 """
95 Extract a physical unit from an info string.
96 Currently looks for energy units like MeV or GeV.
97
98 Args:
99 info: The information string from an option.
100
101 Returns:
102 The detected unit as a string ("MeV", "GeV", etc.) or None if no unit is found.
103 """
104 if not info:
105 return None
106
107 # Check for specific units
108 patterns = [
109 r"\‍[MeV\‍]",
110 r"\‍(MeV\‍)",
111 r"\‍(in MeV\‍)",
112 r"\‍[in MeV\‍]",
113 r"\‍[GeV\‍]",
114 r"\‍(GeV\‍)",
115 r"\‍(in GeV\‍)",
116 r"\‍[in GeV\‍]",
117 r"\‍[mm\‍]",
118 r"\‍(mm\‍)",
119 r"\‍(in mm\‍)",
120 r"\‍[in mm\‍]",
121 ]
122 for pattern in patterns:
123 if re.search(pattern, info):
124 if "MeV" in pattern:
125 return "MeV"
126 elif "GeV" in pattern:
127 return "GeV"
128 elif "mm" in pattern:
129 return "mm"
130 return None
131
132
133def process_info_links(info: str) -> str:
134 """
135 Scan an info string for backtick-enclosed substrings of the form `A::B`.
136 Turn them into a link to the appropriate module/files.
137
138 Args:
139 info: The input info string.
140
141 Returns:
142 The processed string with Markdown links where applicable.
143 """
144 if not info:
145 return info
146
147 # Regex to match `A::B` inside backticks
148 pattern = r"`([^`]+)::([^`]+)`"
149
150 def replace_match(match):
151 A, B = match.group(1), match.group(2)
152 if A == "CP" or A == "ORUtils":
153 url = f"https://acode-browser1.usatlas.bnl.gov/lxr/search?%21v=head&_filestring=**{B}**&_string="
154 return f"[`{A}::{B}`]({url})"
155 elif A == "xAOD" or A == "AthOnnx":
156 url = f"https://acode-browser1.usatlas.bnl.gov/lxr/ident?v=head&_i={B}&_identdefonly=1&_remember=1"
157 return f"[`{A}::{B}`]({url})"
158 else:
159 # TODO: any other cases to handle?
160 return f"`{A}::{B}`"
161
162 return re.sub(pattern, replace_match, info)
163
164
165def link_jira_tickets(info: str) -> str:
166 """
167 Convert JIRA ticket references in a string to Markdown links.
168
169 - JIRA tickets are of the form: all-caps letters, a dash, then digits (e.g., ATLASG-2358)
170 - Converted to Markdown links: [ATLASG-2358](https://its.cern.ch/jira/browse/ATLASG-2358)
171
172 Args:
173 info: Input string that may contain JIRA tickets.
174
175 Returns:
176 The string with JIRA tickets converted to Markdown links.
177 """
178 if not info:
179 return info
180
181 # Regex pattern: one or more uppercase letters, dash, one or more digits
182 pattern = r"\b([A-Z]+-\d+)\b"
183
184 def replace_match(match):
185 ticket = match.group(1)
186 url = f"https://its.cern.ch/jira/browse/{ticket}"
187 return f"[{ticket}]({url})"
188
189 return re.sub(pattern, replace_match, info)
190
191
193 block_classes: List[Type], output_vars_yaml: Optional[Union[str, List[str]]] = None
194) -> List[Dict[str, Any]]:
195 """
196 Extract options information from a list of ConfigBlock classes and merge
197 with output variables metadata.
198
199 Args:
200 block_classes: List of classes that inherit from ConfigBlock
201 output_vars_yaml: Optional path to YAML file or list of paths to YAML files
202 containing output variables. If multiple files provided,
203 their contents will be merged. Files are located using
204 find_datafile.
205
206 Returns:
207 List of dictionaries, each containing information about a block class
208 """
209 # Load output variables if YAML file(s) provided
210 output_vars_map = {}
211 if output_vars_yaml:
212 # Normalize to list for uniform processing
213 yaml_files = (
214 [output_vars_yaml]
215 if isinstance(output_vars_yaml, str)
216 else output_vars_yaml
217 )
218
219 # Load and merge all YAML files
220 for yaml_file in yaml_files:
221 # Locate the file
222 resolved_path = find_datafile(yaml_file)
223 if resolved_path is None:
224 raise FileNotFoundError(f"Could not locate YAML file: {yaml_file}")
225
226 file_vars = load_output_variables(resolved_path)
227 # Merge with existing map (later files can override earlier ones)
228 for class_name, variables in file_vars.items():
229 if class_name in output_vars_map:
230 # Merge variable lists, avoiding duplicates if needed
231 output_vars_map[class_name].extend(variables)
232 else:
233 output_vars_map[class_name] = variables
234
235 results = []
236 for block_class in block_classes:
237 info = extract_block_options(block_class)
238 # Merge output variables if available
239 class_name = block_class.__name__
240 info["output_variables"] = output_vars_map.get(class_name, [])
241
242 results.append(info)
243
244 return results
245
246
247def save_as_yaml(data: List[Dict[str, Any]], filepath: str) -> None:
248 """Save extracted data as YAML."""
249 with open(filepath, "w") as f:
250 yaml.dump(data, f, default_flow_style=False, sort_keys=False)
251 logger.info(f"Saved YAML to {filepath}")
252
253
254def generate_block_markdown(block_info: Dict[str, Any]) -> str:
255 """
256 Generate Markdown documentation for a single block.
257
258 Args:
259 block_info: Dictionary containing block information with keys:
260 - class: Block class name
261 - module: Module containing the block
262 - options: List of option dictionaries
263 - output_variables: List of output variable dictionaries
264
265 Returns:
266 Markdown string for this block
267 """
268 markdown = ""
269
270 # Options section
271 if block_info.get("options"):
272 for opt in block_info["options"]:
273 name = opt["label"]
274
275 # Skip these settings unless they are True
276 if name in ["skipOnData", "skipOnMC", "skipWithSystematics"]:
277 if not opt["default"] is True:
278 continue
279 # Skip these settings unless they are set
280 if name in ["onlyForDSIDs"]:
281 if opt["default"] == []:
282 continue
283
284 # Option label with type, expert flag, required flag
285 label = f"`{opt['label']}` ({opt['type']})"
286 if opt["expertMode"] is not None:
287 expertOptions = list(opt["expertMode"])
288 label += f" **[expert-only options: {','.join(['`' + str(x) + '`' for x in expertOptions])}]**"
289 if opt["required"] is True or opt["noneAction"] != "ignore":
290 label += " **[REQUIRED]**"
291
292 markdown += f"{label}\n"
293 info_string = opt["info"]
294 info_string = process_info_links(info_string)
295 info_string = link_jira_tickets(info_string)
296 markdown += f": {info_string}"
297
298 if opt.get("default") != "":
299 default_val = opt["default"]
300 default_str = repr(default_val)
301
302 # Add unit information if available
303 unit = opt.get("physicalUnit")
304 if unit is None or default_val is None:
305 default_display = f"`{default_str}`"
306 elif unit == "GeV":
307 default_display = f"`{default_str}` GeV"
308 elif unit == "MeV":
309 # Convert MeV to GeV for simplified display
310 try:
311 if isinstance(default_val, (list, tuple)):
312 converted = [float(x) / 1000 for x in default_val]
313 converted_str = (
314 "[" + ", ".join(f"{x}" for x in converted) + "]"
315 )
316 else:
317 converted = float(default_val) / 1000
318 converted_str = f"{converted}"
319 except (TypeError, ValueError):
320 converted_str = "?"
321 default_display = f"`{default_str}` MeV (`{converted_str}` GeV)"
322 else:
323 default_display = f"`{default_str}` {unit}"
324
325 markdown += f" Default: {default_display}."
326
327 markdown += "\n\n"
328
329 # Output variables section
330 if block_info.get("output_variables"):
331 # Separate variables into always-saved and toggled
332 always_saved = []
333 toggled_vars = {} # toggled_by condition -> list of variables
334
335 for var in block_info["output_variables"]:
336 if var.get("toggled_by"):
337 condition = var["toggled_by"]
338 if condition not in toggled_vars:
339 toggled_vars[condition] = []
340 toggled_vars[condition].append(var)
341 else:
342 always_saved.append(var)
343
344 # Always-saved variables section
345 if always_saved:
346 markdown += '!!! success "Registers the following variables:"\n'
347 for var in always_saved:
348 var_name = var.get("name", "N/A")
349 var_desc = var.get("description", "")
350 markdown += f" - `{var_name}`: {var_desc}\n"
351 markdown += "\n"
352
353 # Toggled variables sections
354 for condition, vars_list in toggled_vars.items():
355 markdown += (
356 f'!!! success "Additional variables toggled by `{condition}`:"\n'
357 )
358 for var in vars_list:
359 var_name = var.get("name", "N/A")
360 var_desc = var.get("description", "")
361 markdown += f" - `{var_name}`: {var_desc}\n"
362 markdown += "\n"
363 else:
364 logger.warning(
365 f"Block {block_info.get('class')} didn't register any output variables."
366 )
367
368 return markdown
369
370
372 input_filepath: str, output_filepath: str, block_data: List[Dict[str, Any]]
373) -> None:
374 """
375 Process an input markdown file, replacing AUTOGEN<BlockName> markers
376 with generated block documentation.
377
378 Looks for lines of the form "AUTOGEN<BlockName>" and replaces them
379 with the generated markdown for that block. All other content is
380 left untouched.
381
382 Args:
383 input_filepath: Path to the input markdown file
384 output_filepath: Path to write the processed output
385 block_data: List of extracted block information dictionaries
386
387 Raises:
388 FileNotFoundError: If input file does not exist
389 ValueError: If a referenced block is not found in block_data
390 """
391 # Create a mapping of block names to their markdown
392 block_markdown_map = {
393 block["class"]: generate_block_markdown(block) for block in block_data
394 }
395
396 # Read input file
397 with open(input_filepath, "r") as f:
398 lines = f.readlines()
399
400 output_lines = []
401 for line in lines:
402 stripped = line.strip()
403
404 # Check if this line is an AUTOGEN marker
405 if stripped.startswith("AUTOGEN<") and stripped.endswith(">"):
406 # Extract block name from AUTOGEN<BlockName>
407 block_name = stripped[8:-1] # Remove 'AUTOGEN<' and '>'
408
409 if block_name in block_markdown_map:
410 output_lines.append(block_markdown_map[block_name])
411 else:
412 raise ValueError(
413 f"Block '{block_name}' not found in extracted block data"
414 )
415 else:
416 output_lines.append(line)
417
418 # Write output file
419 with open(output_filepath, "w") as f:
420 f.writelines(output_lines)
421
422 logger.info(f"Processed markdown saved to {output_filepath}.")
Dict[str, List[Dict[str, Any]]] load_output_variables(str yaml_filepath)
None save_as_yaml(List[Dict[str, Any]] data, str filepath)
str generate_block_markdown(Dict[str, Any] block_info)
None process_markdown_with_autogen(str input_filepath, str output_filepath, List[Dict[str, Any]] block_data)
Dict[str, Any] extract_block_options(Type block_class)
Optional[str] interpret_physical_unit(str info)
List[Dict[str, Any]] extract_from_classes(List[Type] block_classes, Optional[Union[str, List[str]]] output_vars_yaml=None)