declinate 0.0.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.
declinate/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # Copyright 2022-2023 Ternaris.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Declinate CLI wrapper generator."""
declinate/check.py ADDED
@@ -0,0 +1,30 @@
1
+ # Copyright 2022-2023 Ternaris.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """CLI generators."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import importlib
8
+ from pathlib import Path
9
+
10
+ from .gen import generate_code
11
+
12
+
13
+ def check_package(package: str) -> str | None:
14
+ """Check if package CLI is up to date.
15
+
16
+ Args:
17
+ package: Name of package to check.
18
+
19
+ Returns:
20
+ Instructions how to update package or None.
21
+
22
+ """
23
+ code = generate_code(package, write=False)
24
+ module = importlib.import_module(name=package)
25
+ assert module
26
+ assert module.__file__
27
+ clipath = Path(module.__file__).parent / 'cli.py'
28
+ if not clipath.exists() or clipath.read_text() != code:
29
+ return f'CLI is outdated, run "declinate generate -w {package}"'
30
+ return None
declinate/cli.py ADDED
@@ -0,0 +1,394 @@
1
+ """Generated declinate CLI."""
2
+
3
+ # DO NOT EDIT THIS FILE MANUALLY
4
+ # ruff: noqa
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import os
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from typing import Callable, NoReturn
17
+
18
+
19
+ class HelpFormatter(argparse.HelpFormatter):
20
+ """Help formatter."""
21
+
22
+ def __init__(
23
+ self,
24
+ prog: str,
25
+ indent_increment: int = 2,
26
+ max_help_position: int = 24,
27
+ width: int | None = None,
28
+ ) -> None:
29
+ """Init."""
30
+ super().__init__(prog, indent_increment, max_help_position, width)
31
+ self._width = min(self._width, 78)
32
+
33
+ def _fill_text(self, text: str, width: int, indent: str) -> str:
34
+ """Reformat individual paragraphs."""
35
+ parent = super()._fill_text
36
+
37
+ def idn(text: str) -> str:
38
+ if re.match(r'(?m)(^.*?:$)|(^ )', text):
39
+ return text
40
+ return parent(text, width, indent)
41
+
42
+ return '\n\n'.join(map(idn, text.split('\n\n')))
43
+
44
+ def _metavar_formatter(
45
+ self,
46
+ action: argparse.Action,
47
+ default_metavar: str,
48
+ ) -> Callable[[int], tuple[str, ...]]:
49
+ if isinstance(action, argparse._SubParsersAction) and action.choices:
50
+ choice_strs = [
51
+ k for k, v in action.choices.items() if not v.description.startswith('SUPPRESS.')
52
+ ]
53
+ result = f'{{{",".join(choice_strs)}}}'
54
+ return lambda x: (result,) * x
55
+ return super()._metavar_formatter(action, default_metavar)
56
+
57
+
58
+ class ArgumentParser(argparse.ArgumentParser):
59
+ """Argument parser."""
60
+
61
+ def _check_value(self, action: argparse.Action, value: str) -> None:
62
+ """Filter suppressed actions."""
63
+ if isinstance(action, argparse._SubParsersAction):
64
+ if action.choices and value not in action.choices:
65
+ msg = f'invalid action: {value!r}'
66
+ raise argparse.ArgumentError(action, msg)
67
+ else:
68
+ super()._check_value(action, value)
69
+
70
+
71
+ PARSER = ArgumentParser(
72
+ allow_abbrev=False,
73
+ argument_default=argparse.SUPPRESS,
74
+ formatter_class=HelpFormatter,
75
+ description='CLI generator for Python.',
76
+ )
77
+ PARSER.add_argument(
78
+ '--version',
79
+ help='Print version number.',
80
+ dest='version',
81
+ action='store_true',
82
+ )
83
+
84
+ SUBS = PARSER.add_subparsers(
85
+ title='sub commands',
86
+ dest='_command',
87
+ required=False,
88
+ )
89
+
90
+ SUB = SUBS.add_parser(
91
+ 'generate',
92
+ allow_abbrev=False,
93
+ argument_default=argparse.SUPPRESS,
94
+ formatter_class=HelpFormatter,
95
+ description=(
96
+ 'Generate CLI code.\n'
97
+ '\n'
98
+ 'This command parses the declarative CLI definition from a Python package\n'
99
+ 'and generates the code for the CLI.'
100
+ ),
101
+ help='Generate CLI code.',
102
+ )
103
+ SUB.add_argument(
104
+ 'package',
105
+ help='Name of the Python package.',
106
+ type=str,
107
+ )
108
+ SUB.add_argument(
109
+ '-w',
110
+ '--write',
111
+ help='Write cli module into package.',
112
+ dest='write',
113
+ action='store_true',
114
+ )
115
+
116
+ SUB = SUBS.add_parser(
117
+ 'check',
118
+ allow_abbrev=False,
119
+ argument_default=argparse.SUPPRESS,
120
+ formatter_class=HelpFormatter,
121
+ description='Check if generated cli is up to date.',
122
+ help='Check if generated cli is up to date.',
123
+ )
124
+ SUB.add_argument(
125
+ 'package',
126
+ help='Name of the Python package.',
127
+ type=str,
128
+ )
129
+
130
+
131
+ def custom_complete(action: argparse.Action, args: list[str], arg: str) -> list[str]:
132
+ """Complete action."""
133
+ ret: list[str] = []
134
+ addargs: dict[str, str] = {}
135
+
136
+ return ret
137
+
138
+
139
+ COMPLETER_BASH = """\
140
+ _completer__%(name)s() {
141
+ local IFS=$'\\n'
142
+ for reply in $(env _COMPLETER=bash COMP_LINE="$COMP_LINE" COMP_POINT=$COMP_POINT $1); do
143
+ IFS=',' read type value descr <<< "$reply"
144
+ if [[ $type == "directory" ]]; then
145
+ compopt -o dirnames
146
+ elif [[ $type == "file" ]]; then
147
+ compopt -o default
148
+ elif [[ $type == "string" ]]; then
149
+ COMPREPLY+=($value)
150
+ fi
151
+ done
152
+ return 0
153
+ }
154
+ complete -o nosort -F _completer__%(name)s %(name)s;
155
+ """
156
+
157
+ COMPLETER_ZSH = """\
158
+ #compdef %(name)s
159
+ _completer__%(name)s() {
160
+ local -a values values_descrs
161
+ (( ! $+commands[%(name)s] )) && return 1
162
+ for reply in "${(@f)$(env _COMPLETER=zsh COMP_LINE="$BUFFER" COMP_POINT=$CURSOR %(name)s)}"; do
163
+ IFS="," read type value descr <<< "$reply"
164
+ if [[ "$type" == "directory" ]]; then
165
+ _path_files -/
166
+ elif [[ "$type" == "file" ]]; then
167
+ _path_files -f
168
+ elif [[ "$type" == "string" ]]; then
169
+ if [[ -n "$descr" ]]; then
170
+ values_descrs+=("$value":"$descr")
171
+ else
172
+ values+=("$value")
173
+ fi
174
+ fi
175
+ done
176
+ if [ -n "$values_descrs" ]; then
177
+ _describe -V unsorted values_descrs -U
178
+ fi
179
+ if [ -n "$values" ]; then
180
+ compadd -U -V unsorted -a values
181
+ fi
182
+ }
183
+ compdef _completer__%(name)s %(name)s;
184
+ """
185
+
186
+
187
+ def generate_source_completion() -> None:
188
+ """Generate snippet for sourcing from shell."""
189
+ completer = os.getenv('_COMPLETER')
190
+ assert completer
191
+ completers = {
192
+ 'bash': COMPLETER_BASH,
193
+ 'zsh': COMPLETER_ZSH,
194
+ }
195
+ print(completers[completer] % {'name': Path(sys.argv[0]).name})
196
+
197
+
198
+ def consume_action(action: argparse.Action, arg: str) -> None:
199
+ """Consume arg in action."""
200
+ if action.choices and arg not in action.choices:
201
+ raise ValueError
202
+
203
+
204
+ def consume_parser(
205
+ parser: ArgumentParser,
206
+ excluded: list[argparse.Action],
207
+ arg: str,
208
+ ) -> tuple[ArgumentParser, argparse.Action | None]:
209
+ """Consume parser."""
210
+ for action in parser._actions:
211
+ if isinstance(action, argparse._SubParsersAction):
212
+ for option, subaction in action.choices.items():
213
+ if option == arg:
214
+ return subaction, None
215
+
216
+ if arg.startswith('-'):
217
+ if any(x == arg for x in action.option_strings):
218
+ break
219
+ else:
220
+ if not action.option_strings:
221
+ break
222
+ else:
223
+ raise ValueError
224
+
225
+ if not isinstance(action, argparse._CountAction) and \
226
+ not (isinstance(action.nargs, str) and action.nargs in '*+'):
227
+ excluded.append(action)
228
+ for group in parser._mutually_exclusive_groups:
229
+ if action in group._group_actions:
230
+ for subaction in group._group_actions:
231
+ if subaction != action and subaction not in excluded:
232
+ excluded.append(subaction)
233
+ return parser, action if (
234
+ action.option_strings or action.choices
235
+ ) and action.nargs != 0 else None
236
+
237
+
238
+ def complete_actions(
239
+ actions: list[argparse.Action],
240
+ excluded: list[argparse.Action],
241
+ args: list[str],
242
+ arg: str,
243
+ ) -> None:
244
+ """Complete actions."""
245
+ completed: list[tuple[str, str]] = []
246
+ for action in sorted(actions, key=lambda x: not x.required):
247
+ if action in excluded:
248
+ continue
249
+
250
+ if isinstance(action, argparse._SubParsersAction):
251
+ completed += [
252
+ (k, v.description)
253
+ for k, v in action.choices.items()
254
+ if not v.description.startswith('SUPPRESS.')
255
+ ]
256
+
257
+ elif action.option_strings:
258
+ completed += [(x, action.help or '') for x in action.option_strings]
259
+
260
+ elif action.choices:
261
+ completed += [(x, '') for x in action.choices]
262
+
263
+ else:
264
+ completed += [(x, '') for x in custom_complete(action, args, arg)]
265
+
266
+ if not arg and action.required:
267
+ break
268
+
269
+ have_positinal = any(not x.startswith('-') for x, _ in completed)
270
+ for option, descr in sorted(completed):
271
+ if have_positinal and not arg and option.startswith('-'):
272
+ continue
273
+ if option.startswith(arg):
274
+ print(f'string,{option},{descr}')
275
+
276
+
277
+ def complete_action(action: argparse.Action, args: list[str], arg: str) -> None:
278
+ """Complete action."""
279
+ completed: list[tuple[str, str]] = []
280
+
281
+ if action.type == Path:
282
+ print('file,,')
283
+
284
+ if action.choices:
285
+ if isinstance(action.choices, dict):
286
+ completed += action.choices.items()
287
+ else:
288
+ completed += [(x, '') for x in action.choices]
289
+
290
+ completed += [(x, '') for x in custom_complete(action, args, arg)]
291
+
292
+ for option, descr in sorted(completed):
293
+ if not arg and option.startswith('-'):
294
+ continue
295
+ if option.startswith(arg):
296
+ print(f'string,{option},{descr}')
297
+
298
+
299
+ def generate_completion() -> None:
300
+ """Generate snippet for sourcing from shell."""
301
+ import shlex
302
+
303
+ lexer = shlex.shlex(os.getenv('COMP_LINE'), posix=True)
304
+ pos = int(os.getenv('COMP_POINT', '0'))
305
+ lexer.whitespace_split = True
306
+ lexer.commenters = ""
307
+ args = []
308
+ cword = None
309
+
310
+ next(lexer)
311
+ laststate = lexer.state # type: ignore[attr-defined]
312
+ try:
313
+ for index, token in enumerate(lexer):
314
+ args.append(token)
315
+ laststate = lexer.state # type: ignore[attr-defined]
316
+ if cword is None and lexer.instream.tell() > pos:
317
+ cword = index
318
+ except ValueError:
319
+ args.append(lexer.token)
320
+ laststate = 'error'
321
+ if laststate == ' ':
322
+ args.append('')
323
+ if cword is None:
324
+ cword = len(args) - 1
325
+
326
+ parser = PARSER
327
+ action = None
328
+ excluded: list[argparse.Action] = []
329
+
330
+ # interpret_args(parser, action, excluded, args, cword)
331
+ for arg in args[:cword]:
332
+ # print('string,try', arg)
333
+ if action:
334
+ try:
335
+ consume_action(action, arg)
336
+ action = None
337
+ except ValueError:
338
+ sys.exit(0)
339
+ continue
340
+
341
+ try:
342
+ parser, action = consume_parser(parser, excluded, arg)
343
+ except ValueError:
344
+ sys.exit(0)
345
+
346
+ arg = args[cword]
347
+
348
+ if action:
349
+ complete_action(action, args, arg)
350
+ sys.exit(0)
351
+
352
+ complete_actions(parser._actions, excluded, args, arg)
353
+ sys.exit(0)
354
+
355
+
356
+ def main() -> NoReturn: # pragma: no cover
357
+ """CLI entrypoint."""
358
+ if 'COMP_LINE' in os.environ:
359
+ generate_completion()
360
+ sys.exit(0)
361
+
362
+ if '_COMPLETER' in os.environ:
363
+ generate_source_completion()
364
+ sys.exit(0)
365
+ args = PARSER.parse_args().__dict__
366
+
367
+ from declinate.declinate import command
368
+ runargs = args
369
+ res = command(argparser=PARSER, **runargs)
370
+
371
+ _command = args.pop('_command')
372
+ _subaction = args['subaction'] if 'subaction' in args else None
373
+ if _command == 'generate':
374
+ from declinate.declinate import generate
375
+ runargs = {
376
+ k: v for k, v in args.items()
377
+ if k in {
378
+ 'package',
379
+ 'write',
380
+ }
381
+ } # yapf: disable
382
+ sys.exit(generate(**runargs))
383
+
384
+ if _command == 'check':
385
+ from declinate.declinate import check
386
+ runargs = {
387
+ k: v for k, v in args.items()
388
+ if k in {
389
+ 'package',
390
+ }
391
+ } # yapf: disable
392
+ sys.exit(check(**runargs))
393
+
394
+ sys.exit(res)
declinate/declinate.py ADDED
@@ -0,0 +1,109 @@
1
+ # Copyright 2022-2023 Ternaris.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """CLI declaration."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import importlib.metadata
8
+ import sys
9
+ from typing import TYPE_CHECKING, TypedDict
10
+
11
+ from .check import check_package
12
+ from .gen import generate_code
13
+
14
+ if TYPE_CHECKING:
15
+ from argparse import ArgumentParser
16
+ from typing import Annotated
17
+
18
+
19
+ class Kwargs(TypedDict):
20
+ """All keyword arg types."""
21
+ package: str
22
+ version: bool
23
+ write: bool
24
+
25
+
26
+ def command(
27
+ argparser: ArgumentParser,
28
+ *,
29
+ version: Annotated[bool, {
30
+ 'flags': ['--version'],
31
+ }] = False,
32
+ **kwargs: Kwargs,
33
+ ) -> int:
34
+ """CLI generator for Python.
35
+
36
+ Args:
37
+ argparser: Argument parser,
38
+ with mnoew text.
39
+ version: Print version number.
40
+ kwargs: Rest of all CLI params.
41
+
42
+ Groups:
43
+ x: SUPPRESS
44
+
45
+ Returns:
46
+ 0 if success.
47
+
48
+ """
49
+ if version:
50
+ print(importlib.metadata.version('declinate')) # noqa: T201
51
+ sys.exit(0)
52
+
53
+ if not kwargs.get('_command'):
54
+ argparser.print_help()
55
+ sys.exit(0)
56
+
57
+ return 1
58
+
59
+
60
+ def generate(
61
+ package: str,
62
+ *,
63
+ write: Annotated[\
64
+ bool, {
65
+ 'flags': ['-w', '--write'],
66
+ },
67
+ ] = False,
68
+ ) -> int:
69
+ """Generate CLI code.
70
+
71
+ This command parses the declarative CLI definition from a Python package
72
+ and generates the code for the CLI.
73
+
74
+ Args:
75
+ package: Name of the Python package.
76
+ write: Write cli module into package.
77
+
78
+ Returns:
79
+ 0 if success.
80
+
81
+ """
82
+ code = generate_code(package, write=write)
83
+ if not write:
84
+ print(code) # noqa: T201
85
+ return 0
86
+
87
+
88
+ def check(package: str) -> int:
89
+ """Check if generated cli is up to date.
90
+
91
+ Args:
92
+ package: Name of the Python package.
93
+
94
+ Returns:
95
+ 0 if success.
96
+
97
+ """
98
+ if res := check_package(package):
99
+ print(res) # noqa: T201
100
+ return 1
101
+ return 0
102
+
103
+
104
+ COMMAND = command
105
+
106
+ SUBCOMMANDS = [
107
+ generate,
108
+ check,
109
+ ]