argparse-help-markdown 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: argparse-help-markdown
3
+ Version: 0.1.0
4
+ Summary: Pull argparse configuration out of a CLI tool and write it as Markdown. Great for embedding in a README.md with cog.
5
+ Author-email: ellieayla <1447600+me@users.noreply.github.com>
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+
9
+ Pull argparse configuration out of a CLI tool and write it as Markdown.
10
+
11
+ Great for embedded in a README.md with cog.
12
+
13
+ Possible invocation options:
14
+
15
+ ```sh
16
+ # entrypoint command, dotted module
17
+ uv run argparse-help-markdown -m tests.data.example
18
+
19
+ # script, script
20
+ argparse_help_markdown.py script.py
21
+
22
+ # import, function, custom writer
23
+ from argparse_help_markdown import run_script
24
+ with open("out.md", mode="w") as f:
25
+ run_script("mytool.py", include_usage=True, writer=f)
26
+ ```
27
+
28
+ <!-- [[[cog
29
+ import src.argparse_help_markdown as m
30
+ m.run(filename="src/argparse_help_markdown.py", include_usage=True, writer=None)
31
+ ]]] -->
32
+ ```
33
+ usage: cog script.py
34
+ ```
35
+
36
+ | Options | Values | Help |
37
+ | ------- | ------- | ---- |
38
+ | *positional arguments* | |
39
+ | <pre>filename</pre> | Optional. | Path to the subject script. |
40
+ | *options* | |
41
+ | <pre>-h --help</pre> | Flag. | show this help message and exit |
42
+ | <pre>--usage</pre> | Flag. | Emit the terse usage info in triple-ticks. Excluded by default. |
43
+ | <pre>--write</pre> | Optional. | Write to named file instead of stdout. |
44
+ | <pre>-m --module</pre> | Optional. | Run as module. |
45
+ <!-- [[[end]]] -->
@@ -0,0 +1,6 @@
1
+ argparse_help_markdown.py,sha256=Kr6ra1IodPZUs1cuw73SHbS9bQsvu37T31_c0nd24O8,14915
2
+ argparse_help_markdown-0.1.0.dist-info/METADATA,sha256=Ptp9rCk0ygzXzeuNB-MjY0k92fm1Nc_ShP8Tm-mmxlo,1456
3
+ argparse_help_markdown-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
4
+ argparse_help_markdown-0.1.0.dist-info/entry_points.txt,sha256=SIlqLN8FnijFjQxI5xkpJrO2dxARXFY7rd7jVi2jH0Y,71
5
+ argparse_help_markdown-0.1.0.dist-info/top_level.txt,sha256=tpSjVPrQW916VGQwrG1zI_6ioXQYXO_lAh_xw7JcLbw,23
6
+ argparse_help_markdown-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ argparse-help-markdown = argparse_help_markdown:main
@@ -0,0 +1 @@
1
+ argparse_help_markdown
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import importlib.machinery
5
+ import importlib.util
6
+ import os
7
+ import sys
8
+ from contextlib import contextmanager
9
+ from importlib.machinery import ModuleSpec
10
+ from pathlib import Path
11
+ from types import CodeType, ModuleType
12
+ from typing import TYPE_CHECKING, Any, Callable, Generator, Iterable
13
+ from unittest.mock import patch
14
+
15
+ if TYPE_CHECKING:
16
+ from io import TextIOWrapper
17
+
18
+
19
+ class DummyLoader:
20
+ # See coveragepy, pep302
21
+ def __init__(self, fullname: str, *_args: Any) -> None:
22
+ self.fullname = fullname
23
+
24
+
25
+ def pre_tag(s: str) -> str:
26
+ """Wrap strings in <pre> tags."""
27
+ return f"<pre>{s}</pre>"
28
+
29
+
30
+ def wrap_in_backticks(s: str) -> str:
31
+ """Wrap `strings` in backticks."""
32
+ return f"`{s}`"
33
+
34
+
35
+ def escape_pipe(s: str) -> str:
36
+ """Escape | characters."""
37
+ return s.replace("|", r"\|")
38
+
39
+
40
+ class MarkdownFormatter(argparse.HelpFormatter):
41
+ """
42
+ ArgumentParser.format_help() calls the public api of HelpFormatter:
43
+
44
+ # usage
45
+ formatter.add_usage(self.usage, self._actions,
46
+ self._mutually_exclusive_groups)
47
+
48
+ # description
49
+ formatter.add_text(self.description)
50
+
51
+ # positionals, optionals and user-defined groups
52
+ for action_group in self._action_groups:
53
+ formatter.start_section(action_group.title)
54
+ formatter.add_text(action_group.description)
55
+ formatter.add_arguments(action_group._group_actions)
56
+ formatter.end_section()
57
+
58
+ # epilog
59
+ formatter.add_text(self.epilog)
60
+
61
+ # determine help from format above
62
+ return formatter.format_help()
63
+
64
+ Normally HelpFormatter creates a list of callback functions to produce help text,
65
+ but how is *not* part of the public api. We can do whatever we want,
66
+ as long as format_help() returns a string.
67
+ """
68
+
69
+ prog: str
70
+ items: list[tuple[Callable[..., str], Iterable[Any]]]
71
+ in_table: bool = False
72
+
73
+ def __init__(self, prog: str, *_args: Iterable[Any]) -> None:
74
+ """Sneak away the prog name, ignore other arguments."""
75
+ # may need to deal with args/kwargs?
76
+ self.prog = prog
77
+ self.items = []
78
+
79
+ def _set_color(self, color: bool) -> None: # noqa: ARG002, FBT001
80
+ """Compatability: Called directly by ArgumentParser._get_formatter(). Make it a no-op."""
81
+ return
82
+
83
+ def ensure_header(self) -> str:
84
+ if not self.in_table:
85
+ self.in_table = True
86
+ return (
87
+ "| Options | Values | Help |\n"
88
+ "| ------- | ------- | ---- |\n"
89
+ ) # fmt: skip
90
+ return "" # pragma: no cover
91
+
92
+ def _add_item(self, func: Callable[[Any], str], args: Iterable[Any]) -> None:
93
+ self.items.append((func, args))
94
+
95
+ def arguments_column(self, action: argparse.Action) -> str:
96
+ default = action.dest
97
+ if action.option_strings:
98
+ return pre_tag(" ".join(action.option_strings))
99
+ return pre_tag(default)
100
+
101
+ def _format_action(self, action: argparse.Action) -> str:
102
+ if action.type and isinstance(action.type, type):
103
+ typename = action.type.__name__
104
+ else:
105
+ typename = "Unknown"
106
+
107
+ # 1
108
+ column_one = self.arguments_column(action)
109
+
110
+ # 2
111
+ column_two_parts: list[str] = []
112
+
113
+ if (
114
+ action.const is None # fmt: skip
115
+ and action.required
116
+ and action.nargs != 0
117
+ ):
118
+ column_two_parts.append(action.dest.upper())
119
+
120
+ if action.nargs == 0:
121
+ column_two_parts.append("Flag.")
122
+ elif action.required:
123
+ column_two_parts.append("Required.")
124
+ elif not action.default or action.default is argparse.SUPPRESS:
125
+ column_two_parts.append("Optional.")
126
+
127
+ if action.type is not None:
128
+ column_two_parts.append(f"Type: {typename}")
129
+ if action.choices:
130
+ column_two_parts.append(f"Choice: {', '.join([wrap_in_backticks(x) for x in action.choices])}")
131
+ if action.default and action.default is not argparse.SUPPRESS and action.const is None:
132
+ column_two_parts.append(f"Default: {wrap_in_backticks(action.default)}")
133
+
134
+ # 3
135
+ if action.help and action.help.strip():
136
+ column_three = action.help.strip()
137
+ else:
138
+ column_three = "" # no help
139
+ return f"| {column_one} | {'<br/>'.join(column_two_parts)} | {column_three} |\n"
140
+
141
+ # TODO:
142
+ # if there are any sub-actions, add their help as well
143
+ # for subaction in self._iter_indented_subactions(action):
144
+ # parts.append(self._format_action(subaction))
145
+
146
+ def add_argument(self, action: argparse.Action) -> None:
147
+ if action.help is not argparse.SUPPRESS:
148
+ self._add_item(self._format_action, [action])
149
+
150
+ def add_arguments(self, actions: Iterable[argparse.Action]) -> None:
151
+ for action in actions:
152
+ self.add_argument(action)
153
+
154
+ def _format_text(self, text: str) -> str:
155
+ return f"| {escape_pipe(text)} | |\n"
156
+
157
+ def add_text(self, text: str | None) -> None:
158
+ if text:
159
+ self._add_item(self._format_text, [text])
160
+
161
+ def add_usage(
162
+ self,
163
+ usage: str | None,
164
+ actions: Iterable[argparse.Action],
165
+ groups: Iterable[Any],
166
+ prefix: str | None = None,
167
+ ) -> None:
168
+ """No implementation; usage doesn't go in the table. Just ignore the arguments."""
169
+
170
+ def _format_heading(self, text: str) -> str:
171
+ return f"| *{escape_pipe(text)}* | |\n"
172
+
173
+ def start_section(self, heading: str | None) -> None:
174
+ if heading:
175
+ self._add_item(self._format_heading, [heading])
176
+
177
+ def end_section(self) -> None:
178
+ return
179
+
180
+ def _join_parts(self, part_strings: Iterable[str]) -> str:
181
+ return "".join([part for part in part_strings if part and part is not argparse.SUPPRESS])
182
+
183
+ def format_help(self) -> str:
184
+ """
185
+ The workhorse. Run all the callbacks in self.items and return a giant string,
186
+ similar to argparse.HelpFormatter._Section.format_help()
187
+ """
188
+
189
+ item_help = self._join_parts(
190
+ [func(*args) for func, args in self.items],
191
+ )
192
+ return self.ensure_header() + item_help
193
+
194
+
195
+ class ParseArgsNotCalledError(Exception):
196
+ """Failure - parse_args() was never called."""
197
+
198
+
199
+ class FinishedGettingOutput(Exception): # noqa: N818
200
+ """Success 'exception' used to escape from inside argparse."""
201
+
202
+
203
+ class NoSourceError(ValueError):
204
+ """Unable to find/load the source for the given module/script"""
205
+
206
+
207
+ @contextmanager
208
+ def open_file_for_writing_or_none(write_filename: str | None) -> Generator[TextIOWrapper | None, None, None]:
209
+ """If a write_filename was passed, open it for writing. Otherwise use stdout."""
210
+ if write_filename is not None:
211
+ with open(file=write_filename, mode="w") as fh:
212
+ yield fh
213
+ else:
214
+ yield None
215
+
216
+
217
+ def main() -> int:
218
+ """
219
+ Parse CLI arguments, open an output file if needed, and run against a script.
220
+
221
+ TODO: Support modules.
222
+ """
223
+ p = argparse.ArgumentParser(usage="%(prog)s script.py")
224
+ p.add_argument("--usage", action="store_true", help="Emit the terse usage info in triple-ticks. Excluded by default.")
225
+ p.add_argument("--write", metavar="out.md", help="Write to named file instead of stdout.")
226
+
227
+ subject_group = p.add_mutually_exclusive_group(required=True)
228
+ subject_group.add_argument("-m", "--module", help="Run as module.")
229
+ subject_group.add_argument("filename", nargs="?", metavar="script.py", help="Path to the subject script.")
230
+
231
+ options = p.parse_args()
232
+
233
+ try:
234
+ with open_file_for_writing_or_none(options.write) as fh:
235
+ run(
236
+ filename=(options.module if options.module else options.filename),
237
+ include_usage=options.usage,
238
+ writer=fh,
239
+ as_module=(options.module is not None),
240
+ )
241
+ return 0
242
+ except ParseArgsNotCalledError:
243
+ p.error("Subject script never called parse_args(). You might need to pass additional arguments or environment variables.")
244
+
245
+
246
+ def find_module(module_name: str) -> tuple[str | None, str, ModuleSpec]:
247
+ try:
248
+ spec = importlib.util.find_spec(module_name)
249
+ except ImportError: # pragma: no cover
250
+ raise NoSourceError(module_name)
251
+ if not spec:
252
+ raise NoSourceError(module_name)
253
+
254
+ path_name = spec.origin
255
+ package_name = spec.name
256
+ if spec.submodule_search_locations:
257
+ mod_main = module_name + ".__main__"
258
+ spec = importlib.util.find_spec(mod_main)
259
+ if not spec:
260
+ raise NoSourceError("Is a package and cannot be directly executed")
261
+
262
+ path_name = spec.origin
263
+ package_name = spec.name
264
+
265
+ package_name = package_name.rpartition(".")[0]
266
+ return path_name, package_name, spec
267
+
268
+
269
+ class Loader:
270
+ """inspired by coveragepy"""
271
+
272
+ filename_or_module: str
273
+ as_module: bool = False
274
+
275
+ package: str | None = None
276
+ loader: DummyLoader
277
+
278
+ def __init__(self, filename_or_module: str, as_module: bool):
279
+ self.filename_or_module = filename_or_module
280
+ self.as_module = as_module
281
+
282
+ def prepare_sys_path(self) -> None:
283
+ """Set sys.path properly so the first element is appropriate from the perspective of filename_or_module."""
284
+
285
+ path0: str | None
286
+
287
+ if getattr(sys.flags, "safe_path", False): # -P # pragma: no cover
288
+ path0 = None
289
+ elif self.as_module:
290
+ path0 = os.getcwd()
291
+ elif os.path.isdir(self.filename_or_module):
292
+ # if passed a directory, we really want the __main__.py file inside it, and set the path to the container
293
+ path0 = self.filename_or_module
294
+ else:
295
+ path0 = os.path.abspath(os.path.dirname(self.filename_or_module))
296
+
297
+ if path0 is not None:
298
+ sys.path[0] = os.path.abspath(path0)
299
+
300
+ def generate_spec(self) -> None:
301
+ """
302
+ Resolve the context (eg, a package) for the code to run.
303
+ """
304
+ if self.as_module:
305
+ """python -m modulename"""
306
+ self.module_name = self.filename_or_module
307
+ path_name, self.package, self.spec = find_module(self.module_name)
308
+ if self.spec is not None:
309
+ self.module_name = self.spec.name
310
+ self.loader = DummyLoader(self.module_name)
311
+ if path_name is None: # pragma: no cover
312
+ raise NoSourceError("path_name is None")
313
+ self.path_name = os.path.abspath(path_name)
314
+ self.filename_or_module = self.path_name
315
+
316
+ elif os.path.isdir(self.filename_or_module):
317
+ """python modulename/"""
318
+ for ext in (".py",): # don't support pyc/pyo
319
+ try_filename = os.path.abspath(os.path.join(self.filename_or_module, f"__main__{ext}"))
320
+ if os.path.exists(try_filename):
321
+ self.filename_or_module = try_filename
322
+ break
323
+ else:
324
+ raise NoSourceError(f"Can't find __main__ module in {self.filename_or_module}")
325
+
326
+ self.spec = importlib.machinery.ModuleSpec(
327
+ name="__main__",
328
+ loader=None,
329
+ origin=try_filename,
330
+ )
331
+ self.spec.has_location = True
332
+ self.package = ""
333
+ self.path_name = try_filename
334
+ self.loader = DummyLoader("__main__")
335
+ else:
336
+ self.path_name = self.filename_or_module
337
+ self.loader = DummyLoader("__main__")
338
+
339
+ def load_main_code(self) -> tuple[CodeType, ModuleType]:
340
+ filename: str = self.path_name
341
+ py_source = Path(filename).read_text()
342
+
343
+ py_code = compile(py_source, filename=filename, mode="exec", dont_inherit=True)
344
+
345
+ main_mod = ModuleType("__main__")
346
+ main_mod.__file__ = filename
347
+ main_mod.__loader__ = DummyLoader("__main__") # type: ignore[assignment]
348
+ main_mod.__builtins__ = sys.modules["builtins"] # type: ignore[attr-defined]
349
+
350
+ if self.package is not None:
351
+ main_mod.__package__ = self.package
352
+
353
+ return py_code, main_mod
354
+
355
+
356
+ def run_script(filename: str, include_usage: bool = False, cwd: Path | str | None = None) -> None:
357
+ """Public API, helpful for calling from cog"""
358
+ if cwd:
359
+ os.chdir(cwd)
360
+ run(filename=filename, as_module=False, include_usage=include_usage, writer=None)
361
+
362
+
363
+ def run_module(modulename: str, include_usage: bool = False, cwd: Path | str | None = None) -> None:
364
+ """Public API, helpful for calling from cog"""
365
+ if cwd:
366
+ os.chdir(cwd)
367
+ run(filename=modulename, as_module=True, include_usage=include_usage, writer=None)
368
+
369
+
370
+ def run(*, filename: str, as_module: bool = False, include_usage: bool, writer: TextIOWrapper | None = None) -> None:
371
+ """
372
+ Read source from filename, construct a __main__ module for it, patch argparse, and exec() the source.
373
+
374
+ Inspired by approach in coverage.py
375
+ """
376
+ r = Loader(filename, as_module=as_module)
377
+ r.prepare_sys_path()
378
+ r.generate_spec()
379
+ py_code, main_mod = r.load_main_code()
380
+
381
+ sys.modules["__main__"] = main_mod
382
+
383
+ # part of argparse's public api in >=3.14, and ignored earlier
384
+ os.environ["NO_COLOR"] = "1"
385
+
386
+ def print_help_cb(
387
+ parser: argparse.ArgumentParser,
388
+ args: list[str] | None = None, # noqa: ARG001 - unused argument
389
+ namespace: argparse.Namespace | None = None, # noqa: ARG001 - unused argument
390
+ ) -> None:
391
+ """Format and print the help instead."""
392
+ if include_usage:
393
+ usage_text = parser.format_usage()
394
+ print(f"```\n{usage_text.strip()}\n```\n", file=writer)
395
+
396
+ # patch attributes of the parser instance:
397
+ # formatter_class will get invoked and used to render to stdout/writer
398
+ # description/epilog=None so they don't mess up the middle of the table
399
+ with patch.multiple(
400
+ parser,
401
+ formatter_class=MarkdownFormatter,
402
+ description=None,
403
+ epilog=None,
404
+ ):
405
+ parser.print_help(file=writer)
406
+ raise FinishedGettingOutput # success
407
+
408
+ # Force the help to be printed as soon as one of the parser.parse_*args() functions is called,
409
+ # regardless of whether --help or any other argument was passed.
410
+ with patch.multiple(
411
+ argparse.ArgumentParser, # not instantiated yet
412
+ parse_args=print_help_cb,
413
+ parse_known_args=print_help_cb,
414
+ parse_intermixed_args=print_help_cb,
415
+ parse_known_intermixed_args=print_help_cb,
416
+ ):
417
+ try:
418
+ exec(py_code, main_mod.__dict__) # noqa: S102 = exec
419
+ raise ParseArgsNotCalledError # failure
420
+ except FinishedGettingOutput:
421
+ return # success
422
+
423
+
424
+ if __name__ == "__main__":
425
+ raise SystemExit(main()) # pragma: no cover