paramspecli 0.2.1__tar.gz → 0.2.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paramspecli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Type-safe facade for the venerable argparse
5
5
  Author: jkmnt
6
6
  Requires-Python: >=3.12
@@ -78,7 +78,7 @@ pip install paramspecli
78
78
  - Commands match handler functions parameters with arguments and options.
79
79
  - Arguments are bound to the handler's positional parameters.
80
80
  - Options are bound to the handler's keyword parameters.
81
- - Groups organize the commands. Groups may be nested. They could act like like an intermediate commands, i.e. have own handlers, options and arguments.
81
+ - Groups organize the commands. Groups may be nested. They could act like an intermediate commands, i.e. have own handlers, options and arguments.
82
82
  - CLI 'compiles' to the `argparse`, runs it, then outputs the parse result.
83
83
  - Parse result is a callable. Calling it invokes handlers along the route.
84
84
 
@@ -64,7 +64,7 @@ pip install paramspecli
64
64
  - Commands match handler functions parameters with arguments and options.
65
65
  - Arguments are bound to the handler's positional parameters.
66
66
  - Options are bound to the handler's keyword parameters.
67
- - Groups organize the commands. Groups may be nested. They could act like like an intermediate commands, i.e. have own handlers, options and arguments.
67
+ - Groups organize the commands. Groups may be nested. They could act like an intermediate commands, i.e. have own handlers, options and arguments.
68
68
  - CLI 'compiles' to the `argparse`, runs it, then outputs the parse result.
69
69
  - Parse result is a callable. Calling it invokes handlers along the route.
70
70
 
@@ -1,15 +1,17 @@
1
1
  """Type-safe facade for the venerable argparse"""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
4
4
 
5
5
 
6
6
  from .args import argument as argument
7
+ from .cli import MISSING as MISSING
7
8
  from .cli import Action as Action
8
9
  from .cli import CallableGroup as CallableGroup
9
10
  from .cli import Command as Command
10
11
  from .cli import Config as Config
11
12
  from .cli import Group as Group
12
13
  from .cli import Handler as Handler
14
+ from .cli import Missing as Missing
13
15
  from .cli import Route as Route
14
16
  from .cli import help_action as help_action
15
17
  from .cli import version_action as version_action
@@ -81,4 +81,4 @@ class ArgumentParserLike(
81
81
  def exit(self, status: int = ..., message: str | None = ...) -> NoReturn: ...
82
82
 
83
83
 
84
- class ArgumentGroupLike(SupportsAddArgument, SupportsAddOneofGroup, Protocol): ...
84
+ class ArgumentGroupLike(SupportsAddArgument, SupportsAddOneofGroup, SupportsSetDefaults, Protocol): ...
@@ -107,4 +107,4 @@ def argument(
107
107
  choices: Iterable[Any] | None = None,
108
108
  ) -> Argument[Any, Any]:
109
109
  """Positional argument. Always required, unless made optional via `nargs="?"`."""
110
- return Argument(metavar, help=help, type=type, choices=choices, nargs=nargs, default=default)
110
+ return Argument(metavar, help=help, conv=type, choices=choices, nargs=nargs, default=default)
@@ -1,6 +1,7 @@
1
1
  import argparse
2
2
  import dataclasses
3
3
  import sys
4
+ from types import EllipsisType
4
5
  from typing import (
5
6
  Any,
6
7
  Callable,
@@ -10,6 +11,7 @@ from typing import (
10
11
  Iterator,
11
12
  Literal,
12
13
  Mapping,
14
+ NamedTuple,
13
15
  Protocol,
14
16
  Self,
15
17
  Sequence,
@@ -28,14 +30,20 @@ from .apstub import (
28
30
  SupportsSetDefaults,
29
31
  TypeConverter,
30
32
  )
33
+ from .conv import PathConv
31
34
 
32
35
  # TODO:
33
36
  # if section has a headline, it's not auto-purged from the help.
34
37
  # Ok for user sections, bad for the default options/arguments sections
35
38
 
36
- type ArgparseActionCls = type[argparse.Action]
39
+ type Missing = EllipsisType
40
+ MISSING: Final = ...
37
41
 
38
- type HandlerSpec = tuple[Callable[..., None] | None, list[Any], dict[str, Any]]
42
+
43
+ class HandlerSpec(NamedTuple):
44
+ func: Callable[..., None] | None
45
+ args: list[str] # [arg_key]
46
+ opts: dict[str, str] # {opt_name: opt_key}
39
47
 
40
48
 
41
49
  class Markup(Protocol):
@@ -48,11 +56,16 @@ class DefaultFunc[**P](Protocol):
48
56
  def __call__(self, parser: ArgumentParserLike, /, *args: P.args, **kwargs: P.kwargs) -> None: ...
49
57
 
50
58
 
51
- def _make_spec(prefix: str, func: Callable[..., None] | None, nargs: int, options: Iterable[str]) -> HandlerSpec:
52
- return (
53
- func,
54
- [f"{prefix}[{i}]" for i in range(nargs)],
55
- {param: f"{prefix}.{param}" for param in options},
59
+ def _make_spec(
60
+ level: int,
61
+ func: Callable[..., None] | None,
62
+ args: Sequence["Arg | Cnst | Ctx"],
63
+ options: Mapping[str, "Opt | MixedOpts | Cnst | Ctx"],
64
+ ) -> HandlerSpec:
65
+ return HandlerSpec(
66
+ func=func,
67
+ args=[f"{level}[{i}]" for i in range(len(args))],
68
+ opts={param: f"{level}.{param}" for param in options},
56
69
  )
57
70
 
58
71
 
@@ -94,11 +107,30 @@ def _as_plain(arg: str | Markup | None) -> str | None:
94
107
  return str(arg)
95
108
 
96
109
 
110
+ def _may_raise_custom_exceptions(conv: TypeConverter[Any]) -> bool:
111
+ if conv is int:
112
+ return False
113
+ if conv is float:
114
+ return False
115
+ if conv is bool:
116
+ return False
117
+ if isinstance(conv, PathConv):
118
+ return False
119
+ return True
120
+
121
+
97
122
  def print_group_help(parser: ArgumentParserLike, /, *args: Any, **kwargs: Any) -> None:
98
123
  parser.print_help()
99
124
  parser.exit()
100
125
 
101
126
 
127
+ # print some values in a nicer way. currently it's a lists w/o quotes
128
+ def nice_str(val: Any) -> str:
129
+ if isinstance(val, list):
130
+ return f"[{ ", ".join([str(item) for item in val])}]"
131
+ return str(val)
132
+
133
+
102
134
  def _with_argparser_arg[**P](
103
135
  f: Callable[Concatenate[ArgumentParserLike, P], None], argparser: ArgumentParserLike
104
136
  ) -> Callable[P, None]:
@@ -126,6 +158,17 @@ class HelpFormatter(argparse.HelpFormatter):
126
158
  return text.splitlines()
127
159
  return super()._split_lines(text, width)
128
160
 
161
+ # interpolate lists manually to avoid extra quotes
162
+ def _get_help_string(self, action: argparse.Action) -> str | None:
163
+ help = action.help
164
+
165
+ if help is not None and "%(default)s" in help:
166
+ if isinstance(action.default, list):
167
+ s = nice_str(action.default)
168
+ help = help.replace("%(default)s", s)
169
+
170
+ return help
171
+
129
172
 
130
173
  @dataclasses.dataclass
131
174
  class Config:
@@ -152,6 +195,8 @@ class Config:
152
195
  parser_class: type[argparse.ArgumentParser] = argparse.ArgumentParser
153
196
  """Argparge parser class to use"""
154
197
  formatter_class: type[argparse.HelpFormatter] = HelpFormatter
198
+ """Silently ignore unknown args and place them into the Route.unknown_args"""
199
+ ignore_unknown_args: bool = False
155
200
  """Argparse help formatter class to use"""
156
201
  root_parser_extra_kwargs: Mapping[str, Any] = dataclasses.field(default_factory=dict)
157
202
  """Dict of extra kwargs for the root (CLI) argparse.ArgumentParser"""
@@ -165,7 +210,7 @@ class Arg:
165
210
 
166
211
  metavar: str
167
212
  _: dataclasses.KW_ONLY
168
- type: TypeConverter[Any] | None = None
213
+ conv: TypeConverter[Any] | None = None
169
214
  help: str | Markup | bool | None = None
170
215
  choices: Iterable[Any] | None = None
171
216
  nargs: int | Literal["*", "+", "?"] | None = None
@@ -179,10 +224,10 @@ class Arg:
179
224
  def __hash__(self) -> int:
180
225
  return hash(self.metavar)
181
226
 
182
- def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str) -> None:
183
- _type = self.type
184
- if _type and config.catch_typeconv_exceptions:
185
- _type = util.catch_all(_type)
227
+ def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str, **_rest: Any) -> None:
228
+ conv = self.conv
229
+ if conv and config.catch_typeconv_exceptions and _may_raise_custom_exceptions(conv):
230
+ conv = util.catch_all(conv)
186
231
 
187
232
  help = self.help
188
233
 
@@ -194,17 +239,16 @@ class Arg:
194
239
  # most kwargs shouldn't be set at all if None
195
240
  kwargs: dict[str, Any] = {
196
241
  "choices": self.choices,
197
- "default": self.default,
198
242
  "nargs": self.nargs,
199
243
  "metavar": self.metavar,
200
244
  # NOTE: if type is not specified, argparse's MetavarTypeHelpFormatter may fail!
201
- "type": _type,
245
+ "type": conv,
202
246
  }
203
247
  kwargs = {k: v for k, v in kwargs.items() if v is not None}
204
248
  if self.extra:
205
249
  kwargs |= self.extra
206
250
 
207
- owner.add_argument(dest=dest, help=_as_plain(help), **kwargs)
251
+ owner.add_argument(dest=dest, help=_as_plain(help), default=self.default, **kwargs)
208
252
 
209
253
 
210
254
  # Too many fields to manage: dataclass to the rescue
@@ -214,14 +258,14 @@ class Opt:
214
258
 
215
259
  names: tuple[str, ...]
216
260
  _: dataclasses.KW_ONLY
217
- type: TypeConverter[Any] | None = None
261
+ conv: TypeConverter[Any] | None = None
218
262
  help: str | Markup | bool | None = None
219
263
  hard_show_default: bool | str | None = None
220
264
  soft_show_default: bool | str = False
221
265
  required: bool = False
222
266
  choices: Iterable[Any] | None = None
223
267
  metavar: str | tuple[str, ...] | None = None
224
- action: str | ArgparseActionCls | None = None
268
+ action: str | type[argparse.Action] | None = None
225
269
  nargs: int | Literal["*", "+", "?"] | None = None
226
270
  default: Any = None
227
271
  const: Any = None
@@ -243,7 +287,7 @@ class Opt:
243
287
  def __hash__(self) -> int:
244
288
  return hash(self.names)
245
289
 
246
- def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str | Literal[False]) -> None:
290
+ def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str | Literal[False], **_rest: Any) -> None:
247
291
  help = self.help
248
292
 
249
293
  if help is False:
@@ -268,19 +312,18 @@ class Opt:
268
312
  else:
269
313
  help += f" (default: {show_default})"
270
314
 
271
- _type = self.type
272
- if _type and config.catch_typeconv_exceptions:
273
- _type = util.catch_all(_type)
315
+ conv = self.conv
316
+ if conv and config.catch_typeconv_exceptions and _may_raise_custom_exceptions(conv):
317
+ conv = util.catch_all(conv)
274
318
 
275
319
  # most kwargs shouldn't be set at all if None
276
320
  kwargs: dict[str, Any] = {
277
321
  "action": self.action,
278
322
  "choices": self.choices,
279
- "default": self.default,
280
323
  "nargs": self.nargs,
281
324
  "const": self.const,
282
325
  "metavar": self.metavar,
283
- "type": _type,
326
+ "type": conv,
284
327
  "required": self.required or None,
285
328
  "deprecated": self.deprecated if sys.version_info >= (3, 13) else None,
286
329
  }
@@ -289,7 +332,7 @@ class Opt:
289
332
  if self.extra:
290
333
  kwargs |= self.extra
291
334
 
292
- owner.add_argument(*self.names, dest=dest or argparse.SUPPRESS, help=help, **kwargs)
335
+ owner.add_argument(*self.names, dest=dest or argparse.SUPPRESS, default=self.default, help=help, **kwargs)
293
336
 
294
337
  @property
295
338
  def is_hidden(self) -> bool:
@@ -300,6 +343,34 @@ class Opt:
300
343
  return self
301
344
 
302
345
 
346
+
347
+ class Cnst:
348
+ __slots__ = ("value",)
349
+
350
+ def __init__(self, value: Any):
351
+ self.value: Final = value
352
+
353
+ def __repr__(self) -> str:
354
+ return _repr_class(self, {"value": self.value}, skip_none=False)
355
+
356
+ def __eq__(self, other: object) -> bool:
357
+ if not isinstance(other, Cnst):
358
+ return NotImplemented
359
+ return bool(self.value == other.value)
360
+
361
+ def _build(self, owner: SupportsSetDefaults, config: Config, *, dest: str, **_rest: Any) -> None:
362
+ owner.set_defaults(**{dest: self.value})
363
+
364
+
365
+ class Ctx:
366
+ __slots__ = ()
367
+
368
+ def _build(self, owner: SupportsSetDefaults, config: Config, *, dest: str, context: Any) -> None:
369
+ if context is None:
370
+ raise ValueError("context can't be None")
371
+ owner.set_defaults(**{dest: context})
372
+
373
+
303
374
  class MixedOpts:
304
375
  """Abstract mix of options"""
305
376
 
@@ -322,44 +393,17 @@ class MixedOpts:
322
393
  return self
323
394
 
324
395
 
325
- class Cnst:
326
- __slots__ = ("value",)
327
-
328
- def __init__(self, value: Any):
329
- self.value: Final = value
330
-
331
- def __repr__(self) -> str:
332
- return _repr_class(self, {"value": self.value}, skip_none=False)
333
-
334
- def __eq__(self, other: object) -> bool:
335
- if not isinstance(other, Cnst):
336
- return NotImplemented
337
- return bool(self.value == other.value)
338
-
339
- def _build(self, owner: SupportsSetDefaults, config: Config, *, dest: str) -> None:
340
- owner.set_defaults(**{dest: self.value})
341
-
342
-
343
396
  class Action(Opt):
344
397
  """Special kind of option with side effects"""
345
398
 
346
399
  __slots__ = ()
347
400
 
348
- def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str | Literal[False]) -> None:
401
+ def _build(self, owner: SupportsAddArgument, config: Config, *, dest: str | Literal[False], **_rest: Any) -> None:
349
402
  if dest is not False:
350
403
  raise TypeError(f"Action is used in place of Option {dest!r}")
351
404
  return super()._build(owner, config, dest=dest)
352
405
 
353
406
 
354
- class Ctx:
355
- __slots__ = ()
356
-
357
- def _build(self, owner: SupportsSetDefaults, config: Config, *, dest: str, context: Any) -> None:
358
- if context is None:
359
- raise ValueError("context can't be None")
360
- owner.set_defaults(**{dest: context})
361
-
362
-
363
407
  class Section:
364
408
  """Shows its options in a separate block of a `--help` output"""
365
409
 
@@ -427,7 +471,7 @@ class ParserLike:
427
471
 
428
472
  self.arguments: list[Arg | Cnst | Ctx] | None = None
429
473
  self.actions: list[Action] = []
430
- self.options: dict[str, Opt | MixedOpts | Cnst | Ctx] | None = {}
474
+ self.options: dict[str, Opt | MixedOpts | Cnst | Ctx] | None = None
431
475
  self.sections: list[Section] = []
432
476
  self.oneofs: list[Oneof] = []
433
477
 
@@ -452,7 +496,7 @@ class ParserLike:
452
496
  self.options = {**options}
453
497
 
454
498
  def _build_sections(
455
- self, owner: ArgumentParserLike, config: Config, *, default_group: ArgumentGroupLike
499
+ self, parser: ArgumentParserLike, config: Config, *, default_group: ArgumentGroupLike
456
500
  ) -> dict[Opt, SupportsAddArgument]:
457
501
  sectmap: dict[Opt, ArgumentGroupLike] = {}
458
502
  oneofmap: dict[Opt, SupportsAddArgument] = {}
@@ -461,7 +505,7 @@ class ParserLike:
461
505
  if section_dupes := section.options.intersection(sectmap):
462
506
  raise KeyError(f"options {section_dupes} are in several sections at once")
463
507
 
464
- sectmap |= dict.fromkeys(section.options, section._build(owner, config))
508
+ sectmap |= dict.fromkeys(section.options, section._build(parser, config))
465
509
 
466
510
  for oneof in self.oneofs:
467
511
  if not oneof.options:
@@ -480,58 +524,51 @@ class ParserLike:
480
524
  return tgtmap
481
525
 
482
526
  def _build_params(
483
- self, owner: ArgumentParserLike, config: Config, *, parents: Sequence["ParserLike"], context: Any
527
+ self, parser: ArgumentParserLike, config: Config, *, parents: Sequence["ParserLike"], context: Any
484
528
  ) -> None:
485
529
  if self._func:
486
530
  if self.arguments is None or self.options is None:
487
531
  raise ValueError("bind() was not called")
488
532
 
489
- key = str(len(parents))
533
+ level = len(parents)
490
534
 
491
- spec = _make_spec(key, self._func, len(self.arguments or []), self.options or {})
492
- owner.set_defaults(**{key: spec})
535
+ spec = _make_spec(level, self._func, self.arguments or [], self.options or {})
536
+ parser.set_defaults(**{str(level): spec})
493
537
 
494
538
  if self.arguments:
495
- arguments_group = owner.add_argument_group(
539
+ arguments_group = parser.add_argument_group(
496
540
  title=config.arguments_title, description=_as_plain(config.arguments_headline)
497
541
  )
498
542
 
499
543
  for i, argument in enumerate(self.arguments):
500
- name = f"{key}[{i}]"
501
- if isinstance(argument, Cnst):
502
- argument._build(owner, config, dest=name)
503
- elif isinstance(argument, Ctx):
504
- argument._build(owner, config, dest=name, context=context)
505
- else:
506
- argument._build(arguments_group, config, dest=name)
507
-
508
- default_options_group = owner.add_argument_group(
544
+ dest = f"{level}[{i}]"
545
+ argument._build(arguments_group, config, dest=dest, context=context)
546
+
547
+ default_options_group = parser.add_argument_group(
509
548
  title=config.options_title, description=_as_plain(config.options_headline)
510
549
  )
511
550
 
512
- tgtmap = self._build_sections(owner, config, default_group=default_options_group)
551
+ tgtmap = self._build_sections(parser, config, default_group=default_options_group)
513
552
 
514
553
  for action in self.actions:
515
554
  action._build(tgtmap.pop(action, default_options_group), config, dest=False)
516
555
 
517
556
  if self.options:
518
557
  for param, item in self.options.items():
519
- name = f"{key}.{param}"
520
- if isinstance(item, Cnst):
521
- item._build(owner, config, dest=name)
522
- elif isinstance(item, Ctx):
523
- item._build(owner, config, dest=name, context=context)
524
- else:
525
- seq = (item,) if isinstance(item, Opt) else item.options
526
- for opt in seq:
527
- opt._build(tgtmap.pop(opt, default_options_group), config, dest=name)
558
+ dest = f"{level}.{param}"
559
+ seq = (item,) if not isinstance(item, MixedOpts) else item.options
560
+ for opt in seq:
561
+ if isinstance(opt, (Cnst, Ctx)):
562
+ opt._build(parser, config, dest=dest, context=context)
563
+ else:
564
+ opt._build(tgtmap.pop(opt, default_options_group), config, dest=dest, context=context)
528
565
 
529
566
  if tgtmap:
530
567
  raise KeyError(f"unconsumed items in sections: {tgtmap.keys()}")
531
568
 
532
569
  def _build_subparser(
533
570
  self,
534
- owner: SupportsAddParser,
571
+ parent: SupportsAddParser,
535
572
  config: Config,
536
573
  *,
537
574
  parents: Sequence["ParserLike"],
@@ -544,7 +581,7 @@ class ParserLike:
544
581
  if epilog is None and config.propagate_epilog and parents:
545
582
  epilog = parents[0].epilog
546
583
 
547
- return owner.add_parser(
584
+ return parent.add_parser(
548
585
  basename,
549
586
  aliases=aliases,
550
587
  help=_as_plain(self.help),
@@ -635,12 +672,10 @@ class Handler:
635
672
  def from_ns(cls, ns: argparse.Namespace, spec: HandlerSpec) -> Self:
636
673
  vars = ns.__dict__
637
674
 
638
- func, argnames, kwargnames = spec
639
-
640
- args = [vars[name] for name in argnames]
641
- kwargs = {k: vars[v] for k, v in kwargnames.items()}
675
+ args = [vars[name] for name in spec.args]
676
+ kwargs = {k: vars[v] for k, v in spec.opts.items()}
642
677
 
643
- return cls(func, args, kwargs)
678
+ return cls(spec.func, args, kwargs)
644
679
 
645
680
  @classmethod
646
681
  def from_spec(cls, func: Callable[..., None] | None, /, *arguments: Any, **options: Any) -> Self:
@@ -651,9 +686,11 @@ class Route:
651
686
  """Represents sequence of handers, arguments and options.
652
687
  Calling it will invoke the handlers"""
653
688
 
654
- def __init__(self, handlers: Sequence[Handler]):
689
+ def __init__(self, handlers: Sequence[Handler], unknown_args: Sequence[str] | None = None):
655
690
  self.handlers = handlers
691
+ self.unknown_args = unknown_args
656
692
 
693
+ # unrecognized args are ignored for the comparison
657
694
  def __eq__(self, other: object) -> bool:
658
695
  if not isinstance(other, Route):
659
696
  return NotImplemented
@@ -680,7 +717,7 @@ class Route:
680
717
  return [h for h in self.handlers if h.func is not None]
681
718
 
682
719
  @classmethod
683
- def from_ns(cls, ns: argparse.Namespace) -> Self:
720
+ def from_ns(cls, ns: argparse.Namespace, unknown_args: Sequence[str] | None = None) -> Self:
684
721
  specs: list[tuple[int, HandlerSpec]] = []
685
722
  for k, v in ns.__dict__.items():
686
723
  try:
@@ -692,7 +729,7 @@ class Route:
692
729
  specs.sort()
693
730
 
694
731
  handlers = [Handler.from_ns(ns, spec[1]) for spec in specs]
695
- return cls(handlers)
732
+ return cls(handlers, unknown_args=unknown_args)
696
733
 
697
734
 
698
735
  ## More concrete
@@ -720,14 +757,14 @@ class Command[**P](ParserLike):
720
757
 
721
758
  def _build(
722
759
  self,
723
- owner: ArgumentParserLike,
760
+ parser: ArgumentParserLike,
724
761
  config: Config,
725
762
  *,
726
763
  parents: Sequence["Group"],
727
764
  context: Any,
728
765
  **kwargs: Any,
729
766
  ) -> None:
730
- self._build_params(owner, config, parents=parents, context=context)
767
+ self._build_params(parser, config, parents=parents, context=context)
731
768
 
732
769
  def bind(self, *arguments: P.args, **options: P.kwargs) -> Self:
733
770
  """Bind function parameters to arguments/options"""
@@ -741,6 +778,10 @@ class Command[**P](ParserLike):
741
778
  parser = self._build_root_parser(config)
742
779
  self._build(parser, config, parents=[], context=context)
743
780
 
781
+ if config.ignore_unknown_args:
782
+ ns, unknown_args = parser.parse_known_args(input)
783
+ return Route.from_ns(ns, unknown_args=unknown_args)
784
+
744
785
  ns = parser.parse_args(input)
745
786
  return Route.from_ns(ns)
746
787
 
@@ -773,31 +814,31 @@ class Group(ParserLike):
773
814
  if add_help:
774
815
  self.append_action(_help_action)
775
816
 
776
- def _build(self, owner: ArgumentParserLike, config: Config, *, parents: Sequence["Group"], context: Any) -> None:
777
- self._build_params(owner, config, parents=parents, context=context)
778
- self._build_default_handler(owner, config, parents=parents)
779
- self._build_nodes(owner, config, parents=parents, context=context)
817
+ def _build(self, parser: ArgumentParserLike, config: Config, *, parents: Sequence["Group"], context: Any) -> None:
818
+ self._build_params(parser, config, parents=parents, context=context)
819
+ self._build_default_handler(parser, config, parents=parents)
820
+ self._build_nodes(parser, config, parents=parents, context=context)
780
821
 
781
822
  def _build_default_handler(
782
- self, owner: ArgumentParserLike, config: Config, *, parents: Sequence["ParserLike"]
823
+ self, parser: ArgumentParserLike, config: Config, *, parents: Sequence["ParserLike"]
783
824
  ) -> None:
784
825
  if not self.default_func:
785
826
  return
786
827
 
787
- default_func = _with_argparser_arg(self.default_func, owner)
828
+ default_func = _with_argparser_arg(self.default_func, parser)
788
829
 
789
- key = str(len(parents))
790
- deeper_key = str(len(parents) + 1)
791
- spec = _make_spec(key, default_func, len(self.arguments or []), self.options or {})
792
- owner.set_defaults(**{deeper_key: spec})
830
+ level = len(parents)
831
+ next_level = level + 1
832
+ spec = _make_spec(level, default_func, self.arguments or [], self.options or {})
833
+ parser.set_defaults(**{str(next_level): spec})
793
834
 
794
835
  def _build_nodes(
795
- self, owner: ArgumentParserLike, config: Config, *, parents: Sequence["Group"], context: Any
836
+ self, parent: ArgumentParserLike, config: Config, *, parents: Sequence["Group"], context: Any
796
837
  ) -> None:
797
838
  if not self.nodes:
798
839
  return
799
840
 
800
- sub = owner.add_subparsers(
841
+ sub = parent.add_subparsers(
801
842
  title=self.title if self.title is not None else config.commands_title,
802
843
  description=_as_plain(self.headline),
803
844
  parser_class=config.parser_class,
@@ -824,6 +865,10 @@ class Group(ParserLike):
824
865
  e.add_note("- Cli")
825
866
  raise
826
867
 
868
+ if config.ignore_unknown_args:
869
+ ns, unknown_args = parser.parse_known_args(input)
870
+ return Route.from_ns(ns, unknown_args=unknown_args)
871
+
827
872
  ns = parser.parse_args(input)
828
873
  return Route.from_ns(ns)
829
874
 
@@ -6,11 +6,11 @@ from typing import Final, Literal
6
6
  class PathConv:
7
7
  __name__ = "PATH"
8
8
 
9
- __slots__ = ("exists", "resolve", "type")
9
+ __slots__ = ("exists", "kind", "resolve")
10
10
 
11
- def __init__(self, type: Literal["file", "dir", None] = None, *, exists: bool | None = None, resolve: bool = True):
11
+ def __init__(self, kind: Literal["file", "dir", None] = None, *, exists: bool | None = None, resolve: bool = True):
12
12
  self.exists: Final = exists
13
- self.type: Final = type
13
+ self.kind: Final = kind
14
14
  self.resolve: Final = resolve
15
15
 
16
16
  # scenarios:
@@ -26,24 +26,24 @@ class PathConv:
26
26
  if self.resolve:
27
27
  path = path.resolve()
28
28
 
29
- check_type = False
29
+ check_kind = False
30
30
 
31
31
  if self.exists is True:
32
32
  if not path.exists():
33
33
  raise ArgumentTypeError(f"{path} does not exists")
34
- check_type = True
34
+ check_kind = True
35
35
  elif self.exists is False:
36
36
  if path.exists():
37
37
  raise ArgumentTypeError(f"{path} already exists")
38
38
  else:
39
- if self.type and path.exists():
40
- check_type = True
39
+ if self.kind and path.exists():
40
+ check_kind = True
41
41
 
42
- if check_type:
43
- if self.type == "file":
42
+ if check_kind:
43
+ if self.kind == "file":
44
44
  if not path.is_file():
45
45
  raise ArgumentTypeError(f"{path} is not a file")
46
- elif self.type == "dir":
46
+ elif self.kind == "dir":
47
47
  if not path.is_dir():
48
48
  raise ArgumentTypeError(f"{path} is not a directory")
49
49
 
@@ -4,7 +4,7 @@ from typing import Any, Final, Iterable, Mapping, NamedTuple, Protocol, Sequence
4
4
 
5
5
  from paramspecli import md
6
6
 
7
- from .cli import Arg, Command, Group, Opt, Section, name_and_aliases
7
+ from .cli import Arg, Command, Group, Opt, Section, name_and_aliases, nice_str
8
8
 
9
9
 
10
10
  class Renderer(Protocol):
@@ -65,6 +65,7 @@ class Settings:
65
65
  usage_title: str = "Usage"
66
66
  aliases_title: str = "Aliases"
67
67
  options_headline: str | None = None
68
+ choice_metavar: str = "CHOICE"
68
69
 
69
70
 
70
71
  class Doc:
@@ -121,8 +122,10 @@ class Doc:
121
122
  else:
122
123
  out += _join([r.code(name) for name in names], sep=", ")
123
124
 
124
- if option.metavar is not None:
125
- out += " " + r.i(r.e(self.r_metavar(option.metavar, option)))
125
+ metavar = self.r_metavar(option)
126
+
127
+ if metavar:
128
+ out += " " + r.i(r.e(metavar))
126
129
  return out
127
130
 
128
131
  def r_option_default(self, option: Opt) -> str:
@@ -134,12 +137,12 @@ class Doc:
134
137
  if isinstance(option.hard_show_default, str):
135
138
  default = option.hard_show_default
136
139
  elif option.hard_show_default is True:
137
- default = str(option.default)
140
+ default = nice_str(option.default)
138
141
  else:
139
142
  if isinstance(option.soft_show_default, str):
140
143
  default = option.soft_show_default
141
144
  elif option.soft_show_default is True:
142
- default = str(option.default)
145
+ default = nice_str(option.default)
143
146
 
144
147
  if default is None:
145
148
  return ""
@@ -164,10 +167,17 @@ class Doc:
164
167
 
165
168
  return out
166
169
 
167
- def r_metavar(self, metavar: str | tuple[str, ...], item: Opt | Arg) -> str:
170
+ def r_metavar(self, item: Opt | Arg) -> str:
171
+ metavar = item.metavar
168
172
  if isinstance(metavar, tuple):
169
173
  return _join(metavar, sep=" ")
170
174
 
175
+ if metavar is None:
176
+ if item.choices:
177
+ metavar = self.config.choice_metavar
178
+ else:
179
+ return ""
180
+
171
181
  nargs = item.nargs
172
182
 
173
183
  if isinstance(nargs, int):
@@ -177,7 +187,7 @@ class Doc:
177
187
  if nargs == "+":
178
188
  return f"{metavar} [{metavar} ...]"
179
189
  if nargs == "?":
180
- return f"[{metavar} ...]"
190
+ return f"[{metavar}]"
181
191
  return metavar
182
192
 
183
193
  def r_arguments(self, arguments: Sequence[Arg]) -> str:
@@ -231,7 +241,7 @@ class Doc:
231
241
  def _group_usage_partial(self, me: BoundGroup) -> str:
232
242
  return _join(
233
243
  me.name,
234
- [self.r_metavar(arg.metavar, arg) for arg in self.get_arguments(me)],
244
+ [self.r_metavar(arg) for arg in self.get_arguments(me)],
235
245
  sep=" ",
236
246
  )
237
247
 
@@ -273,7 +283,7 @@ class Doc:
273
283
  [self._group_usage_partial(p) for p in parents],
274
284
  me.name,
275
285
  [f"[{section.title.lower()}]" for section in options_sections],
276
- [self.r_metavar(arg.metavar, arg) for arg in self.get_arguments(me)],
286
+ [self.r_metavar(arg) for arg in self.get_arguments(me)],
277
287
  sep=" ",
278
288
  )
279
289
  )
@@ -1,6 +1,7 @@
1
1
  from argparse import BooleanOptionalAction
2
2
  from typing import Any, Literal, overload
3
3
 
4
+ from .cli import MISSING
4
5
  from .fake import Option, RepeatedOption
5
6
 
6
7
 
@@ -54,7 +55,7 @@ def flag[D](
54
55
  def flag(
55
56
  *names: str,
56
57
  value: Any = True,
57
- default: Any = ...,
58
+ default: Any = MISSING,
58
59
  help: str | bool | None = None,
59
60
  show_default: bool | str | None = None,
60
61
  ) -> Option[Any, Any]:
@@ -80,7 +81,7 @@ def flag(
80
81
 
81
82
 
82
83
  """
83
- if default is ...:
84
+ if default is MISSING:
84
85
  soft_show_default = False
85
86
  if value is True:
86
87
  default = False
@@ -130,16 +131,6 @@ def switch(
130
131
  show_default: bool | str | None = None,
131
132
  ) -> Option[bool, Any]:
132
133
  """On/Off switch with complimentary flags: `--foo/--no-foo`. Default is `False`"""
133
- longs = [name for name in names if name.startswith("--")]
134
- if not longs:
135
- raise ValueError("at least one name starting with '--' is required")
136
-
137
- soft_show_default: bool | str
138
-
139
- if default is True or default is False:
140
- soft_show_default = longs[0] if default else f"--no-{longs[0][2:]}"
141
- else:
142
- soft_show_default = True
143
134
 
144
135
  return Option(
145
136
  names,
@@ -148,7 +139,7 @@ def switch(
148
139
  default=default,
149
140
  #
150
141
  hard_show_default=show_default,
151
- soft_show_default=soft_show_default,
142
+ soft_show_default=True,
152
143
  )
153
144
 
154
145
 
@@ -1,7 +1,7 @@
1
- from types import EllipsisType
2
1
  from typing import Any, Iterable, Literal, overload
3
2
 
4
3
  from .apstub import TypeConverter
4
+ from .cli import Missing
5
5
  from .fake import Option, RepeatedOption
6
6
 
7
7
 
@@ -49,7 +49,7 @@ def option(
49
49
  choices: Iterable[str] | None = None,
50
50
  metavar: str | tuple[str, ...] | None = None,
51
51
  show_default: bool | str | None = None,
52
- ) -> Option[str | EllipsisType, None]: ...
52
+ ) -> Option[str | Missing, None]: ...
53
53
 
54
54
 
55
55
  ## nargs, default: D
@@ -75,7 +75,7 @@ def option[D](
75
75
  choices: Iterable[str] | None = None,
76
76
  metavar: str | tuple[str, ...] | None = None,
77
77
  show_default: bool | str | None = None,
78
- ) -> Option[str | EllipsisType, D]: ...
78
+ ) -> Option[str | Missing, D]: ...
79
79
 
80
80
 
81
81
  ## type: T
@@ -127,7 +127,7 @@ def option[T](
127
127
  choices: Iterable[T] | None = None,
128
128
  metavar: str | tuple[str, ...] | None = None,
129
129
  show_default: bool | str | None = None,
130
- ) -> Option[T | EllipsisType, None]: ...
130
+ ) -> Option[T | Missing, None]: ...
131
131
 
132
132
 
133
133
  ## type: T, default: str, nargs
@@ -156,7 +156,7 @@ def option[T](
156
156
  choices: Iterable[T] | None = None,
157
157
  metavar: str | tuple[str, ...] | None = None,
158
158
  show_default: bool | str | None = None,
159
- ) -> Option[T | EllipsisType, T]: ...
159
+ ) -> Option[T | Missing, T]: ...
160
160
 
161
161
 
162
162
  ## type: T, default: D, nargs
@@ -184,7 +184,7 @@ def option[T, D](
184
184
  choices: Iterable[T] | None = None,
185
185
  metavar: str | tuple[str, ...] | None = None,
186
186
  show_default: bool | str | None = None,
187
- ) -> Option[T | EllipsisType, D]: ...
187
+ ) -> Option[T | Missing, D]: ...
188
188
 
189
189
 
190
190
  def option(
@@ -198,15 +198,19 @@ def option(
198
198
  show_default: bool | str | None = None,
199
199
  ) -> Option[Any, Any]:
200
200
  """Just an option"""
201
+
202
+ if metavar is None and choices is None:
203
+ metavar = names[0].lstrip("-").upper()
204
+
201
205
  return Option(
202
206
  names,
203
207
  help=help,
204
- type=type,
208
+ conv=type,
205
209
  nargs=nargs,
206
210
  default=default,
207
211
  const=... if nargs == "?" else None,
208
212
  choices=choices,
209
- metavar=metavar if metavar is not None else names[0].lstrip("-").upper(),
213
+ metavar=metavar,
210
214
  action="store",
211
215
  #
212
216
  hard_show_default=show_default,
@@ -310,13 +314,17 @@ def repeated_option(
310
314
  ) -> RepeatedOption[Any]:
311
315
  """Option which could present multiple times on a command line.
312
316
  Result is collected into the list"""
317
+
318
+ if metavar is None and choices is None:
319
+ metavar = names[0].lstrip("-").upper()
320
+
313
321
  return RepeatedOption(
314
322
  names,
315
323
  help=help,
316
- type=type,
324
+ conv=type,
317
325
  nargs=nargs,
318
326
  choices=choices,
319
- metavar=metavar if metavar is not None else names[0].lstrip("-").upper(),
327
+ metavar=metavar,
320
328
  action="extend" if flatten else "append",
321
329
  default=[],
322
330
  #
File without changes
File without changes