dsw-tdk 3.13.0__py3-none-any.whl → 4.27.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.
dsw/tdk/cli.py ADDED
@@ -0,0 +1,708 @@
1
+ # pylint: disable=too-many-positional-arguments
2
+ import asyncio
3
+ import datetime
4
+ import logging
5
+ import mimetypes
6
+ import pathlib
7
+ import signal
8
+ import sys
9
+
10
+ import click
11
+ import humanize
12
+ import slugify
13
+ import watchfiles
14
+
15
+ from . import consts
16
+ from .api_client import WizardCommunicationError
17
+ from .config import CONFIG
18
+ from .core import TDKCore, TDKProcessingError
19
+ from .model import Template
20
+ from .utils import FormatSpec, TemplateBuilder, create_dot_env, safe_utf8
21
+ from .validation import ValidationError
22
+
23
+
24
+ CURRENT_DIR = pathlib.Path.cwd()
25
+ DIR_TYPE = click.Path(exists=True, dir_okay=True, file_okay=False, resolve_path=True,
26
+ readable=True, writable=True)
27
+ FILE_READ_TYPE = click.Path(exists=True, dir_okay=False, file_okay=True, resolve_path=True,
28
+ readable=True)
29
+ NEW_DIR_TYPE = click.Path(dir_okay=True, file_okay=False, resolve_path=True,
30
+ readable=True, writable=True)
31
+
32
+
33
+ def _now() -> datetime.datetime:
34
+ return datetime.datetime.now(tz=datetime.UTC)
35
+
36
+
37
+ class ClickPrinter:
38
+
39
+ CHANGE_SIGNS = {
40
+ watchfiles.Change.added: click.style('+', fg='green'),
41
+ watchfiles.Change.modified: click.style('*', fg='yellow'),
42
+ watchfiles.Change.deleted: click.style('-', fg='red'),
43
+ }
44
+
45
+ @staticmethod
46
+ def error(message: str, **kwargs):
47
+ click.secho(message=message, err=True, fg='red', **kwargs)
48
+
49
+ @staticmethod
50
+ def warning(message: str, **kwargs):
51
+ click.secho('WARNING', fg='yellow', bold=True, nl=False, **kwargs)
52
+ click.echo(f': {message}')
53
+
54
+ @staticmethod
55
+ def success(message: str):
56
+ click.secho('SUCCESS', fg='green', bold=True, nl=False)
57
+ click.echo(f': {message}')
58
+
59
+ @staticmethod
60
+ def failure(message: str):
61
+ click.secho('FAILURE', fg='red', bold=True, nl=False)
62
+ click.echo(f': {message}')
63
+
64
+ @staticmethod
65
+ def watch(message: str):
66
+ click.secho('WATCH', fg='blue', bold=True, nl=False)
67
+ click.echo(f': {message}')
68
+
69
+ @classmethod
70
+ def watch_change(cls, change_type: watchfiles.Change, filepath: pathlib.Path,
71
+ root: pathlib.Path):
72
+ timestamp = _now().isoformat(timespec='milliseconds')
73
+ sign = cls.CHANGE_SIGNS[change_type]
74
+ click.secho('WATCH', fg='blue', bold=True, nl=False)
75
+ click.echo(f'@{timestamp} {sign} {filepath.relative_to(root)}')
76
+
77
+
78
+ def prompt_fill(text: str, obj, attr, **kwargs):
79
+ while True:
80
+ try:
81
+ value = safe_utf8(click.prompt(text, **kwargs).strip())
82
+ setattr(obj, attr, value)
83
+ break
84
+ except ValidationError as e:
85
+ ClickPrinter.error(e.message)
86
+
87
+
88
+ def print_template_info(template: Template):
89
+ click.echo(f'Template ID: {template.id}')
90
+ click.echo(f'Name: {template.name}')
91
+ click.echo(f'License: {template.license}')
92
+ click.echo(f'Description: {template.description}')
93
+ click.echo('Formats:')
94
+ for format_spec in template.formats:
95
+ click.echo(f' - {format_spec.name}')
96
+ click.echo('Files:')
97
+ for template_file in template.files.values():
98
+ filesize = humanize.naturalsize(len(template_file.content))
99
+ click.echo(f' - {template_file.filename.as_posix()} [{filesize}]')
100
+
101
+
102
+ def ensure_api_config(api_url: str | None, api_key: str | None):
103
+ if api_url is not None:
104
+ CONFIG.use_local_env()
105
+ CONFIG.set_api_url(api_url)
106
+ if not CONFIG.has_api_url:
107
+ CONFIG.set_api_url(
108
+ api_url=click.prompt('API URL'),
109
+ )
110
+ if api_key is not None:
111
+ CONFIG.use_local_env()
112
+ CONFIG.set_api_key(api_key)
113
+ if not CONFIG.has_api_key:
114
+ CONFIG.set_api_key(
115
+ api_key=click.prompt('API Key', hide_input=True),
116
+ )
117
+ if not CONFIG.has_api_url or not CONFIG.has_api_key:
118
+ ClickPrinter.error('API URL and API Key are required to proceed.')
119
+ sys.exit(1)
120
+
121
+
122
+ class ClickLogger(logging.Logger):
123
+
124
+ NAME = 'DSW-TDK-CLI'
125
+ LEVEL_STYLES = {
126
+ logging.CRITICAL: lambda x: click.style(x, fg='red', bold=True),
127
+ logging.ERROR: lambda x: click.style(x, fg='red'),
128
+ logging.WARNING: lambda x: click.style(x, fg='yellow'),
129
+ logging.INFO: lambda x: click.style(x, fg='cyan'),
130
+ logging.DEBUG: lambda x: click.style(x, fg='magenta'),
131
+ }
132
+ LEVELS = [
133
+ logging.getLevelName(logging.CRITICAL),
134
+ logging.getLevelName(logging.ERROR),
135
+ logging.getLevelName(logging.WARNING),
136
+ logging.getLevelName(logging.INFO),
137
+ logging.getLevelName(logging.DEBUG),
138
+ ]
139
+
140
+ def __init__(self, show_timestamp: bool = False, show_level: bool = True, colors: bool = True):
141
+ super().__init__(name=self.NAME)
142
+ self.show_timestamp = show_timestamp
143
+ self.show_level = show_level
144
+ self.colors = colors
145
+ self.muted = False
146
+
147
+ def _format_level(self, level, justify=False):
148
+ name = logging.getLevelName(level) # type: str
149
+ if justify:
150
+ name = name.ljust(8, ' ')
151
+ if self.colors and level in self.LEVEL_STYLES:
152
+ name = self.LEVEL_STYLES[level](name)
153
+ return name
154
+
155
+ def _print_message(self, level, message):
156
+ if self.show_timestamp:
157
+ timestamp = _now().isoformat(timespec='milliseconds')
158
+ click.echo(timestamp + ' | ', nl=False)
159
+ if self.show_level:
160
+ sep = ' | ' if self.show_timestamp else ': '
161
+ click.echo(self._format_level(level, justify=self.show_timestamp) + sep, nl=False)
162
+ click.echo(message)
163
+
164
+ # pylint: disable-next=unused-argument
165
+ def _log(self, level, msg, args, *other, **kwargs):
166
+ if not self.muted and isinstance(msg, str):
167
+ self._print_message(level, msg % args)
168
+
169
+ @staticmethod
170
+ def default():
171
+ logger = ClickLogger()
172
+ logger.setLevel('INFO')
173
+ return logger
174
+
175
+
176
+ class AliasedGroup(click.Group):
177
+
178
+ def get_command(self, ctx, cmd_name):
179
+ rv = click.Group.get_command(self, ctx, cmd_name)
180
+ if rv is not None:
181
+ return rv
182
+ matches = [x for x in self.list_commands(ctx)
183
+ if x.startswith(cmd_name)]
184
+ if not matches:
185
+ return None
186
+ if len(matches) == 1:
187
+ return click.Group.get_command(self, ctx, matches[0])
188
+ return ctx.fail(f'Too many matches: {", ".join(sorted(matches))}')
189
+
190
+
191
+ class CLIContext:
192
+
193
+ def __init__(self):
194
+ self.logger = ClickLogger.default()
195
+ self.dot_env_file = None
196
+
197
+ def debug_mode(self):
198
+ self.logger.show_timestamp = True
199
+ self.logger.setLevel(level=logging.DEBUG)
200
+
201
+ def quiet_mode(self):
202
+ self.logger.muted = True
203
+
204
+
205
+ def interact_formats() -> dict[str, FormatSpec]:
206
+ add_format = click.confirm('Do you want to add a format?', default=True)
207
+ formats: dict[str, FormatSpec] = {}
208
+ while add_format:
209
+ format_spec = FormatSpec()
210
+ prompt_fill('Format name', obj=format_spec, attr='name', default='HTML')
211
+ if format_spec.name not in formats or click.confirm(
212
+ 'There is already a format with this name. Do you want to change it?',
213
+ ):
214
+ prompt_fill('File extension', obj=format_spec, attr='file_extension',
215
+ default=format_spec.name.lower() if ' ' not in format_spec.name else None)
216
+ prompt_fill('Content type', obj=format_spec, attr='content_type',
217
+ default=mimetypes.types_map.get(f'.{format_spec.file_extension}', None))
218
+ t_path = pathlib.Path('src') / f'template.{format_spec.file_extension}.j2'
219
+ prompt_fill(
220
+ text='Jinja2 filename',
221
+ obj=format_spec,
222
+ attr='filename',
223
+ default=str(t_path),
224
+ )
225
+ formats[format_spec.name] = format_spec
226
+ click.echo('=' * 60)
227
+ add_format = click.confirm('Do you want to add yet another format?', default=False)
228
+ return formats
229
+
230
+
231
+ def interact_builder(builder: TemplateBuilder):
232
+ prompt_fill('Template name', obj=builder, attr='name')
233
+ prompt_fill('Organization ID', obj=builder, attr='organization_id')
234
+ prompt_fill('Template ID', obj=builder, attr='template_id',
235
+ default=slugify.slugify(builder.name))
236
+ prompt_fill('Version', obj=builder, attr='version',
237
+ default='0.1.0')
238
+ prompt_fill('Description', obj=builder, attr='description',
239
+ default='My custom template')
240
+ prompt_fill('License', obj=builder, attr='license',
241
+ default='CC0')
242
+ click.echo('=' * 60)
243
+ formats = interact_formats()
244
+ for format_spec in formats.values():
245
+ builder.add_format(format_spec)
246
+
247
+
248
+ def load_local(tdk: TDKCore, template_dir: pathlib.Path):
249
+ try:
250
+ tdk.load_local(template_dir=template_dir)
251
+ except Exception as e:
252
+ ClickPrinter.failure('Could not load local template')
253
+ ClickPrinter.error(f'> {e}')
254
+ sys.exit(1)
255
+
256
+
257
+ def dir_from_id(template_id: str) -> pathlib.Path:
258
+ return pathlib.Path.cwd() / template_id.replace(':', '_')
259
+
260
+
261
+ @click.group(cls=AliasedGroup)
262
+ @click.option('-d', '--dot-env', default='.env', required=False,
263
+ show_default=True, type=click.Path(file_okay=True, dir_okay=False),
264
+ help='File path to dot-env file with environment variables.')
265
+ @click.option('-e', '--environment', default=None, required=False,
266
+ help='Configuration environment name.')
267
+ @click.option('--no-dot-env', is_flag=True, default=False,
268
+ help='Do not load .env file, use only environment variables.')
269
+ @click.option('--no-config', is_flag=True, default=False,
270
+ help='Do not load shared configuration, use only environment variables.')
271
+ @click.option('-q', '--quiet', is_flag=True,
272
+ help='Hide additional information logs.')
273
+ @click.option('--debug', is_flag=True,
274
+ help='Enable debug logging.')
275
+ @click.version_option(version=consts.VERSION)
276
+ @click.pass_context
277
+ def main(ctx, quiet, debug, dot_env, environment, no_dot_env, no_config):
278
+ if not no_config:
279
+ try:
280
+ CONFIG.load_home_config()
281
+ except Exception as e:
282
+ ClickPrinter.warning('Failed to load shared configuration')
283
+ ClickPrinter.warning(f'> {e}')
284
+ if not no_dot_env and dot_env is not None:
285
+ dot_env_path = pathlib.Path(dot_env)
286
+ if dot_env_path.exists():
287
+ CONFIG.load_dotenv(path=dot_env_path)
288
+ try:
289
+ if environment is not None:
290
+ CONFIG.switch_current_env(environment)
291
+ except Exception as e:
292
+ ClickPrinter.warning('Failed to set config environment')
293
+ ClickPrinter.warning(f'> {e}')
294
+ ctx.ensure_object(CLIContext)
295
+ ctx.obj.dot_env_file = dot_env
296
+ if quiet:
297
+ ctx.obj.quiet_mode()
298
+ if debug:
299
+ ctx.obj.debug_mode()
300
+
301
+
302
+ @main.command(help='Create a new template project.', name='new')
303
+ @click.argument('TEMPLATE-DIR', type=NEW_DIR_TYPE, default=None, required=False)
304
+ @click.option('-f', '--force', is_flag=True, help='Overwrite any matching files.')
305
+ @click.pass_context
306
+ def new_template(ctx, template_dir, force):
307
+ builder = TemplateBuilder()
308
+ try:
309
+ interact_builder(builder)
310
+ except Exception:
311
+ click.echo('')
312
+ ClickPrinter.failure('Exited...')
313
+ sys.exit(1)
314
+ tdk = TDKCore(template=builder.build(), logger=ctx.obj.logger)
315
+ template_dir = template_dir or dir_from_id(tdk.safe_template.id)
316
+ tdk.prepare_local(template_dir=template_dir)
317
+ try:
318
+ tdk.store_local(force=force)
319
+ ClickPrinter.success(f'Template project created: {template_dir}')
320
+ except Exception as e:
321
+ ClickPrinter.failure('Could not create new template project')
322
+ ClickPrinter.error(f'> {e}')
323
+ sys.exit(1)
324
+
325
+
326
+ @main.command(help='Download template from Wizard.', name='get')
327
+ @click.argument('TEMPLATE-ID')
328
+ @click.argument('TEMPLATE-DIR', type=NEW_DIR_TYPE, default=None, required=False)
329
+ @click.option('-u', '--api-url', metavar='API-URL', envvar='DSW_API_URL',
330
+ help='URL of Wizard server API.')
331
+ @click.option('-k', '--api-key', metavar='API-KEY', envvar='DSW_API_KEY',
332
+ help='API key for Wizard instance.')
333
+ @click.option('-f', '--force', is_flag=True, help='Overwrite any existing files.')
334
+ @click.pass_context
335
+ def get_template(ctx, template_id, template_dir, api_url, api_key, force):
336
+ ensure_api_config(api_url, api_key)
337
+ template_dir = pathlib.Path(template_dir or dir_from_id(template_id))
338
+
339
+ async def main_routine():
340
+ tdk = TDKCore(logger=ctx.obj.logger)
341
+ template_type = 'unknown'
342
+ zip_data = None
343
+ try:
344
+ await tdk.init_client(
345
+ api_url=CONFIG.env.api_url,
346
+ api_key=CONFIG.env.api_key,
347
+ )
348
+ try:
349
+ await tdk.load_remote(template_id=template_id)
350
+ template_type = 'draft'
351
+ except Exception:
352
+ zip_data = await tdk.download_bundle(template_id=template_id)
353
+ template_type = 'bundle'
354
+ await tdk.safe_client.close()
355
+ except WizardCommunicationError as e:
356
+ ClickPrinter.error('Could not get template:', bold=True)
357
+ ClickPrinter.error(f'> {e.reason}\n> {e.message}')
358
+ await tdk.safe_client.close()
359
+ sys.exit(1)
360
+ await tdk.safe_client.safe_close()
361
+ if template_type == 'draft':
362
+ tdk.prepare_local(template_dir=template_dir)
363
+ try:
364
+ tdk.store_local(force=force)
365
+ ClickPrinter.success(f'Template draft {template_id} '
366
+ f'downloaded to {template_dir}')
367
+ except Exception as e:
368
+ ClickPrinter.failure('Could not store template locally')
369
+ ClickPrinter.error(f'> {e}')
370
+ await tdk.safe_client.close()
371
+ sys.exit(1)
372
+ elif template_type == 'bundle' and zip_data is not None:
373
+ try:
374
+ tdk.extract_package(zip_data=zip_data, template_dir=template_dir, force=force)
375
+ ClickPrinter.success(f'Template {template_id} (released) '
376
+ f'downloaded to {template_dir}')
377
+ except Exception as e:
378
+ ClickPrinter.failure('Could not store template locally')
379
+ ClickPrinter.error(f'> {e}')
380
+ await tdk.safe_client.close()
381
+ sys.exit(1)
382
+ else:
383
+ ClickPrinter.failure(f'{template_id} is not released nor draft of a document template')
384
+ sys.exit(1)
385
+
386
+ loop = asyncio.get_event_loop()
387
+ loop.run_until_complete(main_routine())
388
+
389
+
390
+ @main.command(help='Upload template to Wizard.', name='put')
391
+ @click.argument('TEMPLATE-DIR', type=DIR_TYPE, default=CURRENT_DIR, required=False)
392
+ @click.option('-u', '--api-url', metavar='API-URL', envvar='DSW_API_URL',
393
+ help='URL of Wizard server API.')
394
+ @click.option('-k', '--api-key', metavar='API-KEY', envvar='DSW_API_KEY',
395
+ help='API key for Wizard instance.')
396
+ @click.option('-f', '--force', is_flag=True,
397
+ help='Delete template if already exists.')
398
+ @click.option('-w', '--watch', is_flag=True,
399
+ help='Enter watch mode to continually upload changes.')
400
+ @click.pass_context
401
+ def put_template(ctx, template_dir, api_url, api_key, force, watch):
402
+ ensure_api_config(api_url, api_key)
403
+ tdk = TDKCore(logger=ctx.obj.logger)
404
+ stop_event = asyncio.Event()
405
+
406
+ async def watch_callback(changes):
407
+ changes = list(changes)
408
+ for change in changes:
409
+ ClickPrinter.watch_change(
410
+ change_type=change[0],
411
+ filepath=change[1],
412
+ root=tdk.safe_project.template_dir,
413
+ )
414
+ if len(changes) > 0:
415
+ await tdk.process_changes(changes, force=force)
416
+
417
+ async def main_routine():
418
+ load_local(tdk, template_dir)
419
+ try:
420
+ await tdk.init_client(
421
+ api_url=CONFIG.env.api_url,
422
+ api_key=CONFIG.env.api_key,
423
+ )
424
+ await tdk.store_remote(force=force)
425
+ ClickPrinter.success(f'Template {tdk.safe_project.safe_template.id} '
426
+ f'uploaded to {CONFIG.env.api_url}')
427
+
428
+ if watch:
429
+ ClickPrinter.watch('Entering watch mode... (press Ctrl+C to abort)')
430
+ await tdk.watch_project(watch_callback, stop_event)
431
+
432
+ await tdk.safe_client.close()
433
+ except TDKProcessingError as e:
434
+ ClickPrinter.failure('Could not upload template')
435
+ ClickPrinter.error(f'> {e.message}\n> {e.hint}')
436
+ await tdk.safe_client.safe_close()
437
+ sys.exit(1)
438
+ except WizardCommunicationError as e:
439
+ ClickPrinter.failure('Could not upload template')
440
+ ClickPrinter.error(f'> {e.reason}\n> {e.message}')
441
+ ClickPrinter.error('> Probably incorrect API URL, metamodel version, '
442
+ 'or template already exists...')
443
+ ClickPrinter.error('> Check if you are using the matching version')
444
+ await tdk.safe_client.safe_close()
445
+ sys.exit(1)
446
+
447
+ # pylint: disable-next=unused-argument
448
+ def set_stop_event(signal_num, frame):
449
+ signal_name = signal.Signals(signal_num).name
450
+ ClickPrinter.warning(f'Got {signal_name}, finishing... Bye!')
451
+ stop_event.set()
452
+
453
+ signal.signal(signal.SIGINT, set_stop_event)
454
+ signal.signal(signal.SIGABRT, set_stop_event)
455
+
456
+ loop = asyncio.get_event_loop()
457
+ main_task = asyncio.ensure_future(main_routine())
458
+ loop.run_until_complete(main_task)
459
+
460
+
461
+ @main.command(help='Create ZIP package for a template.', name='package')
462
+ @click.argument('TEMPLATE-DIR', type=DIR_TYPE, default=CURRENT_DIR, required=False)
463
+ @click.option('-o', '--output', default='template.zip', type=click.Path(writable=True),
464
+ show_default=True, help='Target package file.')
465
+ @click.option('-f', '--force', is_flag=True, help='Delete package if already exists.')
466
+ @click.pass_context
467
+ def create_package(ctx, template_dir, output, force: bool):
468
+ tdk = TDKCore(logger=ctx.obj.logger)
469
+ load_local(tdk, template_dir)
470
+ try:
471
+ tdk.create_package(output=pathlib.Path(output), force=force)
472
+ except Exception as e:
473
+ ClickPrinter.failure('Failed to create the package')
474
+ ClickPrinter.error(f'> {e}')
475
+ sys.exit(1)
476
+ filename = click.style(output, bold=True)
477
+ ClickPrinter.success(f'Package {filename} created')
478
+
479
+
480
+ @main.command(help='Extract a template from ZIP package', name='unpackage')
481
+ @click.argument('TEMPLATE-PACKAGE', type=FILE_READ_TYPE, required=False)
482
+ @click.option('-o', '--output', type=NEW_DIR_TYPE, default=None, required=False,
483
+ help='Target package file.')
484
+ @click.option('-f', '--force', is_flag=True, help='Overwrite folder if already exists.')
485
+ @click.pass_context
486
+ def extract_package(ctx, template_package, output, force: bool):
487
+ tdk = TDKCore(logger=ctx.obj.logger)
488
+ try:
489
+ data = pathlib.Path(template_package).read_bytes()
490
+ tdk.extract_package(
491
+ zip_data=data,
492
+ template_dir=pathlib.Path(output) if output is not None else output,
493
+ force=force,
494
+ )
495
+ except Exception as e:
496
+ ClickPrinter.failure('Failed to extract the package')
497
+ ClickPrinter.error(f'> {e}')
498
+ sys.exit(1)
499
+ ClickPrinter.success(f'Package {template_package} extracted')
500
+
501
+
502
+ @main.command(help='List templates from Wizard via API.', name='list')
503
+ @click.option('-u', '--api-url', metavar='API-URL', envvar='DSW_API_URL',
504
+ help='URL of Wizard server API.')
505
+ @click.option('-k', '--api-key', metavar='API-KEY', envvar='DSW_API_KEY',
506
+ help='API key for Wizard instance.')
507
+ @click.option('--output-format', default=consts.DEFAULT_LIST_FORMAT,
508
+ metavar='FORMAT', help='Entry format string for printing.')
509
+ @click.option('-r', '--released-only', is_flag=True, help='List only released templates')
510
+ @click.option('-d', '--drafts-only', is_flag=True, help='List only template drafts')
511
+ @click.pass_context
512
+ def list_templates(ctx, api_url, api_key, output_format: str,
513
+ released_only: bool, drafts_only: bool):
514
+ ensure_api_config(api_url, api_key)
515
+
516
+ async def main_routine():
517
+ tdk = TDKCore(logger=ctx.obj.logger)
518
+ try:
519
+ await tdk.init_client(
520
+ api_url=CONFIG.env.api_url,
521
+ api_key=CONFIG.env.api_key,
522
+ )
523
+ if released_only:
524
+ templates = await tdk.list_remote_templates()
525
+ for template in templates:
526
+ click.echo(output_format.format(template=template))
527
+ elif drafts_only:
528
+ drafts = await tdk.list_remote_drafts()
529
+ for template in drafts:
530
+ click.echo(output_format.format(template=template))
531
+ else:
532
+ click.echo('Document Templates (released)')
533
+ templates = await tdk.list_remote_templates()
534
+ for template in templates:
535
+ click.echo(output_format.format(template=template))
536
+ click.echo('\nDocument Templates Drafts')
537
+ drafts = await tdk.list_remote_drafts()
538
+ for template in drafts:
539
+ click.echo(output_format.format(template=template))
540
+ await tdk.safe_client.safe_close()
541
+
542
+ except WizardCommunicationError as e:
543
+ ClickPrinter.failure('Failed to get list of templates')
544
+ ClickPrinter.error(f'> {e.reason}\n> {e.message}')
545
+ await tdk.safe_client.safe_close()
546
+ sys.exit(1)
547
+
548
+ loop = asyncio.get_event_loop()
549
+ loop.run_until_complete(main_routine())
550
+
551
+
552
+ @main.command(help='Verify a template project.', name='verify')
553
+ @click.argument('TEMPLATE-DIR', type=DIR_TYPE, default=CURRENT_DIR, required=False)
554
+ @click.pass_context
555
+ def verify_template(ctx, template_dir):
556
+ tdk = TDKCore(logger=ctx.obj.logger)
557
+ load_local(tdk, template_dir)
558
+ errors = tdk.verify()
559
+ if len(errors) == 0:
560
+ ClickPrinter.success('The template is valid!')
561
+ print_template_info(template=tdk.safe_project.safe_template)
562
+ else:
563
+ ClickPrinter.failure('The template is invalid!')
564
+ click.echo('Found violations:')
565
+ for err in errors:
566
+ click.echo(f' - {err.field_name}: {err.message}')
567
+
568
+
569
+ @main.group(help='Manage shared user configuration (~/.dsw-tdk).', name='config')
570
+ @click.pass_context
571
+ # pylint: disable-next=unused-argument
572
+ def config(ctx):
573
+ pass
574
+
575
+
576
+ @config.command(name='init', help='Initialize the shared user configuration (~/.dsw-tdk).')
577
+ @click.option('-f', '--force', is_flag=True, help='Overwrite file if already exists.')
578
+ def config_init(force):
579
+ if CONFIG.HOME_CONFIG.exists() and not force:
580
+ ClickPrinter.error('Configuration file already exists. Use `--force` to overwrite it.')
581
+ sys.exit(1)
582
+ CONFIG.shared_envs.clear()
583
+ CONFIG.default_env_name = None
584
+
585
+ click.echo('You need to specify your first environment name (default one).')
586
+ click.echo('Recommendation: use short and lowercase name, e.g. "production"')
587
+ environment = click.prompt('Environment name', default='production')
588
+ api_url = click.prompt('API URL')
589
+ api_key = click.prompt('API Key', hide_input=True)
590
+ try:
591
+ CONFIG.add_shared_env(
592
+ name=environment,
593
+ api_url=api_url,
594
+ api_key=api_key,
595
+ )
596
+ CONFIG.default_env_name = environment
597
+ except Exception as e:
598
+ ClickPrinter.failure('Failed to add environment')
599
+ ClickPrinter.error(f'> {e}')
600
+ sys.exit(1)
601
+ while True:
602
+ add_another = click.confirm('Do you want to add another environment?', default=False)
603
+ if not add_another:
604
+ break
605
+ environment = click.prompt('Environment name')
606
+ api_url = click.prompt('API URL')
607
+ api_key = click.prompt('API Key', hide_input=True)
608
+ try:
609
+ CONFIG.add_shared_env(
610
+ name=environment,
611
+ api_url=api_url,
612
+ api_key=api_key,
613
+ )
614
+ except Exception as e:
615
+ ClickPrinter.failure('Failed to add environment (skipping)')
616
+ ClickPrinter.warning(f'> {e}')
617
+
618
+ try:
619
+ CONFIG.persist(force=force)
620
+ ClickPrinter.success('Configuration initialized successfully.')
621
+ except Exception as e:
622
+ ClickPrinter.failure('Failed to initialize configuration')
623
+ ClickPrinter.error(f'> {e}')
624
+ sys.exit(1)
625
+
626
+
627
+ @config.command(name='edit', help='Edit the shared user configuration (~/.dsw-tdk).')
628
+ @click.option('-f', '--force', is_flag=True, help='Create file if does not exist.')
629
+ def config_edit(force):
630
+ if not CONFIG.HOME_CONFIG.exists():
631
+ if force:
632
+ CONFIG.HOME_CONFIG.parent.mkdir(parents=True, exist_ok=True)
633
+ CONFIG.HOME_CONFIG.touch()
634
+ else:
635
+ ClickPrinter.error('Configuration file does not exist. Use `init` command '
636
+ 'or `--force` flag to create it.')
637
+ sys.exit(1)
638
+ click.edit(
639
+ filename=str(CONFIG.HOME_CONFIG),
640
+ extension='.cfg',
641
+ require_save=True,
642
+ )
643
+
644
+
645
+ @config.command(name='check', help='Check the current configuration that can be loaded.')
646
+ def config_check():
647
+ hidden = click.style('(hidden)', fg='red', bold=True)
648
+ not_set = click.style('(not set)', fg='yellow', bold=True)
649
+ click.secho('Shared configuration (~/.dsw-tdk):', bold=True)
650
+ for env_name, env in CONFIG.shared_envs.items():
651
+ if env_name == CONFIG.current_env_name:
652
+ env_out = click.style(env_name, fg='green', bold=True)
653
+ click.echo(f'{env_out} (current)')
654
+ elif env_name == CONFIG.default_env_name:
655
+ env_out = click.style(env_name, fg='cyan', bold=True)
656
+ click.echo(f'{env_out} (default)')
657
+ else:
658
+ env_out = click.style(env_name, fg='blue', bold=True)
659
+ click.echo(env_out)
660
+ click.echo(f' API URL: {env.api_url or not_set}')
661
+ click.echo(f' API Key: {hidden if env.api_key else not_set}')
662
+ click.echo('')
663
+ click.secho('Project-local configuration:', bold=True)
664
+ if CONFIG.current_env_name == CONFIG.LOCAL_CONFIG:
665
+ env_out = click.style(CONFIG.LOCAL_CONFIG, fg='green', bold=True)
666
+ click.echo(f'{env_out} (current)')
667
+ else:
668
+ env_out = click.style('local', fg='blue', bold=True)
669
+ click.echo(env_out)
670
+ if CONFIG.local_env.api_url:
671
+ click.echo(f' API URL: {CONFIG.local_env.api_url}')
672
+ else:
673
+ click.echo(f' API URL: {not_set}')
674
+ if CONFIG.local_env.api_key:
675
+ click.echo(f' API Key: {hidden}')
676
+ else:
677
+ click.echo(f' API Key: {not_set}')
678
+
679
+
680
+ @config.command(name='dot-env', help='Create a .env file with API configuration.')
681
+ @click.argument('TEMPLATE-DIR', type=DIR_TYPE, default=CURRENT_DIR, required=False)
682
+ @click.option('-u', '--api-url', metavar='API-URL', envvar='DSW_API_URL',
683
+ help='URL of Wizard server API.')
684
+ @click.option('-k', '--api-key', metavar='API-KEY', envvar='DSW_API_KEY',
685
+ help='API key for Wizard instance.')
686
+ @click.option('-f', '--force', is_flag=True, help='Overwrite file if already exists.')
687
+ @click.pass_context
688
+ def config_create_dotenv(ctx, template_dir, api_url, api_key, force):
689
+ ensure_api_config(api_url, api_key)
690
+ filename = ctx.obj.dot_env_file or '.env'
691
+ output = pathlib.Path(template_dir) / filename
692
+ try:
693
+ if output.exists():
694
+ if force:
695
+ ClickPrinter.warning(f'Overwriting {output.as_posix()} (forced)')
696
+ else:
697
+ raise FileExistsError(f'File {output.as_posix()} already exists (not forced)')
698
+ output.write_text(
699
+ data=create_dot_env(
700
+ api_url=CONFIG.env.api_url,
701
+ api_key=CONFIG.env.api_key,
702
+ ),
703
+ encoding=consts.DEFAULT_ENCODING,
704
+ )
705
+ except Exception as e:
706
+ ClickPrinter.failure('Failed to create dot-env file')
707
+ ClickPrinter.error(f'> {e}')
708
+ sys.exit(1)