@@ -20,6 +20,7 @@ Run this tool from the repository root directory.
2020
2121import ast
2222import os
23+ import re
2324import sys
2425
2526import yaml
@@ -130,76 +131,199 @@ def extract_plugin_docs(filepath):
130131 }
131132
132133
134+ _ANSIBLE_DOC_MARKUP = re .compile (r'([CIBLMOPRUV])\(([^)]+)\)' )
135+
136+
137+ def _convert_ansible_markup (text ):
138+ """Convert Ansible's `C(foo)` / `I(foo)` / `B(foo)` / `M(module)` / `U(url)` / `L(text,url)`
139+ inline markup to Markdown. Stays close to Ansible's own rendering: code-span for
140+ C / O / V, italic for I, bold for B, plain text for L's display part.
141+ """
142+ if not text :
143+ return text
144+ def _sub (match ):
145+ kind , body = match .group (1 ), match .group (2 )
146+ if kind in ('C' , 'O' , 'V' ):
147+ return f'`{ body } `'
148+ if kind == 'I' :
149+ return f'*{ body } *'
150+ if kind == 'B' :
151+ return f'**{ body } **'
152+ if kind == 'M' :
153+ return f'`{ body } `'
154+ if kind == 'U' :
155+ return f'<{ body } >'
156+ if kind == 'L' :
157+ # L(label, url)
158+ if ',' in body :
159+ label , url = (s .strip () for s in body .split (',' , 1 ))
160+ return f'[{ label } ]({ url } )'
161+ return body
162+ if kind in ('P' , 'R' ):
163+ return f'`{ body } `'
164+ return match .group (0 )
165+ return _ANSIBLE_DOC_MARKUP .sub (_sub , text )
166+
167+
133168def _flatten_description (value ):
134- """Description fields can be a string or a list of strings; return a single string."""
169+ """Description fields can be a string or a list of strings; return a single string
170+ with Ansible inline markup converted to Markdown.
171+ """
135172 if value is None :
136173 return ''
137174 if isinstance (value , list ):
138- return ' ' .join (str (line ).strip () for line in value if line is not None )
139- return str (value ).strip ()
175+ joined = ' ' .join (str (line ).strip () for line in value if line is not None )
176+ else :
177+ joined = str (value ).strip ()
178+ return _convert_ansible_markup (joined )
179+
180+
181+ _TYPE_LABELS = {
182+ 'str' : 'String' ,
183+ 'int' : 'Number' ,
184+ 'float' : 'Number' ,
185+ 'bool' : 'Bool' ,
186+ 'list' : 'List' ,
187+ 'dict' : 'Dictionary' ,
188+ 'path' : 'Path' ,
189+ 'raw' : 'Raw' ,
190+ 'json' : 'JSON' ,
191+ 'jsonarg' : 'JSON' ,
192+ }
193+
194+
195+ def _pretty_type (otype ):
196+ """Map Ansible's wire-level type names to the labels the role-style READMEs use
197+ (`str` -> `String`, `int` -> `Number`, etc.).
198+ """
199+ if isinstance (otype , list ):
200+ return ' / ' .join (_pretty_type (t ) for t in otype )
201+ if not otype :
202+ return ''
203+ key = str (otype ).lower ()
204+ return _TYPE_LABELS .get (key , str (otype ).capitalize ())
205+
206+
207+ def _format_default (value ):
208+ """Render an option's default value the same way the role-style READMEs do."""
209+ if value is None :
210+ return None
211+ if isinstance (value , bool ):
212+ return f'`{ str (value ).lower ()} `'
213+ if isinstance (value , str ) and value == '' :
214+ return "`''`"
215+ return f'`{ value } `'
140216
141217
142- def _render_options_table (options , level = 0 ):
143- """Render an options block as a Markdown table. Suboptions are folded into the
144- description column as a nested bullet list to keep the table flat.
218+ def _format_type (opt ):
219+ """Render `Type: <type>[. One of <a>, <b>, <c>.]` for an option dict, using the
220+ role-style type labels (`String`, `Number`, `Bool`, `List`, `Dictionary`, ...).
221+ """
222+ type_str = _pretty_type (opt .get ('type' , '' ))
223+ choices = opt .get ('choices' ) or []
224+ if choices :
225+ choices_str = ', ' .join (f'`{ c } `' for c in choices )
226+ return f'Type: { type_str } . One of { choices_str } .'
227+ return f'Type: { type_str } .'
228+
229+
230+ def _render_option_block (name , opt , depth = 0 ):
231+ """Render one option in the LFOps role-style definition format.
232+
233+ At depth=0 (top-level option), the variable name is a bare heading-like
234+ line and the bullets sit at column 0:
235+
236+ `name`
237+
238+ * Description.
239+ * Type: ...
240+ * Default: ...
241+
242+ At depth>=1 (suboption), the name becomes a bullet item indented under
243+ its parent's "Subkeys:" line and its own bullets indent one more level.
244+ The pattern matches `roles/example/README.md`.
245+ """
246+ opt = opt or {}
247+ if depth == 0 :
248+ heading = f'`{ name } `'
249+ heading_indent = ''
250+ else :
251+ heading = f'* `{ name } `:'
252+ heading_indent = ' ' * (2 * depth - 1 )
253+ bullet_indent = ' ' * (2 * depth )
254+
255+ lines = [f'{ heading_indent } { heading } ' , '' ]
256+
257+ description = _flatten_description (opt .get ('description' )) or '(no description)'
258+ lines .append (f'{ bullet_indent } * { description } ' )
259+ lines .append (f'{ bullet_indent } * { _format_type (opt )} ' )
260+
261+ default = _format_default (opt .get ('default' ))
262+ if default is not None :
263+ lines .append (f'{ bullet_indent } * Default: { default } ' )
264+
265+ suboptions = opt .get ('suboptions' ) or {}
266+ if suboptions :
267+ lines .append (f'{ bullet_indent } * Subkeys:' )
268+ lines .append ('' )
269+ for sub_name in sorted (suboptions ):
270+ lines .extend (_render_option_block (sub_name , suboptions [sub_name ], depth + 1 ))
271+ lines .append ('' )
272+ return lines
273+
274+
275+ def _render_options_section (options ):
276+ """Render an options block as role-style "Mandatory" / "Optional" sections.
277+ Returns a list of Markdown lines (with H2 headings already emitted).
145278 """
146279 if not options :
147280 return []
281+ mandatory = [n for n in sorted (options ) if (options [n ] or {}).get ('required' )]
282+ optional = [n for n in sorted (options ) if not (options [n ] or {}).get ('required' )]
283+
148284 lines = []
149- indent = ' ' * level
150- if level == 0 :
151- lines .append ('| Parameter | Type | Required | Default | Choices | Description |' )
152- lines .append ('|---|---|---|---|---|---|' )
153- for opt_name in sorted (options ):
154- opt = options [opt_name ] or {}
155- otype = opt .get ('type' , '' )
156- if isinstance (otype , list ):
157- otype = ' / ' .join (str (t ) for t in otype )
158- required = 'yes' if opt .get ('required' ) else ''
159- default = opt .get ('default' )
160- if default is None :
161- default_str = ''
162- elif isinstance (default , bool ):
163- default_str = str (default ).lower ()
164- else :
165- default_str = f'`{ default } `'
166- choices = opt .get ('choices' ) or []
167- choices_str = ', ' .join (f'`{ c } `' for c in choices )
168- description = _flatten_description (opt .get ('description' ))
169- suboptions = opt .get ('suboptions' ) or {}
170- if suboptions :
171- sub_bullets = []
172- for sub_name in sorted (suboptions ):
173- sub = suboptions [sub_name ] or {}
174- sub_desc = _flatten_description (sub .get ('description' ))
175- sub_type = sub .get ('type' , '' )
176- sub_req = ' (required)' if sub .get ('required' ) else ''
177- sub_bullets .append (
178- f'<br> • **{ sub_name } ** ({ sub_type } ){ sub_req } : { sub_desc } '
179- )
180- description = description + '' .join (sub_bullets )
181- lines .append (
182- f'{ indent } | **{ opt_name } ** | { otype } | { required } | '
183- f'{ default_str } | { choices_str } | { description } |'
184- )
285+ if mandatory :
286+ lines .append ('## Mandatory Parameters' )
287+ lines .append ('' )
288+ for name in mandatory :
289+ lines .extend (_render_option_block (name , options [name ]))
290+ if optional :
291+ lines .append ('## Optional Parameters' )
292+ lines .append ('' )
293+ for name in optional :
294+ lines .extend (_render_option_block (name , options [name ]))
185295 return lines
186296
187297
188- def _render_return_table (returns ):
189- """Render the RETURN block as a Markdown table ."""
298+ def _render_return_section (returns ):
299+ """Render the RETURN block as a role-style definition list ."""
190300 if not returns :
191301 return []
192- lines = []
193- lines .append ('| Key | Type | Returned | Description |' )
194- lines .append ('|---|---|---|---|' )
302+ lines = ['## Return Values' , '' ]
195303 for ret_name in sorted (returns ):
196304 ret = returns [ret_name ] or {}
305+ lines .append (f'`{ ret_name } `' )
306+ lines .append ('' )
307+ description = _flatten_description (ret .get ('description' )) or '(no description)'
308+ lines .append (f'* { description } ' )
197309 rtype = ret .get ('type' , '' )
198- returned = ret .get ('returned' , '' )
199- description = _flatten_description (ret .get ('description' ))
200- lines .append (
201- f'| **{ ret_name } ** | { rtype } | { returned } | { description } |'
202- )
310+ if rtype :
311+ lines .append (f'* Type: { _pretty_type (rtype )} .' )
312+ returned = ret .get ('returned' )
313+ if returned :
314+ lines .append (f'* Returned: { returned } .' )
315+ sample = ret .get ('sample' )
316+ if sample is not None :
317+ if isinstance (sample , (dict , list )):
318+ lines .append ('* Sample:' )
319+ lines .append ('' )
320+ lines .append (' ```yaml' )
321+ for sline in yaml .safe_dump (sample , default_flow_style = False ).rstrip ().split ('\n ' ):
322+ lines .append (f' { sline } ' )
323+ lines .append (' ```' )
324+ else :
325+ lines .append (f'* Sample: `{ sample } `' )
326+ lines .append ('' )
203327 return lines
204328
205329
@@ -221,7 +345,7 @@ def render_plugin_md(kind, name, parsed):
221345 lines = [f'# `{ title } `' , '' ]
222346
223347 if short :
224- lines .append (f'> { short } ' )
348+ lines .append (f'> { _convert_ansible_markup ( short ) } ' )
225349 lines .append ('' )
226350
227351 if deprecated :
@@ -243,7 +367,7 @@ def render_plugin_md(kind, name, parsed):
243367 if isinstance (description , str ):
244368 description = [description ]
245369 for paragraph in description :
246- lines .append (f'* { str (paragraph ).strip ()} ' )
370+ lines .append (f'* { _convert_ansible_markup ( str (paragraph ).strip () )} ' )
247371 lines .append ('' )
248372
249373 if version_added :
@@ -254,14 +378,11 @@ def render_plugin_md(kind, name, parsed):
254378 lines .append ('## Requirements' )
255379 lines .append ('' )
256380 for req in (requirements if isinstance (requirements , list ) else [requirements ]):
257- lines .append (f'* { req } ' )
381+ lines .append (f'* { _convert_ansible_markup ( str ( req ). strip ()) } ' )
258382 lines .append ('' )
259383
260384 if options :
261- lines .append ('## Parameters' )
262- lines .append ('' )
263- lines .extend (_render_options_table (options ))
264- lines .append ('' )
385+ lines .extend (_render_options_section (options ))
265386
266387 if parsed .get ('examples' ):
267388 lines .append ('## Examples' )
@@ -272,23 +393,20 @@ def render_plugin_md(kind, name, parsed):
272393 lines .append ('' )
273394
274395 if parsed .get ('return' ):
275- lines .append ('## Return Values' )
276- lines .append ('' )
277- lines .extend (_render_return_table (parsed ['return' ]))
278- lines .append ('' )
396+ lines .extend (_render_return_section (parsed ['return' ]))
279397
280398 if notes :
281399 lines .append ('## Notes' )
282400 lines .append ('' )
283401 for note in (notes if isinstance (notes , list ) else [notes ]):
284- lines .append (f'* { str (note ).strip ()} ' )
402+ lines .append (f'* { _convert_ansible_markup ( str (note ).strip () )} ' )
285403 lines .append ('' )
286404
287405 if authors :
288406 lines .append ('## Authors' )
289407 lines .append ('' )
290408 for author in (authors if isinstance (authors , list ) else [authors ]):
291- lines .append (f'* { author } ' )
409+ lines .append (f'* { _convert_ansible_markup ( str ( author ). strip ()) } ' )
292410 lines .append ('' )
293411
294412 return '\n ' .join (lines ).rstrip () + '\n '
0 commit comments