outerbounds 0.3.175rc1__py3-none-any.whl → 0.3.176rc2__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.
@@ -13,14 +13,11 @@ from .app_config import (
13
13
  AuthType,
14
14
  )
15
15
  from .cli_to_config import build_config_from_options
16
- from .utils import (
17
- CommaSeparatedListType,
18
- KVPairType,
19
- )
16
+ from .utils import CommaSeparatedListType, KVPairType, KVDictType
20
17
  from . import experimental
21
18
  from .validations import deploy_validations
22
19
  from .code_package import CodePackager
23
- from .capsule import Capsule
20
+ from .capsule import Capsule, list_and_filter_capsules
24
21
  import shlex
25
22
  import time
26
23
  import uuid
@@ -30,6 +27,8 @@ LOGGER_TIMESTAMP = "magenta"
30
27
  LOGGER_COLOR = "green"
31
28
  LOGGER_BAD_COLOR = "red"
32
29
 
30
+ NativeList = list
31
+
33
32
 
34
33
  def _logger(
35
34
  body="", system_msg=False, head="", bad=False, timestamp=True, nl=True, color=None
@@ -75,6 +74,36 @@ def _pre_create_debug(app_config: AppConfig, capsule: Capsule, state_dir: str):
75
74
  )
76
75
 
77
76
 
77
+ def print_table(data, headers):
78
+ """Print data in a formatted table."""
79
+ if not data:
80
+ return
81
+
82
+ # Calculate column widths
83
+ col_widths = [len(h) for h in headers]
84
+
85
+ # Calculate actual widths based on data
86
+ for row in data:
87
+ for i, cell in enumerate(row):
88
+ col_widths[i] = max(col_widths[i], len(str(cell)))
89
+
90
+ # Print header
91
+ header_row = " | ".join(
92
+ [headers[i].ljust(col_widths[i]) for i in range(len(headers))]
93
+ )
94
+ click.secho("-" * len(header_row), fg="yellow")
95
+ click.secho(header_row, fg="yellow", bold=True)
96
+ click.secho("-" * len(header_row), fg="yellow")
97
+
98
+ # Print data rows
99
+ for row in data:
100
+ formatted_row = " | ".join(
101
+ [str(row[i]).ljust(col_widths[i]) for i in range(len(row))]
102
+ )
103
+ click.secho(formatted_row, fg="green", bold=True)
104
+ click.secho("-" * len(header_row), fg="yellow")
105
+
106
+
78
107
  @click.group()
79
108
  def cli():
80
109
  """Outerbounds CLI tool."""
@@ -490,6 +519,139 @@ def deploy(ctx, command, **options):
490
519
  raise e
491
520
 
492
521
 
522
+ def _parse_capsule_table(filtered_capsules):
523
+ headers = ["Name", "ID", "Ready", "App Type", "Port", "Tags", "URL"]
524
+ table_data = []
525
+
526
+ for capsule in filtered_capsules:
527
+ spec = capsule.get("spec", {})
528
+ status = capsule.get("status", {})
529
+ cap_id = capsule.get("id")
530
+
531
+ display_name = spec.get("displayName", "")
532
+ ready = str(status.get("readyToServeTraffic", False))
533
+ auth_type = spec.get("authConfig", {}).get("authType", "")
534
+ port = str(spec.get("port", ""))
535
+ tags_str = ", ".join(
536
+ [f"{tag['key']}={tag['value']}" for tag in spec.get("tags", [])]
537
+ )
538
+ url = status.get("accessInfo", {}).get("outOfClusterURL", "")
539
+
540
+ table_data.append(
541
+ [
542
+ display_name,
543
+ cap_id,
544
+ ready,
545
+ auth_type,
546
+ port,
547
+ tags_str,
548
+ f"https://{url}",
549
+ ]
550
+ )
551
+ return headers, table_data
552
+
553
+
554
+ @app.command(help="List apps in the Outerbounds Platform.")
555
+ @click.option("--project", type=str, help="Filter apps by project")
556
+ @click.option("--branch", type=str, help="Filter apps by branch")
557
+ @click.option("--name", type=str, help="Filter apps by name")
558
+ @click.option(
559
+ "--tag",
560
+ "tags",
561
+ type=KVDictType,
562
+ 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.",
563
+ multiple=True,
564
+ )
565
+ @click.option(
566
+ "--format",
567
+ type=click.Choice(["json", "text"]),
568
+ help="Format the output",
569
+ default="text",
570
+ )
571
+ @click.option(
572
+ "--auth-type", type=click.Choice(AuthType.enums()), help="Filter apps by Auth type"
573
+ )
574
+ @click.pass_context
575
+ def list(ctx, project, branch, name, tags, format, auth_type):
576
+ """List apps in the Outerbounds Platform."""
577
+
578
+ filtered_capsules = list_and_filter_capsules(
579
+ ctx.obj.api_url, ctx.obj.perimeter, project, branch, name, tags, auth_type, None
580
+ )
581
+ if format == "json":
582
+ click.echo(json.dumps(filtered_capsules, indent=4))
583
+ else:
584
+ headers, table_data = _parse_capsule_table(filtered_capsules)
585
+ print_table(table_data, headers)
586
+
587
+
588
+ @app.command(help="Delete an app/apps from the Outerbounds Platform.")
589
+ @click.option("--name", type=str, help="Filter app to delete by name")
590
+ @click.option("--id", "cap_id", type=str, help="Filter app to delete by id")
591
+ @click.option("--project", type=str, help="Filter apps to delete by project")
592
+ @click.option("--branch", type=str, help="Filter apps to delete by branch")
593
+ @click.option(
594
+ "--tag",
595
+ "tags",
596
+ multiple=True,
597
+ type=KVPairType,
598
+ 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.",
599
+ )
600
+ @click.pass_context
601
+ def delete(ctx, name, cap_id, project, branch, tags):
602
+
603
+ """Delete an app/apps from the Outerbounds Platform."""
604
+ # Atleast one of the args need to be provided
605
+ if not any(
606
+ [
607
+ name is not None,
608
+ cap_id is not None,
609
+ project is not None,
610
+ branch is not None,
611
+ len(tags) != 0,
612
+ ]
613
+ ):
614
+ raise AppConfigError(
615
+ "Atleast one of the options need to be provided. You can use --name, --id, --project, --branch, --tag"
616
+ )
617
+
618
+ filtered_capsules = list_and_filter_capsules(
619
+ ctx.obj.api_url, ctx.obj.perimeter, project, branch, name, tags, None, cap_id
620
+ )
621
+
622
+ headers, table_data = _parse_capsule_table(filtered_capsules)
623
+ click.secho("The following apps will be deleted:", fg="red", bold=True)
624
+ print_table(table_data, headers)
625
+
626
+ # Confirm the deletion
627
+ confirm = click.prompt(
628
+ click.style(
629
+ "💊 Are you sure you want to delete these apps?", fg="red", bold=True
630
+ ),
631
+ default="no",
632
+ type=click.Choice(["yes", "no"]),
633
+ )
634
+ if confirm == "no":
635
+ exit(1)
636
+
637
+ def item_show_func(x):
638
+ if not x:
639
+ return None
640
+ name = x.get("spec", {}).get("displayName", "")
641
+ id = x.get("id", "")
642
+ return click.style("💊 deleting %s [%s]" % (name, id), fg="red", bold=True)
643
+
644
+ with click.progressbar(
645
+ filtered_capsules,
646
+ label=click.style("💊 Deleting apps...", fg="red", bold=True),
647
+ fill_char=click.style("█", fg="red", bold=True),
648
+ empty_char=click.style("░", fg="red", bold=True),
649
+ item_show_func=item_show_func,
650
+ ) as bar:
651
+ for capsule in bar:
652
+ Capsule.delete(capsule.get("id"), ctx.obj.api_url, ctx.obj.perimeter)
653
+
654
+
493
655
  @app.command(help="Run an app locally (for testing).")
494
656
  @common_run_options
495
657
  @click.pass_context
@@ -270,9 +270,10 @@ def get_capsule(capsule_id: str, api_url: str, request_headers: dict):
270
270
 
271
271
 
272
272
  def delete_capsule(capsule_id: str, api_url: str, request_headers: dict):
273
+ _url = os.path.join(api_url, capsule_id)
273
274
  response = safe_requests_wrapper(
274
275
  requests.delete,
275
- os.path.join(api_url, capsule_id),
276
+ _url,
276
277
  headers=request_headers,
277
278
  retryable_status_codes=[409], # todo : verify me
278
279
  )
@@ -281,15 +282,117 @@ def delete_capsule(capsule_id: str, api_url: str, request_headers: dict):
281
282
  f"Failed to delete capsule: {response.status_code} {response.text}"
282
283
  )
283
284
 
285
+ if response.status_code == 200:
286
+ return True
287
+ return False
288
+
289
+
290
+ def list_capsules(api_url: str, request_headers: dict):
291
+ response = safe_requests_wrapper(
292
+ requests.get,
293
+ api_url,
294
+ headers=request_headers,
295
+ )
296
+ if response.status_code >= 400:
297
+ raise TODOException(
298
+ f"Failed to list capsules: {response.status_code} {response.text}"
299
+ )
284
300
  return response.json()
285
301
 
286
302
 
303
+ def list_and_filter_capsules(
304
+ api_url, perimeter, project, branch, name, tags, auth_type, capsule_id
305
+ ):
306
+ capsules = Capsule.list(api_url, perimeter)
307
+
308
+ def _tags_match(tags, key, value):
309
+ for t in tags:
310
+ if t["key"] == key and t["value"] == value:
311
+ return True
312
+ return False
313
+
314
+ def _all_tags_match(tags, tags_to_match):
315
+ for t in tags_to_match:
316
+ if _tags_match(tags, t["key"], t["value"]):
317
+ return True
318
+ return False
319
+
320
+ def _filter_capsules(capsules, project, branch, name, tags, auth_type, capsule_id):
321
+ _filtered_capsules = []
322
+ for capsule in capsules:
323
+ set_tags = capsule.get("spec", {}).get("tags", [])
324
+ display_name = capsule.get("spec", {}).get("displayName", None)
325
+ set_id = capsule.get("id", None)
326
+ set_auth_type = (
327
+ capsule.get("spec", {}).get("authConfig", {}).get("authType", None)
328
+ )
329
+
330
+ if auth_type and set_auth_type != auth_type:
331
+ continue
332
+ if project and not _tags_match(set_tags, "project", project):
333
+ continue
334
+ if branch and not _tags_match(set_tags, "branch", branch):
335
+ continue
336
+ if name and display_name != name:
337
+ continue
338
+ if tags and not _all_tags_match(set_tags, tags):
339
+ continue
340
+ if capsule_id and set_id != capsule_id:
341
+ continue
342
+
343
+ _filtered_capsules.append(capsule)
344
+ return _filtered_capsules
345
+
346
+ return _filter_capsules(
347
+ capsules, project, branch, name, tags, auth_type, capsule_id
348
+ )
349
+
350
+
287
351
  class Capsule:
288
352
 
289
353
  status: CapsuleStateMachine
290
354
 
291
355
  identifier = None
292
356
 
357
+ @classmethod
358
+ def list(cls, base_url: str, perimeter: str):
359
+ base_url = cls._create_base_url(base_url, perimeter)
360
+ from metaflow.metaflow_config import SERVICE_HEADERS
361
+
362
+ request_headers = {
363
+ **{"Content-Type": "application/json", "Connection": "keep-alive"},
364
+ **(SERVICE_HEADERS or {}),
365
+ }
366
+ _capsules = list_capsules(base_url, request_headers)
367
+ if "capsules" not in _capsules:
368
+ raise TODOException(f"Failed to list capsules")
369
+ return _capsules.get("capsules", [])
370
+
371
+ @classmethod
372
+ def delete(cls, identifier: str, base_url: str, perimeter: str):
373
+ base_url = cls._create_base_url(base_url, perimeter)
374
+ from metaflow.metaflow_config import SERVICE_HEADERS
375
+
376
+ request_headers = {
377
+ **{"Content-Type": "application/json", "Connection": "keep-alive"},
378
+ **(SERVICE_HEADERS or {}),
379
+ }
380
+ return delete_capsule(identifier, base_url, request_headers)
381
+
382
+ @classmethod
383
+ def _create_base_url(
384
+ cls,
385
+ base_url: str,
386
+ perimeter: str,
387
+ ):
388
+ return os.path.join(
389
+ base_url,
390
+ "v1",
391
+ "perimeters",
392
+ perimeter,
393
+ "capsules",
394
+ )
395
+
293
396
  # TODO: Current default timeout is very large of 5 minutes. Ideally we should have finished the deployed in less than 1 minutes.
294
397
  def __init__(
295
398
  self,
@@ -299,12 +402,9 @@ class Capsule:
299
402
  debug_dir: Optional[str] = None,
300
403
  ):
301
404
  self._app_config = app_config
302
- self._base_url = os.path.join(
405
+ self._base_url = self._create_base_url(
303
406
  base_url,
304
- "v1",
305
- "perimeters",
306
407
  app_config.get_state("perimeter"),
307
- "capsules",
308
408
  )
309
409
  self._create_timeout = create_timeout
310
410
  self._debug_dir = debug_dir
@@ -371,9 +471,3 @@ class Capsule:
371
471
  state_machine.check_for_debug(self._debug_dir)
372
472
 
373
473
  return capsule_response
374
-
375
- def list(self):
376
- return list_capsules(self._base_url, self._request_headers)
377
-
378
- def delete(self):
379
- return delete_capsule(self.identifier, self._base_url, self._request_headers)
outerbounds/apps/utils.py CHANGED
@@ -18,6 +18,31 @@ class MaximumRetriesExceeded(Exception):
18
18
  return f"Maximum retries exceeded for {self.url}[{self.method}] {self.status_code} {self.text}"
19
19
 
20
20
 
21
+ class KeyValueDictPair(click.ParamType):
22
+ name = "KV-DICT-PAIR"
23
+
24
+ def convert(self, value, param, ctx):
25
+ # Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
26
+ if len(value.split("=", 1)) != 2:
27
+ self.fail(
28
+ f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
29
+ )
30
+
31
+ key, _value = value.split("=", 1)
32
+ try:
33
+ return {"key": key, "value": json.loads(_value)}
34
+ except json.JSONDecodeError:
35
+ return {"key": key, "value": _value}
36
+ except Exception as e:
37
+ self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
38
+
39
+ def __str__(self):
40
+ return repr(self)
41
+
42
+ def __repr__(self):
43
+ return "KV-PAIR"
44
+
45
+
21
46
  class KeyValuePair(click.ParamType):
22
47
  name = "KV-PAIR"
23
48
 
@@ -155,6 +180,7 @@ KVPairType = KeyValuePair()
155
180
  MetaflowArtifactType = MountMetaflowArtifact()
156
181
  SecretMountType = MountSecret()
157
182
  CommaSeparatedListType = CommaSeparatedList()
183
+ KVDictType = KeyValueDictPair()
158
184
 
159
185
 
160
186
  class TODOException(Exception):
@@ -410,7 +410,7 @@ def kill_process(config_dir=None, profile=None, port=-1, name=""):
410
410
  default=os.environ.get("METAFLOW_PROFILE", ""),
411
411
  help="The named metaflow profile in which your workstation exists",
412
412
  )
413
- def list(config_dir=None, profile=None):
413
+ def list_local(config_dir=None, profile=None):
414
414
  if "WORKSTATION_ID" not in os.environ:
415
415
  click.secho(
416
416
  "All outerbounds app commands can only be run from a workstation.",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: outerbounds
3
- Version: 0.3.175rc1
3
+ Version: 0.3.176rc2
4
4
  Summary: More Data Science, Less Administration
5
5
  License: Proprietary
6
6
  Keywords: data science,machine learning,MLOps
@@ -29,8 +29,8 @@ Requires-Dist: google-cloud-secret-manager (>=2.20.0,<3.0.0) ; extra == "gcp"
29
29
  Requires-Dist: google-cloud-storage (>=2.14.0,<3.0.0) ; extra == "gcp"
30
30
  Requires-Dist: metaflow-checkpoint (==0.2.1)
31
31
  Requires-Dist: ob-metaflow (==2.15.14.1)
32
- Requires-Dist: ob-metaflow-extensions (==1.1.161rc1)
33
- Requires-Dist: ob-metaflow-stubs (==6.0.3.175rc1)
32
+ Requires-Dist: ob-metaflow-extensions (==1.1.163rc3)
33
+ Requires-Dist: ob-metaflow-stubs (==6.0.3.176rc2)
34
34
  Requires-Dist: opentelemetry-distro (>=0.41b0) ; extra == "otel"
35
35
  Requires-Dist: opentelemetry-exporter-otlp-proto-http (>=1.20.0) ; extra == "otel"
36
36
  Requires-Dist: opentelemetry-instrumentation-requests (>=0.41b0) ; extra == "otel"
@@ -40,10 +40,10 @@ outerbounds/_vendor/yaml/scanner.py,sha256=ZcI8IngR56PaQ0m27WU2vxCqmDCuRjz-hr7pi
40
40
  outerbounds/_vendor/yaml/serializer.py,sha256=8wFZRy9SsQSktF_f9OOroroqsh4qVUe53ry07P9UgCc,4368
41
41
  outerbounds/_vendor/yaml/tokens.py,sha256=JBSu38wihGr4l73JwbfMA7Ks1-X84g8-NskTz7KwPmA,2578
42
42
  outerbounds/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
- outerbounds/apps/app_cli.py,sha256=zJFjoO6wPjRoKOPywWZ7NqXt4HwlHfPMQ6AquvKKAus,17833
43
+ outerbounds/apps/app_cli.py,sha256=kAKanVOVdBKI82ihAkRL2XcCLYyA-qkr_4FPS9xY-XI,23377
44
44
  outerbounds/apps/app_config.py,sha256=KBmW9grhiuG9XZG-R0GZkM-024cjj6ztGzOX_2wZW34,11291
45
45
  outerbounds/apps/artifacts.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
- outerbounds/apps/capsule.py,sha256=BRUBF8WV_lm4QCnxxPQ6jlikdKS_bvf41pwrRqgWIpQ,13608
46
+ outerbounds/apps/capsule.py,sha256=8UC0MXn3SeLBwX8k3ej5Uiqy6U5tcynIrNFZ0GRqFNk,16696
47
47
  outerbounds/apps/cli_to_config.py,sha256=hV6rfPgCiAX03O363GkvdjSIJBt3-oSbL6F2sTUucFE,3195
48
48
  outerbounds/apps/code_package/__init__.py,sha256=8McF7pgx8ghvjRnazp2Qktlxi9yYwNiwESSQrk-2oW8,68
49
49
  outerbounds/apps/code_package/code_packager.py,sha256=SQDBXKwizzpag5GpwoZpvvkyPOodRSQwk2ecAAfO0HI,23316
@@ -53,11 +53,11 @@ outerbounds/apps/dependencies.py,sha256=SqvdFQdFZZW0wXX_CHMHCrfE0TwaRkTvGCRbQ2Mx
53
53
  outerbounds/apps/deployer.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
54
  outerbounds/apps/experimental/__init__.py,sha256=12L_FzZyzv162uo4I6cmlrxat7feUtIu_kxbObTJZTA,3059
55
55
  outerbounds/apps/secrets.py,sha256=27qf04lOBqRjvcswj0ldHOmntP2T6SEjtMJtkJQ_GUg,6100
56
- outerbounds/apps/utils.py,sha256=JymjsgpU0osF-eDvstFS9zkM7bqliJdqAEV7kAjqxCM,7298
56
+ outerbounds/apps/utils.py,sha256=xKaSJRmAYToyWvantixLES1vdi5vLLdoQ6yZXAv9IBw,8089
57
57
  outerbounds/apps/validations.py,sha256=AVEw9eCvkzqq1m5ZC8btaWrSR6kWYKzarELfrASuAwQ,1117
58
58
  outerbounds/cli_main.py,sha256=e9UMnPysmc7gbrimq2I4KfltggyU7pw59Cn9aEguVcU,74
59
59
  outerbounds/command_groups/__init__.py,sha256=QPWtj5wDRTINDxVUL7XPqG3HoxHNvYOg08EnuSZB2Hc,21
60
- outerbounds/command_groups/apps_cli.py,sha256=weXYgUbTVIxMSweLVdod_C1laiB32YKwYhf22OfqQJE,20815
60
+ outerbounds/command_groups/apps_cli.py,sha256=ecXyLhGxjbct62iqviP9qBX8s4d-XG56ICpTM2h2etk,20821
61
61
  outerbounds/command_groups/cli.py,sha256=I8b0zyY_xhZfmaLgJcgR98YXxgirkUK7xsjknEd4nfc,534
62
62
  outerbounds/command_groups/fast_bakery_cli.py,sha256=5kja7v6C651XAY6dsP_IkBPJQgfU4hA4S9yTOiVPhW0,6213
63
63
  outerbounds/command_groups/flowprojects_cli.py,sha256=gFAA_zUIyhD092Hd7IW5InuIxOqdwRJsHgyWQjy8LZw,3792
@@ -72,7 +72,7 @@ outerbounds/utils/metaflowconfig.py,sha256=l2vJbgPkLISU-XPGZFaC8ZKmYFyJemlD6bwB-
72
72
  outerbounds/utils/schema.py,sha256=lMUr9kNgn9wy-sO_t_Tlxmbt63yLeN4b0xQXbDUDj4A,2331
73
73
  outerbounds/utils/utils.py,sha256=4Z8cszNob_8kDYCLNTrP-wWads_S_MdL3Uj3ju4mEsk,501
74
74
  outerbounds/vendor.py,sha256=gRLRJNXtZBeUpPEog0LOeIsl6GosaFFbCxUvR4bW6IQ,5093
75
- outerbounds-0.3.175rc1.dist-info/METADATA,sha256=BzZlrHhvLf-__NA2SX6gihlFIgGuXyJ-VhzpVrjSi3Q,1846
76
- outerbounds-0.3.175rc1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
77
- outerbounds-0.3.175rc1.dist-info/entry_points.txt,sha256=7ye0281PKlvqxu15rjw60zKg2pMsXI49_A8BmGqIqBw,47
78
- outerbounds-0.3.175rc1.dist-info/RECORD,,
75
+ outerbounds-0.3.176rc2.dist-info/METADATA,sha256=sv3uctge_hMpjW2RR9Il9aD6N1d_bIIXfYvnjcdtL9c,1846
76
+ outerbounds-0.3.176rc2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
77
+ outerbounds-0.3.176rc2.dist-info/entry_points.txt,sha256=7ye0281PKlvqxu15rjw60zKg2pMsXI49_A8BmGqIqBw,47
78
+ outerbounds-0.3.176rc2.dist-info/RECORD,,