paramspecli 0.2.1__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.
paramspecli/conv.py ADDED
@@ -0,0 +1,58 @@
1
+ from argparse import ArgumentTypeError
2
+ from pathlib import Path
3
+ from typing import Final, Literal
4
+
5
+
6
+ class PathConv:
7
+ __name__ = "PATH"
8
+
9
+ __slots__ = ("exists", "resolve", "type")
10
+
11
+ def __init__(self, type: Literal["file", "dir", None] = None, *, exists: bool | None = None, resolve: bool = True):
12
+ self.exists: Final = exists
13
+ self.type: Final = type
14
+ self.resolve: Final = resolve
15
+
16
+ # scenarios:
17
+ # - existing path: check exists
18
+ # - existing file: check exists and is_file
19
+ # - existing dir: check exists and is_dir
20
+ # - non-existing path/file/dir: check not exists
21
+ # - maybe existing path: nothing
22
+ # - maybe existing file: if exists, check is_file
23
+ # - maybe existing dir: if exists, check is_dir
24
+ def __call__(self, arg: str) -> Path:
25
+ path = Path(arg)
26
+ if self.resolve:
27
+ path = path.resolve()
28
+
29
+ check_type = False
30
+
31
+ if self.exists is True:
32
+ if not path.exists():
33
+ raise ArgumentTypeError(f"{path} does not exists")
34
+ check_type = True
35
+ elif self.exists is False:
36
+ if path.exists():
37
+ raise ArgumentTypeError(f"{path} already exists")
38
+ else:
39
+ if self.type and path.exists():
40
+ check_type = True
41
+
42
+ if check_type:
43
+ if self.type == "file":
44
+ if not path.is_file():
45
+ raise ArgumentTypeError(f"{path} is not a file")
46
+ elif self.type == "dir":
47
+ if not path.is_dir():
48
+ raise ArgumentTypeError(f"{path} is not a directory")
49
+
50
+ return path
51
+
52
+ @classmethod
53
+ def file(cls, *, exists: bool | None = None, resolve: bool = True) -> "PathConv":
54
+ return cls("file", exists=exists, resolve=resolve)
55
+
56
+ @classmethod
57
+ def dir(cls, *, exists: bool | None = None, resolve: bool = True) -> "PathConv":
58
+ return cls("dir", exists=exists, resolve=resolve)
paramspecli/doc.py ADDED
@@ -0,0 +1,368 @@
1
+ import dataclasses
2
+ from argparse import BooleanOptionalAction
3
+ from typing import Any, Final, Iterable, Mapping, NamedTuple, Protocol, Sequence
4
+
5
+ from paramspecli import md
6
+
7
+ from .cli import Arg, Command, Group, Opt, Section, name_and_aliases
8
+
9
+
10
+ class Renderer(Protocol):
11
+ """
12
+ Doc render backend interface (protocol).
13
+ """
14
+
15
+ def p(self, text: str) -> str: ...
16
+ def h(self, level: int, text: str) -> str: ...
17
+ def br(self) -> str: ...
18
+ def b(self, text: str) -> str: ...
19
+ def i(self, text: str) -> str: ...
20
+ def blockquote(self, text: str) -> str: ...
21
+ def ul(self, lis: Iterable[str]) -> str: ...
22
+ def dl(self, dtdds: Iterable[tuple[str, str]]) -> str: ...
23
+ def code(self, text: str) -> str: ...
24
+ def codeblock(self, text: str, lang: str | None = None) -> str: ...
25
+ def strike(self, text: str) -> str: ...
26
+ def hr(self) -> str: ...
27
+ def link(self, url: str, *, text: str | None) -> str: ...
28
+ def e(self, text: str) -> str: ...
29
+ def body(self, text: str) -> str: ...
30
+ def postprocess(self, text: str) -> str: ...
31
+
32
+
33
+ class BoundGroup(NamedTuple):
34
+ name: str
35
+ aliases: tuple[str, ...]
36
+ obj: Group
37
+
38
+
39
+ class BoundCommand(NamedTuple):
40
+ name: str
41
+ aliases: tuple[str, ...]
42
+ obj: Command[...]
43
+
44
+
45
+ # A little helper joining all truthy strings with the sep
46
+ def _join(*args: str | None | bool | Iterable[str | None | bool], sep: str) -> str:
47
+ out: list[str] = []
48
+ for arg in args:
49
+ if isinstance(arg, str) and arg:
50
+ out.append(arg)
51
+ elif arg is not None and arg is not True and arg is not False:
52
+ out.extend(sub for sub in arg if isinstance(sub, str) and arg)
53
+ return sep.join(out)
54
+
55
+
56
+ @dataclasses.dataclass(kw_only=True)
57
+ class Settings:
58
+ """Documentation generator config"""
59
+
60
+ commands_title: str = "Commands"
61
+ arguments_title: str | None = "Arguments"
62
+ arguments_headline: str | None = None
63
+ options_title: str = "Options"
64
+ choices_title: str = "Possible values"
65
+ usage_title: str = "Usage"
66
+ aliases_title: str = "Aliases"
67
+ options_headline: str | None = None
68
+
69
+
70
+ class Doc:
71
+ """Documentation generator"""
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ renderer: Renderer | None = None,
77
+ settings: Settings | None = None,
78
+ ):
79
+ self.r: Final[Renderer] = renderer or md.Renderer()
80
+ self.config: Final = settings or Settings()
81
+
82
+ def r_choices(self, choices: Iterable[Any]) -> str:
83
+ r = self.r
84
+ out = ""
85
+
86
+ out += r.p(self.config.choices_title + ":")
87
+ if isinstance(choices, Mapping):
88
+ out += r.ul(r.code(str(c)) + ": " + str(desc) for c, desc in choices.items())
89
+ else:
90
+ out += r.ul(r.code(str(c)) for c in choices)
91
+ return out
92
+
93
+ def r_argument_title(self, arg: Arg) -> str:
94
+ r = self.r
95
+
96
+ return r.code(arg.metavar)
97
+
98
+ def r_argument_info(self, arg: Arg) -> str:
99
+ r = self.r
100
+ out = ""
101
+
102
+ if isinstance(arg.help, str):
103
+ out += r.p(arg.help)
104
+ if arg.choices:
105
+ out += self.r_choices(arg.choices)
106
+ return out
107
+
108
+ def r_option_title(self, option: Opt) -> str:
109
+ r = self.r
110
+ out = ""
111
+
112
+ names = option.names
113
+
114
+ if option.action == BooleanOptionalAction:
115
+ basename = [name for name in names if name.startswith("--")][0]
116
+ out += _join(
117
+ r.code(basename) + "/" + r.code(f"--no-{basename[2:]}"),
118
+ [r.code(name) for name in names if name != basename],
119
+ sep=", ",
120
+ )
121
+ else:
122
+ out += _join([r.code(name) for name in names], sep=", ")
123
+
124
+ if option.metavar is not None:
125
+ out += " " + r.i(r.e(self.r_metavar(option.metavar, option)))
126
+ return out
127
+
128
+ def r_option_default(self, option: Opt) -> str:
129
+ r = self.r
130
+
131
+ default: str | None = None
132
+
133
+ if option.hard_show_default is not None:
134
+ if isinstance(option.hard_show_default, str):
135
+ default = option.hard_show_default
136
+ elif option.hard_show_default is True:
137
+ default = str(option.default)
138
+ else:
139
+ if isinstance(option.soft_show_default, str):
140
+ default = option.soft_show_default
141
+ elif option.soft_show_default is True:
142
+ default = str(option.default)
143
+
144
+ if default is None:
145
+ return ""
146
+
147
+ # this wrapping in code avoid the need for most escapes
148
+ return f"(default: {r.code(default)})"
149
+
150
+ def r_option_info(self, option: Opt) -> str:
151
+ r = self.r
152
+ out = ""
153
+
154
+ out += r.p(
155
+ _join(
156
+ option.help if isinstance(option.help, str) else None,
157
+ self.r_option_default(option),
158
+ sep=" ",
159
+ )
160
+ )
161
+
162
+ if option.choices:
163
+ out += self.r_choices(option.choices)
164
+
165
+ return out
166
+
167
+ def r_metavar(self, metavar: str | tuple[str, ...], item: Opt | Arg) -> str:
168
+ if isinstance(metavar, tuple):
169
+ return _join(metavar, sep=" ")
170
+
171
+ nargs = item.nargs
172
+
173
+ if isinstance(nargs, int):
174
+ return _join([metavar] * nargs, sep=" ")
175
+ if nargs == "*":
176
+ return f"[{metavar} ...]"
177
+ if nargs == "+":
178
+ return f"{metavar} [{metavar} ...]"
179
+ if nargs == "?":
180
+ return f"[{metavar} ...]"
181
+ return metavar
182
+
183
+ def r_arguments(self, arguments: Sequence[Arg]) -> str:
184
+ r = self.r
185
+ out = ""
186
+
187
+ if not arguments:
188
+ return ""
189
+
190
+ title = self.config.arguments_title
191
+ headline = self.config.arguments_headline
192
+
193
+ if title:
194
+ out += r.p(title + ":")
195
+ if headline:
196
+ out += r.p(headline)
197
+ out += r.dl((self.r_argument_title(arg), self.r_argument_info(arg)) for arg in arguments)
198
+ return out
199
+
200
+ def r_options_section(self, section: Section, options: Sequence[Opt]) -> str:
201
+ r = self.r
202
+ out = ""
203
+
204
+ if not options:
205
+ return ""
206
+
207
+ if section.title:
208
+ out += r.p(section.title + ":")
209
+ if section.headline:
210
+ out += r.p(str(section.headline))
211
+ out += r.dl((self.r_option_title(opt), self.r_option_info(opt)) for opt in options)
212
+ return out
213
+
214
+ def get_options(self, me: BoundGroup | BoundCommand) -> list[Opt]:
215
+ return [*me.obj.actions, *me.obj.get_real_options()]
216
+
217
+ def get_arguments(self, me: BoundGroup | BoundCommand) -> list[Arg]:
218
+ return me.obj.get_real_arguments()
219
+
220
+ def _opts_by_sections(self, me: BoundGroup | BoundCommand) -> dict[Section, list[Opt]]:
221
+ default_section = Section(self.config.options_title, headline=self.config.options_headline)
222
+
223
+ out: dict[Section, list[Opt]] = {}
224
+
225
+ for option in self.get_options(me):
226
+ sect = next((sect for sect in me.obj.sections if option in sect.options), default_section)
227
+ out.setdefault(sect, []).append(option)
228
+
229
+ return out
230
+
231
+ def _group_usage_partial(self, me: BoundGroup) -> str:
232
+ return _join(
233
+ me.name,
234
+ [self.r_metavar(arg.metavar, arg) for arg in self.get_arguments(me)],
235
+ sep=" ",
236
+ )
237
+
238
+ def r_aliases(self, me: BoundGroup | BoundCommand) -> str:
239
+ r = self.r
240
+ out = ""
241
+
242
+ if me.aliases:
243
+ out += r.p(self.config.aliases_title + ":")
244
+ out += r.ul(r.b(alias) for alias in me.aliases)
245
+ return out
246
+
247
+ def r_command_title(self, me: BoundCommand, parents: Sequence[BoundGroup]) -> str:
248
+ r = self.r
249
+ out = ""
250
+
251
+ title = _join([p.name for p in parents], me.name, sep=" ")
252
+ out += r.h(len(parents) + 1, text=title)
253
+ return out
254
+
255
+ def r_command_info(self, me: BoundCommand) -> str:
256
+ r = self.r
257
+ out = ""
258
+
259
+ info = me.obj.info or me.obj.help
260
+ if info:
261
+ out += r.p(str(info))
262
+ return out
263
+
264
+ def r_command_usage(
265
+ self, me: BoundCommand, parents: Sequence[BoundGroup], options_sections: Iterable[Section]
266
+ ) -> str:
267
+ r = self.r
268
+ out = ""
269
+
270
+ out += r.p(self.config.usage_title + ":")
271
+ out += r.codeblock(
272
+ _join(
273
+ [self._group_usage_partial(p) for p in parents],
274
+ me.name,
275
+ [f"[{section.title.lower()}]" for section in options_sections],
276
+ [self.r_metavar(arg.metavar, arg) for arg in self.get_arguments(me)],
277
+ sep=" ",
278
+ )
279
+ )
280
+ return out
281
+
282
+ def r_command(self, me: BoundCommand, parents: Sequence[BoundGroup]) -> str:
283
+ out = ""
284
+ out += self.r_command_title(me, parents)
285
+ out += self.r_command_info(me)
286
+ out += self.r_aliases(me)
287
+
288
+ opts_sections = self._opts_by_sections(me)
289
+
290
+ out += self.r_command_usage(me, parents, opts_sections)
291
+ out += self.r_arguments(self.get_arguments(me))
292
+
293
+ for section, opts in opts_sections.items():
294
+ out += self.r_options_section(section, opts)
295
+
296
+ return out
297
+
298
+ def r_group_legend(self, me: BoundGroup, parents: Sequence[BoundGroup]) -> str:
299
+ r = self.r
300
+ out = ""
301
+
302
+ if not me.obj.nodes:
303
+ return ""
304
+
305
+ out += r.p((me.obj.title or self.config.commands_title) + ":")
306
+
307
+ if me.obj.headline:
308
+ out += r.p(str(me.obj.headline))
309
+
310
+ dtdds: list[tuple[str, str]] = []
311
+ for names, node in me.obj.nodes.items():
312
+ name, _aliases = name_and_aliases(names)
313
+ anchor = _join([p.name for p in parents], me.name, name, sep="-").lower()
314
+ text = _join(name, "..." if isinstance(node, Group) else None, sep=" ")
315
+ dtdds.append((r.link("#" + anchor, text=text), str(node.help or "")))
316
+
317
+ out += r.dl(dtdds)
318
+ return out
319
+
320
+ def r_group_info(self, me: BoundGroup) -> str:
321
+ r = self.r
322
+ out = ""
323
+
324
+ info = me.obj.info or me.obj.help
325
+ if info:
326
+ out += r.p(str(info))
327
+ return out
328
+
329
+ def r_group_title(self, me: BoundGroup, parents: Sequence[BoundGroup]) -> str:
330
+ r = self.r
331
+ out = ""
332
+
333
+ title = _join([p.name for p in parents], me.name, "..." if parents else None, sep=" ")
334
+ out += r.h(len(parents) + 1, text=title)
335
+ return out
336
+
337
+ def r_group(self, me: BoundGroup, parents: Sequence[BoundGroup]) -> str:
338
+ out = ""
339
+
340
+ out = self.r_group_title(me, parents)
341
+ out += self.r_group_info(me)
342
+ out += self.r_aliases(me)
343
+ out += self.r_arguments(self.get_arguments(me))
344
+
345
+ opts_sections = self._opts_by_sections(me)
346
+
347
+ for section, opts in opts_sections.items():
348
+ out += self.r_options_section(section, opts)
349
+
350
+ out += self.r_group_legend(me, parents)
351
+
352
+ for item_name, node in me.obj.nodes.items():
353
+ if isinstance(node, Group):
354
+ out += self.r_group(BoundGroup(*name_and_aliases(item_name), node), [*parents, me])
355
+ else:
356
+ out += self.r_command(BoundCommand(*name_and_aliases(item_name), node), [*parents, me])
357
+
358
+ return out
359
+
360
+ def generate(self, node: Group | Command[...], *, prog: str) -> str:
361
+ r = self.r
362
+
363
+ if isinstance(node, Group):
364
+ content = self.r_group(BoundGroup(prog, (), node), [])
365
+ else:
366
+ content = self.r_command(BoundCommand(prog, (), node), [])
367
+
368
+ return r.postprocess(r.body(content))
paramspecli/fake.py ADDED
@@ -0,0 +1,127 @@
1
+ import dataclasses
2
+ from typing import Any, Final, overload
3
+
4
+ from .cli import Arg, Cnst, Ctx, MixedOpts, Opt
5
+
6
+ # These specialized arguments/options classes are for the typechecher benefit
7
+ # These are really shouldn't be referenced anywhere else
8
+
9
+
10
+ class LieMixin[T]:
11
+ # NOTE: arguments, options, etc are using this mixin.
12
+ # they all define __slots__ for extra savings, so we should define an empty __slots__ too
13
+ __slots__ = ()
14
+
15
+ @property
16
+ def t(self) -> T:
17
+ return self # type: ignore[return-value] # ty: ignore[invalid-return-type]
18
+
19
+ def __neg__(self) -> T:
20
+ return self # type: ignore[return-value] # ty: ignore[invalid-return-type]
21
+
22
+
23
+ class Argument[T, D](Arg, LieMixin[T | D]):
24
+ """Parameter-argument"""
25
+
26
+ __slots__ = ()
27
+
28
+
29
+ class Option[T, D](Opt, LieMixin[T | D]):
30
+ """Parameter-option"""
31
+
32
+ __slots__ = ()
33
+
34
+ def mixed_with[O](self, other: "Option[O, Any]") -> "MixedOptions[T | O, D]":
35
+ return MixedOptions(self, other)
36
+
37
+ __or__ = mixed_with
38
+
39
+
40
+ class RepeatedOption[T](Opt, LieMixin[list[T]]):
41
+ """Parameter-option which could be repeated multiple times on command line"""
42
+
43
+ __slots__ = ()
44
+
45
+ def mixed_with[O](self, other: "RepeatedOption[O]") -> "MixedRepeatedOptions[T | O]":
46
+ return MixedRepeatedOptions(self, other)
47
+
48
+ __add__ = mixed_with
49
+
50
+
51
+ class MixedOptions[T, D](MixedOpts, LieMixin[T | D]):
52
+ """Parameter-mix of several options targeting the same parameter"""
53
+
54
+ __slots__ = ()
55
+
56
+ def mixed_with[O](self, other: "Option[O, Any]") -> "MixedOptions[T | O, D]":
57
+ return MixedOptions(*self.options, other)
58
+
59
+ __or__ = mixed_with
60
+
61
+
62
+ class MixedRepeatedOptions[T](MixedOpts, LieMixin[list[T]]):
63
+ """Parameter-mix of several repeated options targeting the same parameter"""
64
+
65
+ __slots__ = ()
66
+
67
+ def mixed_with[O](self, other: RepeatedOption[O]) -> "MixedRepeatedOptions[T | O]":
68
+ # ty wants this specialization for some reason
69
+ return MixedRepeatedOptions[T | O](*self.options, other)
70
+
71
+ __add__ = mixed_with
72
+
73
+
74
+ class Const[T](Cnst, LieMixin[T]):
75
+ """Parameter-constant value. Allows to remove parameter from the command line"""
76
+
77
+ __slots__ = ()
78
+
79
+ def __init__(self, value: T):
80
+ super().__init__(value)
81
+
82
+
83
+ class Context[T](Ctx, LieMixin[T]):
84
+ """Parameter-context. Marks parameter as accepting the shared context object"""
85
+
86
+ __slots__ = ()
87
+
88
+
89
+ class Lier:
90
+ """Cast argument/option to its type. In other words, lie to the type checker"""
91
+
92
+ @overload
93
+ def __call__[T, D](self, obj: Option[T, D] | MixedOptions[T, D] | Argument[T, D]) -> T | D: ...
94
+
95
+ @overload
96
+ def __call__[T](self, obj: RepeatedOption[T] | MixedRepeatedOptions[T]) -> list[T]: ...
97
+
98
+ @overload
99
+ def __call__[T](self, obj: Const[T]) -> T: ...
100
+
101
+ def __call__(self, obj: Any) -> Any:
102
+ return obj
103
+
104
+ __getitem__ = __call__
105
+ __matmul__ = __call__
106
+ __rmatmul__ = __call__
107
+
108
+
109
+ @overload
110
+ def required[T](option: Option[T, Any]) -> Option[T, T]: ...
111
+
112
+
113
+ @overload
114
+ def required[T](option: RepeatedOption[T]) -> RepeatedOption[T]: ...
115
+
116
+
117
+ def required(option: Opt) -> Opt:
118
+ """Return a copy of option with the `required` flag set"""
119
+ return dataclasses.replace(option, required=True)
120
+
121
+
122
+ def deprecated[T: Opt](option: T) -> T:
123
+ """Return a copy of option with the `deprecated` flag set"""
124
+ return dataclasses.replace(option, deprecated=True)
125
+
126
+
127
+ t: Final = Lier()