outerbounds 0.3.179rc5__py3-none-any.whl → 0.3.180__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.
@@ -1,717 +0,0 @@
1
- import json
2
- import os
3
- import sys
4
- from functools import wraps
5
- from typing import Dict, List, Any, Optional, Union
6
-
7
- # IF this CLI is supposed to be reusable across Metaflow Too
8
- # Then we need to ensure that the click IMPORT happens from metaflow
9
- # so that any object class comparisons end up working as expected.
10
- # since Metaflow lazy loads Click Modules, we need to ensure that the
11
- # module's click Groups correlate to the same module otherwise Metaflow
12
- # will not accept the cli as valid.
13
- # But the BIGGEST Problem of adding a `metaflow._vendor` import is that
14
- # It will run the remote-config check and that can raise a really dirty exception
15
- # That will break the CLI.
16
- # So we need to find a way to import click without having it try to check for remote-config
17
- # or load the config from the environment.
18
- from metaflow._vendor import click
19
- from outerbounds._vendor import yaml
20
- from outerbounds.utils import metaflowconfig
21
- from .app_config import (
22
- AppConfig,
23
- AppConfigError,
24
- CODE_PACKAGE_PREFIX,
25
- CAPSULE_DEBUG,
26
- AuthType,
27
- )
28
- from .perimeters import PerimeterExtractor
29
- from .cli_to_config import build_config_from_options
30
- from .utils import CommaSeparatedListType, KVPairType, KVDictType
31
- from . import experimental
32
- from .validations import deploy_validations
33
- from .code_package import CodePackager
34
- from .capsule import CapsuleDeployer, list_and_filter_capsules, CapsuleApi
35
- import shlex
36
- import time
37
- import uuid
38
- from datetime import datetime
39
-
40
- LOGGER_TIMESTAMP = "magenta"
41
- LOGGER_COLOR = "green"
42
- LOGGER_BAD_COLOR = "red"
43
-
44
- NativeList = list
45
-
46
-
47
- def _logger(
48
- body="", system_msg=False, head="", bad=False, timestamp=True, nl=True, color=None
49
- ):
50
- if timestamp:
51
- if timestamp is True:
52
- dt = datetime.now()
53
- else:
54
- dt = timestamp
55
- tstamp = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
56
- click.secho(tstamp + " ", fg=LOGGER_TIMESTAMP, nl=False)
57
- if head:
58
- click.secho(head, fg=LOGGER_COLOR, nl=False)
59
- click.secho(
60
- body,
61
- bold=system_msg,
62
- fg=LOGGER_BAD_COLOR if bad else color if color is not None else None,
63
- nl=nl,
64
- )
65
-
66
-
67
- class CliState(object):
68
- pass
69
-
70
-
71
- def _pre_create_debug(app_config: AppConfig, capsule: CapsuleDeployer, state_dir: str):
72
- if CAPSULE_DEBUG:
73
- os.makedirs(state_dir, exist_ok=True)
74
- debug_path = os.path.join(state_dir, f"debug_{time.time()}.yaml")
75
- with open(
76
- debug_path,
77
- "w",
78
- ) as f:
79
- f.write(
80
- yaml.dump(
81
- {
82
- "app_state": app_config.dump_state(),
83
- "capsule_input": capsule.create_input(),
84
- },
85
- default_flow_style=False,
86
- indent=2,
87
- )
88
- )
89
-
90
-
91
- def print_table(data, headers):
92
- """Print data in a formatted table."""
93
- if not data:
94
- return
95
-
96
- # Calculate column widths
97
- col_widths = [len(h) for h in headers]
98
-
99
- # Calculate actual widths based on data
100
- for row in data:
101
- for i, cell in enumerate(row):
102
- col_widths[i] = max(col_widths[i], len(str(cell)))
103
-
104
- # Print header
105
- header_row = " | ".join(
106
- [headers[i].ljust(col_widths[i]) for i in range(len(headers))]
107
- )
108
- click.secho("-" * len(header_row), fg="yellow")
109
- click.secho(header_row, fg="yellow", bold=True)
110
- click.secho("-" * len(header_row), fg="yellow")
111
-
112
- # Print data rows
113
- for row in data:
114
- formatted_row = " | ".join(
115
- [str(row[i]).ljust(col_widths[i]) for i in range(len(row))]
116
- )
117
- click.secho(formatted_row, fg="green", bold=True)
118
- click.secho("-" * len(header_row), fg="yellow")
119
-
120
-
121
- @click.group()
122
- def cli():
123
- """Outerbounds CLI tool."""
124
- pass
125
-
126
-
127
- @cli.group(
128
- help="Commands related to Deploying/Running/Managing Apps on Outerbounds Platform."
129
- )
130
- @click.pass_context
131
- def app(ctx):
132
- """App-related commands."""
133
- metaflow_set_context = getattr(ctx, "obj", None)
134
- ctx.obj = CliState()
135
- ctx.obj.trace_id = str(uuid.uuid4())
136
- ctx.obj.app_state_dir = os.path.join(os.curdir, ".ob_apps")
137
- profile = os.environ.get("METAFLOW_PROFILE", "")
138
- config_dir = os.path.expanduser(
139
- os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")
140
- )
141
- perimeter, api_server = PerimeterExtractor.for_ob_cli(
142
- config_dir=config_dir, profile=profile
143
- )
144
- if perimeter is None or api_server is None:
145
- raise AppConfigError(
146
- "Perimeter not found in the environment, Found perimeter: %s, api_server: %s"
147
- % (perimeter, api_server)
148
- )
149
- ctx.obj.perimeter = perimeter
150
- ctx.obj.api_url = api_server
151
- os.makedirs(ctx.obj.app_state_dir, exist_ok=True)
152
-
153
-
154
- def parse_commands(app_config: AppConfig, cli_command_input):
155
- # There can be two modes:
156
- # 1. User passes command via `--` in the CLI
157
- # 2. User passes the `commands` key in the config.
158
- base_commands = []
159
- if len(cli_command_input) > 0:
160
- # TODO: we can be a little more fancy here by allowing the user to just call
161
- # `outerbounds app deploy -- foo.py` and figure out if we need to stuff python
162
- # in front of the command or not. But for sake of dumb simplicity, we can just
163
- # assume what ever the user called on local needs to be called remotely, we can
164
- # just ask them to add the outerbounds command in front of it.
165
- # So the dev ex would be :
166
- # `python foo.py` -> `outerbounds app deploy -- python foo.py`
167
- if type(cli_command_input) == str:
168
- base_commands.append(cli_command_input)
169
- else:
170
- base_commands.append(shlex.join(cli_command_input))
171
- elif app_config.get("commands", None) is not None:
172
- base_commands.extend(app_config.get("commands"))
173
- return base_commands
174
-
175
-
176
- def common_deploy_options(func):
177
- @click.option(
178
- "--name",
179
- type=str,
180
- help="The name of the app to deploy.",
181
- )
182
- @click.option("--port", type=int, help="Port where the app is hosted.")
183
- @click.option(
184
- "--tag",
185
- "tags",
186
- multiple=True,
187
- type=KVPairType,
188
- help="The tags of the app to deploy. Format KEY=VALUE. Example --tag foo=bar --tag x=y",
189
- default=None,
190
- )
191
- @click.option(
192
- "--image",
193
- type=str,
194
- help="The Docker image to deploy with the App",
195
- default=None,
196
- )
197
- @click.option(
198
- "--cpu",
199
- type=str,
200
- help="CPU resource request and limit",
201
- default=None,
202
- )
203
- @click.option(
204
- "--memory",
205
- type=str,
206
- help="Memory resource request and limit",
207
- default=None,
208
- )
209
- @click.option(
210
- "--gpu",
211
- type=str,
212
- help="GPU resource request and limit",
213
- default=None,
214
- )
215
- @click.option(
216
- "--disk",
217
- type=str,
218
- help="Storage resource request and limit",
219
- default=None,
220
- )
221
- @click.option(
222
- "--health-check-enabled",
223
- type=bool,
224
- help="Enable health checks",
225
- default=None,
226
- )
227
- @click.option(
228
- "--health-check-path",
229
- type=str,
230
- help="Health check path",
231
- default=None,
232
- )
233
- @click.option(
234
- "--health-check-initial-delay",
235
- type=int,
236
- help="Initial delay seconds for health check",
237
- default=None,
238
- )
239
- @click.option(
240
- "--health-check-period",
241
- type=int,
242
- help="Period seconds for health check",
243
- default=None,
244
- )
245
- @click.option(
246
- "--compute-pools",
247
- type=CommaSeparatedListType,
248
- help="The compute pools to deploy the app to. Example: --compute-pools default,large",
249
- default=None,
250
- )
251
- @click.option(
252
- "--auth-type",
253
- type=click.Choice(AuthType.enums()),
254
- help="The type of authentication to use for the app.",
255
- default=None,
256
- )
257
- @click.option(
258
- "--public-access/--private-access",
259
- "auth_public",
260
- type=bool,
261
- help="Whether the app is public or not.",
262
- default=None,
263
- )
264
- @click.option(
265
- "--no-deps",
266
- is_flag=True,
267
- help="Do not any dependencies. Directly used the image provided",
268
- default=False,
269
- )
270
- @click.option(
271
- "--min-replicas",
272
- type=int,
273
- help="Minimum number of replicas to deploy",
274
- default=None,
275
- )
276
- @click.option(
277
- "--max-replicas",
278
- type=int,
279
- help="Maximum number of replicas to deploy",
280
- default=None,
281
- )
282
- @click.option(
283
- "--description",
284
- type=str,
285
- help="The description of the app to deploy.",
286
- default=None,
287
- )
288
- @click.option(
289
- "--app-type",
290
- type=str,
291
- help="The type of app to deploy.",
292
- default=None,
293
- )
294
- @wraps(func)
295
- def wrapper(*args, **kwargs):
296
- return func(*args, **kwargs)
297
-
298
- return wrapper
299
-
300
-
301
- def common_run_options(func):
302
- """Common options for running and deploying apps."""
303
-
304
- @click.option(
305
- "--config-file",
306
- type=str,
307
- help="The config file to use for the App (YAML or JSON)",
308
- default=None,
309
- )
310
- @click.option(
311
- "--secret",
312
- "secrets",
313
- multiple=True,
314
- type=str,
315
- help="Secrets to deploy with the App",
316
- default=None,
317
- )
318
- @click.option(
319
- "--env",
320
- "envs",
321
- multiple=True,
322
- type=KVPairType,
323
- help="Environment variables to deploy with the App. Use format KEY=VALUE",
324
- default=None,
325
- )
326
- @click.option(
327
- "--package-src-path",
328
- type=str,
329
- help="The path to the source code to deploy with the App.",
330
- default=None,
331
- )
332
- @click.option(
333
- "--package-suffixes",
334
- type=CommaSeparatedListType,
335
- help="The suffixes of the source code to deploy with the App.",
336
- default=None,
337
- )
338
- @click.option(
339
- "--dep-from-requirements",
340
- type=str,
341
- help="Path to requirements.txt file for dependencies",
342
- default=None,
343
- )
344
- @click.option(
345
- "--dep-from-pyproject",
346
- type=str,
347
- help="Path to pyproject.toml file for dependencies",
348
- default=None,
349
- )
350
- # TODO: [FIX ME]: Get better CLI abstraction for pypi/conda dependencies
351
- @wraps(func)
352
- def wrapper(*args, **kwargs):
353
- return func(*args, **kwargs)
354
-
355
- return wrapper
356
-
357
-
358
- def _package_necessary_things(app_config: AppConfig, logger):
359
- # Packaging has a few things to be thought through:
360
- # 1. if `entrypoint_path` exists then should we package the directory
361
- # where the entrypoint lives. For example : if the user calls
362
- # `outerbounds app deploy foo/bar.py` should we package `foo` dir
363
- # or should we package the cwd from which foo/bar.py is being called.
364
- # 2. if the src path is used with the config file then how should we view
365
- # that path ?
366
- # 3. It becomes interesting when users call the deployment with config files
367
- # where there is a `src_path` and then is the src_path relative to the config file
368
- # or is it relative to where the caller command is sitting. Ideally it should work
369
- # like Kustomizations where its relative to where the yaml file sits for simplicity
370
- # of understanding relationships between config files. Ideally users can pass the src_path
371
- # from the command line and that will aliviate any need to package any other directories for
372
- #
373
-
374
- package_dir = app_config.get_state("packaging_directory")
375
- if package_dir is None:
376
- app_config.set_state("code_package_url", None)
377
- app_config.set_state("code_package_key", None)
378
- return
379
- from metaflow.metaflow_config import DEFAULT_DATASTORE
380
-
381
- package = app_config.get_state("package") or {}
382
- suffixes = package.get("suffixes", None)
383
-
384
- packager = CodePackager(
385
- datastore_type=DEFAULT_DATASTORE, code_package_prefix=CODE_PACKAGE_PREFIX
386
- )
387
- package_url, package_key = packager.store(
388
- paths_to_include=[package_dir], file_suffixes=suffixes
389
- )
390
- app_config.set_state("code_package_url", package_url)
391
- app_config.set_state("code_package_key", package_key)
392
- logger("💾 Code Package Saved to : %s" % app_config.get_state("code_package_url"))
393
-
394
-
395
- @app.command(help="Deploy an app to the Outerbounds Platform.")
396
- @common_deploy_options
397
- @common_run_options
398
- @experimental.wrapping_cli_options
399
- @click.pass_context
400
- @click.argument("command", nargs=-1, type=click.UNPROCESSED, required=False)
401
- def deploy(ctx, command, **options):
402
- """Deploy an app to the Outerbounds Platform."""
403
- from functools import partial
404
-
405
- if not ctx.obj.perimeter:
406
- raise AppConfigError("OB_CURRENT_PERIMETER is not set")
407
-
408
- logger = partial(_logger, timestamp=True)
409
- try:
410
- # Create configuration
411
- if options["config_file"]:
412
- # Load from file
413
- app_config = AppConfig.from_file(options["config_file"])
414
-
415
- # Update with any CLI options using the unified method
416
- app_config.update_from_cli_options(options)
417
- else:
418
- # Create from CLI options
419
- config_dict = build_config_from_options(options)
420
- app_config = AppConfig(config_dict)
421
-
422
- # Validate the configuration
423
- app_config.validate()
424
- logger(
425
- f"🚀 Deploying {app_config.get('name')} to the Outerbounds platform...",
426
- color=LOGGER_COLOR,
427
- system_msg=True,
428
- )
429
-
430
- packaging_directory = None
431
- package_src_path = app_config.get("package", {}).get("src_path", None)
432
- if package_src_path:
433
- if os.path.isfile(package_src_path):
434
- raise AppConfigError("src_path must be a directory, not a file")
435
- elif os.path.isdir(package_src_path):
436
- packaging_directory = os.path.abspath(package_src_path)
437
- else:
438
- raise AppConfigError(f"src_path '{package_src_path}' does not exist")
439
- else:
440
- # If src_path is None then we assume then we can assume for the moment
441
- # that we can package the current working directory.
442
- packaging_directory = os.getcwd()
443
-
444
- app_config.set_state("packaging_directory", packaging_directory)
445
- logger(
446
- "📦 Packaging Directory : %s" % app_config.get_state("packaging_directory"),
447
- )
448
- # TODO: Construct the command needed to run the app
449
- # If we are constructing the directory with the src_path
450
- # then we need to add the command from the option otherwise
451
- # we use the command from the entrypoint path and whatever follows `--`
452
- # is the command to run.
453
-
454
- # Set some defaults for the deploy command
455
- app_config.set_deploy_defaults(packaging_directory)
456
-
457
- if options.get("no_deps") == True:
458
- # Setting this in the state will make it skip the fast-bakery step
459
- # of building an image.
460
- app_config.set_state("skip_dependencies", True)
461
- else:
462
- # Check if the user has set the dependencies in the app config
463
- dependencies = app_config.get("dependencies", {})
464
- if len(dependencies) == 0:
465
- # The user has not set any dependencies, so we can sniff the packaging directory
466
- # for a dependencies file.
467
- requirements_file = os.path.join(
468
- packaging_directory, "requirements.txt"
469
- )
470
- pyproject_toml = os.path.join(packaging_directory, "pyproject.toml")
471
- if os.path.exists(pyproject_toml):
472
- app_config.set_state(
473
- "dependencies", {"from_pyproject_toml": pyproject_toml}
474
- )
475
- logger(
476
- "📦 Using dependencies from pyproject.toml: %s" % pyproject_toml
477
- )
478
- elif os.path.exists(requirements_file):
479
- app_config.set_state(
480
- "dependencies", {"from_requirements_file": requirements_file}
481
- )
482
- logger(
483
- "📦 Using dependencies from requirements.txt: %s"
484
- % requirements_file
485
- )
486
-
487
- # Print the configuration
488
- # 1. validate that the secrets for the app exist
489
- # 2. TODO: validate that the compute pool specified in the app exists.
490
- # 3. Building Docker image if necessary (based on parameters)
491
- # - We will bake images with fastbakery and pass it to the deploy command
492
- # TODO: validation logic can be wrapped in try catch so that we can provide
493
- # better error messages.
494
- cache_dir = os.path.join(
495
- ctx.obj.app_state_dir, app_config.get("name", "default")
496
- )
497
- deploy_validations(
498
- app_config,
499
- cache_dir=cache_dir,
500
- logger=logger,
501
- )
502
-
503
- base_commands = parse_commands(app_config, command)
504
-
505
- app_config.set_state("commands", base_commands)
506
-
507
- # TODO: Handle the case where packaging_directory is None
508
- # This would involve:
509
- # 1. Packaging the code:
510
- # - We need to package the code and throw the tarball to some object store
511
- _package_necessary_things(app_config, logger)
512
-
513
- app_config.set_state("perimeter", ctx.obj.perimeter)
514
-
515
- # 2. Convert to the IR that the backend accepts
516
- capsule = CapsuleDeployer(app_config, ctx.obj.api_url, debug_dir=cache_dir)
517
-
518
- _pre_create_debug(app_config, capsule, cache_dir)
519
- # 3. Throw the job into the platform and report deployment status
520
- logger(
521
- f"🚀 Deploying {capsule.capsule_type.lower()} to the platform...",
522
- color=LOGGER_COLOR,
523
- system_msg=True,
524
- )
525
- capsule.create()
526
- capsule.wait_for_terminal_state(logger=logger)
527
- logger(
528
- f"💊 {capsule.capsule_type} {app_config.config['name']} ({capsule.identifier}) deployed successfully! You can access it at {capsule.status.out_of_cluster_url}",
529
- color=LOGGER_COLOR,
530
- system_msg=True,
531
- )
532
-
533
- except AppConfigError as e:
534
- click.echo(f"Error in app configuration: {e}", err=True)
535
- raise e
536
- except Exception as e:
537
- click.echo(f"Error deploying app: {e}", err=True)
538
- raise e
539
-
540
-
541
- def _parse_capsule_table(filtered_capsules):
542
- headers = ["Name", "ID", "Ready", "App Type", "Port", "Tags", "URL"]
543
- table_data = []
544
-
545
- for capsule in filtered_capsules:
546
- spec = capsule.get("spec", {})
547
- status = capsule.get("status", {}) or {}
548
- cap_id = capsule.get("id")
549
- display_name = spec.get("displayName", "")
550
- ready = str(status.get("readyToServeTraffic", False))
551
- auth_type = spec.get("authConfig", {}).get("authType", "")
552
- port = str(spec.get("port", ""))
553
- tags_str = ", ".join(
554
- [f"{tag['key']}={tag['value']}" for tag in spec.get("tags", [])]
555
- )
556
- access_info = status.get("accessInfo", {}) or {}
557
- url = access_info.get("outOfClusterURL", None)
558
-
559
- table_data.append(
560
- [
561
- display_name,
562
- cap_id,
563
- ready,
564
- auth_type,
565
- port,
566
- tags_str,
567
- f"https://{url}" if url else "URL not available",
568
- ]
569
- )
570
- return headers, table_data
571
-
572
-
573
- @app.command(help="List apps in the Outerbounds Platform.")
574
- @click.option("--project", type=str, help="Filter apps by project")
575
- @click.option("--branch", type=str, help="Filter apps by branch")
576
- @click.option("--name", type=str, help="Filter apps by name")
577
- @click.option(
578
- "--tag",
579
- "tags",
580
- type=KVDictType,
581
- help="Filter apps by tag. Format KEY=VALUE. Example --tag foo=bar --tag x=y. If multiple tags are provided, the app must match all of them.",
582
- multiple=True,
583
- )
584
- @click.option(
585
- "--format",
586
- type=click.Choice(["json", "text"]),
587
- help="Format the output",
588
- default="text",
589
- )
590
- @click.option(
591
- "--auth-type", type=click.Choice(AuthType.enums()), help="Filter apps by Auth type"
592
- )
593
- @click.pass_context
594
- def list(ctx, project, branch, name, tags, format, auth_type):
595
- """List apps in the Outerbounds Platform."""
596
-
597
- filtered_capsules = list_and_filter_capsules(
598
- ctx.obj.api_url, ctx.obj.perimeter, project, branch, name, tags, auth_type, None
599
- )
600
- if format == "json":
601
- click.echo(json.dumps(filtered_capsules, indent=4))
602
- else:
603
- headers, table_data = _parse_capsule_table(filtered_capsules)
604
- print_table(table_data, headers)
605
-
606
-
607
- @app.command(help="Delete an app/apps from the Outerbounds Platform.")
608
- @click.option("--name", type=str, help="Filter app to delete by name")
609
- @click.option("--id", "cap_id", type=str, help="Filter app to delete by id")
610
- @click.option("--project", type=str, help="Filter apps to delete by project")
611
- @click.option("--branch", type=str, help="Filter apps to delete by branch")
612
- @click.option(
613
- "--tag",
614
- "tags",
615
- multiple=True,
616
- type=KVDictType,
617
- help="Filter apps to delete by tag. Format KEY=VALUE. Example --tag foo=bar --tag x=y. If multiple tags are provided, the app must match all of them.",
618
- )
619
- @click.pass_context
620
- def delete(ctx, name, cap_id, project, branch, tags):
621
-
622
- """Delete an app/apps from the Outerbounds Platform."""
623
- # Atleast one of the args need to be provided
624
- if not any(
625
- [
626
- name is not None,
627
- cap_id is not None,
628
- project is not None,
629
- branch is not None,
630
- len(tags) != 0,
631
- ]
632
- ):
633
- raise AppConfigError(
634
- "Atleast one of the options need to be provided. You can use --name, --id, --project, --branch, --tag"
635
- )
636
-
637
- filtered_capsules = list_and_filter_capsules(
638
- ctx.obj.api_url, ctx.obj.perimeter, project, branch, name, tags, None, cap_id
639
- )
640
-
641
- headers, table_data = _parse_capsule_table(filtered_capsules)
642
- click.secho("The following apps will be deleted:", fg="red", bold=True)
643
- print_table(table_data, headers)
644
-
645
- # Confirm the deletion
646
- confirm = click.prompt(
647
- click.style(
648
- "💊 Are you sure you want to delete these apps?", fg="red", bold=True
649
- ),
650
- default="no",
651
- type=click.Choice(["yes", "no"]),
652
- )
653
- if confirm == "no":
654
- exit(1)
655
-
656
- def item_show_func(x):
657
- if not x:
658
- return None
659
- name = x.get("spec", {}).get("displayName", "")
660
- id = x.get("id", "")
661
- return click.style("💊 deleting %s [%s]" % (name, id), fg="red", bold=True)
662
-
663
- with click.progressbar(
664
- filtered_capsules,
665
- label=click.style("💊 Deleting apps...", fg="red", bold=True),
666
- fill_char=click.style("█", fg="red", bold=True),
667
- empty_char=click.style("░", fg="red", bold=True),
668
- item_show_func=item_show_func,
669
- ) as bar:
670
- capsule_api = CapsuleApi(ctx.obj.api_url, ctx.obj.perimeter)
671
- for capsule in bar:
672
- capsule_api.delete(capsule.get("id"))
673
-
674
-
675
- @app.command(help="Run an app locally (for testing).")
676
- @common_run_options
677
- @click.pass_context
678
- def run(ctx, **options):
679
- """Run an app locally for testing."""
680
- try:
681
- # Create configuration
682
- if options["config_file"]:
683
- # Load from file
684
- app_config = AppConfig.from_file(options["config_file"])
685
-
686
- # Update with any CLI options using the unified method
687
- app_config.update_from_cli_options(options)
688
- else:
689
- # Create from CLI options
690
- config_dict = build_config_from_options(options)
691
- app_config = AppConfig(config_dict)
692
-
693
- # Validate the configuration
694
- app_config.validate()
695
-
696
- # Print the configuration
697
- click.echo("Running App with configuration:")
698
- click.echo(app_config.to_yaml())
699
-
700
- # TODO: Implement local run logic
701
- # This would involve:
702
- # 1. Setting up the environment
703
- # 2. Running the app locally
704
- # 3. Reporting status
705
-
706
- click.echo(f"App '{app_config.config['name']}' running locally!")
707
-
708
- except AppConfigError as e:
709
- click.echo(f"Error in app configuration: {e}", err=True)
710
- ctx.exit(1)
711
- except Exception as e:
712
- click.echo(f"Error running app: {e}", err=True)
713
- ctx.exit(1)
714
-
715
-
716
- # if __name__ == "__main__":
717
- # cli()