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
12import 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 else:
158 # TODO: any other cases to handle?
159 return f"`{A}::{B}`"
160
161 return re.sub(pattern, replace_match, info)
162
163
164def link_jira_tickets(info: str) -> str:
165 """
166 Convert JIRA ticket references in a string to Markdown links.
167
168 - JIRA tickets are of the form: all-caps letters, a dash, then digits (e.g., ATLASG-2358)
169 - Converted to Markdown links: [ATLASG-2358](https://its.cern.ch/jira/browse/ATLASG-2358)
170
171 Args:
172 info: Input string that may contain JIRA tickets.
173
174 Returns:
175 The string with JIRA tickets converted to Markdown links.
176 """
177 if not info:
178 return info
179
180 # Regex pattern: one or more uppercase letters, dash, one or more digits
181 pattern = r"\b([A-Z]+-\d+)\b"
182
183 def replace_match(match):
184 ticket = match.group(1)
185 url = f"https://its.cern.ch/jira/browse/{ticket}"
186 return f"[{ticket}]({url})"
187
188 return re.sub(pattern, replace_match, info)
189
190
192 block_classes: List[Type], output_vars_yaml: Optional[Union[str, List[str]]] = None
193) -> List[Dict[str, Any]]:
194 """
195 Extract options information from a list of ConfigBlock classes and merge
196 with output variables metadata.
197
198 Args:
199 block_classes: List of classes that inherit from ConfigBlock
200 output_vars_yaml: Optional path to YAML file or list of paths to YAML files
201 containing output variables. If multiple files provided,
202 their contents will be merged. Files are located using
203 find_datafile.
204
205 Returns:
206 List of dictionaries, each containing information about a block class
207 """
208 # Load output variables if YAML file(s) provided
209 output_vars_map = {}
210 if output_vars_yaml:
211 # Normalize to list for uniform processing
212 yaml_files = (
213 [output_vars_yaml]
214 if isinstance(output_vars_yaml, str)
215 else output_vars_yaml
216 )
217
218 # Load and merge all YAML files
219 for yaml_file in yaml_files:
220 # Locate the file
221 resolved_path = find_datafile(yaml_file)
222 if resolved_path is None:
223 raise FileNotFoundError(f"Could not locate YAML file: {yaml_file}")
224
225 file_vars = load_output_variables(resolved_path)
226 # Merge with existing map (later files can override earlier ones)
227 for class_name, variables in file_vars.items():
228 if class_name in output_vars_map:
229 # Merge variable lists, avoiding duplicates if needed
230 output_vars_map[class_name].extend(variables)
231 else:
232 output_vars_map[class_name] = variables
233
234 results = []
235 for block_class in block_classes:
236 info = extract_block_options(block_class)
237 # Merge output variables if available
238 class_name = block_class.__name__
239 info["output_variables"] = output_vars_map.get(class_name, [])
240
241 results.append(info)
242
243 return results
244
245
246def save_as_yaml(data: List[Dict[str, Any]], filepath: str) -> None:
247 """Save extracted data as YAML."""
248 with open(filepath, "w") as f:
249 yaml.dump(data, f, default_flow_style=False, sort_keys=False)
250 logger.info(f"Saved YAML to {filepath}")
251
252
253def generate_block_markdown(block_info: Dict[str, Any]) -> str:
254 """
255 Generate Markdown documentation for a single block.
256
257 Args:
258 block_info: Dictionary containing block information with keys:
259 - class: Block class name
260 - module: Module containing the block
261 - options: List of option dictionaries
262 - output_variables: List of output variable dictionaries
263
264 Returns:
265 Markdown string for this block
266 """
267 markdown = ""
268
269 # Options section
270 if block_info.get("options"):
271 for opt in block_info["options"]:
272 name = opt["label"]
273
274 # Skip these settings unless they are True
275 if name in ["skipOnData", "skipOnMC", "skipWithSystematics"]:
276 if not opt["default"] is True:
277 continue
278 # Skip these settings unless they are set
279 if name in ["onlyForDSIDs"]:
280 if not opt["default"] is []:
281 continue
282
283 # Option label with type, expert flag, required flag
284 label = f"`{opt['label']}` ({opt['type']})"
285 if opt["expertMode"] is not None:
286 expertOptions = list(opt["expertMode"])
287 label += f" **[expert-only options: {','.join(['`' + str(x) + '`' for x in expertOptions])}]**"
288 if opt["required"] is True or opt["noneAction"] != "ignore":
289 label += " **[REQUIRED]**"
290
291 markdown += f"{label}\n"
292 info_string = opt["info"]
293 info_string = process_info_links(info_string)
294 info_string = link_jira_tickets(info_string)
295 markdown += f": {info_string}"
296
297 if opt.get("default") != "":
298 default_val = opt["default"]
299 default_str = repr(default_val)
300
301 # Add unit information if available
302 unit = opt.get("physicalUnit")
303 if unit is None or default_val is None:
304 default_display = f"`{default_str}`"
305 elif unit == "GeV":
306 default_display = f"`{default_str}` GeV"
307 elif unit == "MeV":
308 # Convert MeV to GeV for simplified display
309 try:
310 if isinstance(default_val, (list, tuple)):
311 converted = [float(x) / 1000 for x in default_val]
312 converted_str = (
313 "[" + ", ".join(f"{x}" for x in converted) + "]"
314 )
315 else:
316 converted = float(default_val) / 1000
317 converted_str = f"{converted}"
318 except (TypeError, ValueError):
319 converted_str = "?"
320 default_display = f"`{default_str}` MeV (`{converted_str}` GeV)"
321 else:
322 default_display = f"`{default_str}` {unit}"
323
324 markdown += f" Default: {default_display}."
325
326 markdown += "\n\n"
327
328 # Output variables section
329 if block_info.get("output_variables"):
330 # Separate variables into always-saved and toggled
331 always_saved = []
332 toggled_vars = {} # toggled_by condition -> list of variables
333
334 for var in block_info["output_variables"]:
335 if var.get("toggled_by"):
336 condition = var["toggled_by"]
337 if condition not in toggled_vars:
338 toggled_vars[condition] = []
339 toggled_vars[condition].append(var)
340 else:
341 always_saved.append(var)
342
343 # Always-saved variables section
344 if always_saved:
345 markdown += '!!! success "Registers the following variables:"\n'
346 for var in always_saved:
347 var_name = var.get("name", "N/A")
348 var_desc = var.get("description", "")
349 markdown += f" - `{var_name}`: {var_desc}\n"
350 markdown += "\n"
351
352 # Toggled variables sections
353 for condition, vars_list in toggled_vars.items():
354 markdown += (
355 f'!!! success "Additional variables toggled by `{condition}`:"\n'
356 )
357 for var in vars_list:
358 var_name = var.get("name", "N/A")
359 var_desc = var.get("description", "")
360 markdown += f" - `{var_name}`: {var_desc}\n"
361 markdown += "\n"
362 else:
363 logger.warning(
364 f"Block {block_info.get('class')} didn't register any output variables."
365 )
366
367 return markdown
368
369
371 input_filepath: str, output_filepath: str, block_data: List[Dict[str, Any]]
372) -> None:
373 """
374 Process an input markdown file, replacing AUTOGEN<BlockName> markers
375 with generated block documentation.
376
377 Looks for lines of the form "AUTOGEN<BlockName>" and replaces them
378 with the generated markdown for that block. All other content is
379 left untouched.
380
381 Args:
382 input_filepath: Path to the input markdown file
383 output_filepath: Path to write the processed output
384 block_data: List of extracted block information dictionaries
385
386 Raises:
387 FileNotFoundError: If input file does not exist
388 ValueError: If a referenced block is not found in block_data
389 """
390 # Create a mapping of block names to their markdown
391 block_markdown_map = {
392 block["class"]: generate_block_markdown(block) for block in block_data
393 }
394
395 # Read input file
396 with open(input_filepath, "r") as f:
397 lines = f.readlines()
398
399 output_lines = []
400 for line in lines:
401 stripped = line.strip()
402
403 # Check if this line is an AUTOGEN marker
404 if stripped.startswith("AUTOGEN<") and stripped.endswith(">"):
405 # Extract block name from AUTOGEN<BlockName>
406 block_name = stripped[8:-1] # Remove 'AUTOGEN<' and '>'
407
408 if block_name in block_markdown_map:
409 output_lines.append(block_markdown_map[block_name])
410 else:
411 raise ValueError(
412 f"Block '{block_name}' not found in extracted block data"
413 )
414 else:
415 output_lines.append(line)
416
417 # Write output file
418 with open(output_filepath, "w") as f:
419 f.writelines(output_lines)
420
421 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)