Found while migrating thorwhalen/priv (101 commands, four groups) — the last of the wave.
1. convention.hyphenate_groups is silently ignored by add_commands(group_name=...)
A group's name is hyphenated when it is derived from a mapping key, and passed through verbatim when it is given as group_name=. cw.MODERN therefore means two different things depending on which of the two documented spellings of "a group" you used:
>>> import cw
>>> def status(): ...
>>> list(cw.commands_from({'git_ops': [status]}, convention=cw.MODERN))
['git-ops']
>>> parser = cw.mk_parser({'info': status}, prog='priv')
>>> _ = cw.add_commands(parser, [status], group_name='git_ops', convention=cw.MODERN)
>>> 'git_ops' in parser._subparsers._group_actions[0].choices
True # not 'git-ops'
cli.py passes group_name straight to the subparser adder; it never reaches _named. commands_from runs its key through _named(key, convention=convention, group=True).
Why it matters. add_commands(group_name=...) is the recommended shape for exactly the repos that have both top-level commands and groups — which is to say the big ones. A repo that migrates to cw.MODERN on the strength of ADR/README text saying groups get hyphenated will silently keep its underscored group names, and the parity harness will report identical because the CLI genuinely did not change. The failure mode is a convention that quietly does not apply, which is worse than one that errors.
For priv this happened to be the outcome we wanted (priv git_ops is a literal string in three docs), so it cost nothing — but we only found out by flipping to MODERN as a canary and watching the group name not move while the flag names did.
Suggested fix, in rough order of preference:
- Run
group_name through _named(..., group=True) like a mapping key, so one convention means one thing. This is a behaviour change for anyone on MODERN + group_name=, which today is nobody who noticed.
- If verbatim is the intended contract for an explicitly-passed name, say so in the
add_commands docstring next to group_name, and add a doctest pinning it — right now hyphenate_groups's own docstring is the only place a reader learns the rule and it does not mention the exception.
Either way the two paths should be documented as agreeing or documented as differing. Silently differing is the one option that isn't fine.
2. cw.resolve_to_function rejects the 'pkg.mod:name' form that commands_from documents
commands_from's docstring (and mk_parser's obj: parameter doc) both advertise 'pkg.mod:name' as a supported spelling. So, wanting to defer four imports, I reached for the public resolver with the same string:
>>> import cw
>>> cw.resolve_to_function('priv.git_ops:dispatch_funcs')
Traceback (most recent call last):
...
ValueError: func_spec must contain only word characters and dots: 'priv.git_ops:dispatch_funcs'
commands_from resolves via cw.commands.import_object, which takes the colon form; cw.resolve_to_function goes through parse_spec_with_dot_path, which takes only dots. Both are public, both are "turn a string into a function", and they accept different grammars — with the one that's exported in __all__ and named most generically being the stricter of the two.
The error is at least loud rather than wrong, but it names a rule (word characters and dots) without naming the resolver that does take the string you have. Either accept the colon form in resolve_to_function (delegating to import_object when a colon is present), or have the message say "for the pkg.mod:name form, use cw.commands.import_object".
Neither of these affected priv's result — thorwhalen/priv#118 replayed 111/111 cases identical under --strict-help, the whole 101-command surface, no diff at all. Recording that here because the migration wave is what these were found in.
Found while migrating
thorwhalen/priv(101 commands, four groups) — the last of the wave.1.
convention.hyphenate_groupsis silently ignored byadd_commands(group_name=...)A group's name is hyphenated when it is derived from a mapping key, and passed through verbatim when it is given as
group_name=.cw.MODERNtherefore means two different things depending on which of the two documented spellings of "a group" you used:cli.pypassesgroup_namestraight to the subparser adder; it never reaches_named.commands_fromruns its key through_named(key, convention=convention, group=True).Why it matters.
add_commands(group_name=...)is the recommended shape for exactly the repos that have both top-level commands and groups — which is to say the big ones. A repo that migrates tocw.MODERNon the strength of ADR/README text saying groups get hyphenated will silently keep its underscored group names, and the parity harness will reportidenticalbecause the CLI genuinely did not change. The failure mode is a convention that quietly does not apply, which is worse than one that errors.For priv this happened to be the outcome we wanted (
priv git_opsis a literal string in three docs), so it cost nothing — but we only found out by flipping toMODERNas a canary and watching the group name not move while the flag names did.Suggested fix, in rough order of preference:
group_namethrough_named(..., group=True)like a mapping key, so one convention means one thing. This is a behaviour change for anyone onMODERN+group_name=, which today is nobody who noticed.add_commandsdocstring next togroup_name, and add a doctest pinning it — right nowhyphenate_groups's own docstring is the only place a reader learns the rule and it does not mention the exception.Either way the two paths should be documented as agreeing or documented as differing. Silently differing is the one option that isn't fine.
2.
cw.resolve_to_functionrejects the'pkg.mod:name'form thatcommands_fromdocumentscommands_from's docstring (andmk_parser'sobj:parameter doc) both advertise'pkg.mod:name'as a supported spelling. So, wanting to defer four imports, I reached for the public resolver with the same string:commands_fromresolves viacw.commands.import_object, which takes the colon form;cw.resolve_to_functiongoes throughparse_spec_with_dot_path, which takes only dots. Both are public, both are "turn a string into a function", and they accept different grammars — with the one that's exported in__all__and named most generically being the stricter of the two.The error is at least loud rather than wrong, but it names a rule (
word characters and dots) without naming the resolver that does take the string you have. Either accept the colon form inresolve_to_function(delegating toimport_objectwhen a colon is present), or have the message say "for thepkg.mod:nameform, usecw.commands.import_object".Neither of these affected priv's result — thorwhalen/priv#118 replayed 111/111 cases identical under
--strict-help, the whole 101-command surface, no diff at all. Recording that here because the migration wave is what these were found in.