outerbounds 0.3.55rc8__py3-none-any.whl → 0.3.133__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.
Files changed (56) hide show
  1. outerbounds/_vendor/PyYAML.LICENSE +20 -0
  2. outerbounds/_vendor/__init__.py +0 -0
  3. outerbounds/_vendor/_yaml/__init__.py +34 -0
  4. outerbounds/_vendor/click/__init__.py +73 -0
  5. outerbounds/_vendor/click/_compat.py +626 -0
  6. outerbounds/_vendor/click/_termui_impl.py +717 -0
  7. outerbounds/_vendor/click/_textwrap.py +49 -0
  8. outerbounds/_vendor/click/_winconsole.py +279 -0
  9. outerbounds/_vendor/click/core.py +2998 -0
  10. outerbounds/_vendor/click/decorators.py +497 -0
  11. outerbounds/_vendor/click/exceptions.py +287 -0
  12. outerbounds/_vendor/click/formatting.py +301 -0
  13. outerbounds/_vendor/click/globals.py +68 -0
  14. outerbounds/_vendor/click/parser.py +529 -0
  15. outerbounds/_vendor/click/py.typed +0 -0
  16. outerbounds/_vendor/click/shell_completion.py +580 -0
  17. outerbounds/_vendor/click/termui.py +787 -0
  18. outerbounds/_vendor/click/testing.py +479 -0
  19. outerbounds/_vendor/click/types.py +1073 -0
  20. outerbounds/_vendor/click/utils.py +580 -0
  21. outerbounds/_vendor/click.LICENSE +28 -0
  22. outerbounds/_vendor/vendor_any.txt +2 -0
  23. outerbounds/_vendor/yaml/__init__.py +471 -0
  24. outerbounds/_vendor/yaml/_yaml.cpython-311-darwin.so +0 -0
  25. outerbounds/_vendor/yaml/composer.py +146 -0
  26. outerbounds/_vendor/yaml/constructor.py +862 -0
  27. outerbounds/_vendor/yaml/cyaml.py +177 -0
  28. outerbounds/_vendor/yaml/dumper.py +138 -0
  29. outerbounds/_vendor/yaml/emitter.py +1239 -0
  30. outerbounds/_vendor/yaml/error.py +94 -0
  31. outerbounds/_vendor/yaml/events.py +104 -0
  32. outerbounds/_vendor/yaml/loader.py +62 -0
  33. outerbounds/_vendor/yaml/nodes.py +51 -0
  34. outerbounds/_vendor/yaml/parser.py +629 -0
  35. outerbounds/_vendor/yaml/reader.py +208 -0
  36. outerbounds/_vendor/yaml/representer.py +378 -0
  37. outerbounds/_vendor/yaml/resolver.py +245 -0
  38. outerbounds/_vendor/yaml/scanner.py +1555 -0
  39. outerbounds/_vendor/yaml/serializer.py +127 -0
  40. outerbounds/_vendor/yaml/tokens.py +129 -0
  41. outerbounds/command_groups/apps_cli.py +450 -0
  42. outerbounds/command_groups/cli.py +9 -5
  43. outerbounds/command_groups/local_setup_cli.py +247 -36
  44. outerbounds/command_groups/perimeters_cli.py +212 -32
  45. outerbounds/command_groups/tutorials_cli.py +111 -0
  46. outerbounds/command_groups/workstations_cli.py +2 -2
  47. outerbounds/utils/kubeconfig.py +2 -2
  48. outerbounds/utils/metaflowconfig.py +93 -16
  49. outerbounds/utils/schema.py +2 -2
  50. outerbounds/utils/utils.py +19 -0
  51. outerbounds/vendor.py +159 -0
  52. {outerbounds-0.3.55rc8.dist-info → outerbounds-0.3.133.dist-info}/METADATA +17 -6
  53. outerbounds-0.3.133.dist-info/RECORD +59 -0
  54. {outerbounds-0.3.55rc8.dist-info → outerbounds-0.3.133.dist-info}/WHEEL +1 -1
  55. outerbounds-0.3.55rc8.dist-info/RECORD +0 -15
  56. {outerbounds-0.3.55rc8.dist-info → outerbounds-0.3.133.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,127 @@
1
+ __all__ = ["Serializer", "SerializerError"]
2
+
3
+ from .error import YAMLError
4
+ from .events import *
5
+ from .nodes import *
6
+
7
+
8
+ class SerializerError(YAMLError):
9
+ pass
10
+
11
+
12
+ class Serializer:
13
+
14
+ ANCHOR_TEMPLATE = "id%03d"
15
+
16
+ def __init__(
17
+ self,
18
+ encoding=None,
19
+ explicit_start=None,
20
+ explicit_end=None,
21
+ version=None,
22
+ tags=None,
23
+ ):
24
+ self.use_encoding = encoding
25
+ self.use_explicit_start = explicit_start
26
+ self.use_explicit_end = explicit_end
27
+ self.use_version = version
28
+ self.use_tags = tags
29
+ self.serialized_nodes = {}
30
+ self.anchors = {}
31
+ self.last_anchor_id = 0
32
+ self.closed = None
33
+
34
+ def open(self):
35
+ if self.closed is None:
36
+ self.emit(StreamStartEvent(encoding=self.use_encoding))
37
+ self.closed = False
38
+ elif self.closed:
39
+ raise SerializerError("serializer is closed")
40
+ else:
41
+ raise SerializerError("serializer is already opened")
42
+
43
+ def close(self):
44
+ if self.closed is None:
45
+ raise SerializerError("serializer is not opened")
46
+ elif not self.closed:
47
+ self.emit(StreamEndEvent())
48
+ self.closed = True
49
+
50
+ # def __del__(self):
51
+ # self.close()
52
+
53
+ def serialize(self, node):
54
+ if self.closed is None:
55
+ raise SerializerError("serializer is not opened")
56
+ elif self.closed:
57
+ raise SerializerError("serializer is closed")
58
+ self.emit(
59
+ DocumentStartEvent(
60
+ explicit=self.use_explicit_start,
61
+ version=self.use_version,
62
+ tags=self.use_tags,
63
+ )
64
+ )
65
+ self.anchor_node(node)
66
+ self.serialize_node(node, None, None)
67
+ self.emit(DocumentEndEvent(explicit=self.use_explicit_end))
68
+ self.serialized_nodes = {}
69
+ self.anchors = {}
70
+ self.last_anchor_id = 0
71
+
72
+ def anchor_node(self, node):
73
+ if node in self.anchors:
74
+ if self.anchors[node] is None:
75
+ self.anchors[node] = self.generate_anchor(node)
76
+ else:
77
+ self.anchors[node] = None
78
+ if isinstance(node, SequenceNode):
79
+ for item in node.value:
80
+ self.anchor_node(item)
81
+ elif isinstance(node, MappingNode):
82
+ for key, value in node.value:
83
+ self.anchor_node(key)
84
+ self.anchor_node(value)
85
+
86
+ def generate_anchor(self, node):
87
+ self.last_anchor_id += 1
88
+ return self.ANCHOR_TEMPLATE % self.last_anchor_id
89
+
90
+ def serialize_node(self, node, parent, index):
91
+ alias = self.anchors[node]
92
+ if node in self.serialized_nodes:
93
+ self.emit(AliasEvent(alias))
94
+ else:
95
+ self.serialized_nodes[node] = True
96
+ self.descend_resolver(parent, index)
97
+ if isinstance(node, ScalarNode):
98
+ detected_tag = self.resolve(ScalarNode, node.value, (True, False))
99
+ default_tag = self.resolve(ScalarNode, node.value, (False, True))
100
+ implicit = (node.tag == detected_tag), (node.tag == default_tag)
101
+ self.emit(
102
+ ScalarEvent(alias, node.tag, implicit, node.value, style=node.style)
103
+ )
104
+ elif isinstance(node, SequenceNode):
105
+ implicit = node.tag == self.resolve(SequenceNode, node.value, True)
106
+ self.emit(
107
+ SequenceStartEvent(
108
+ alias, node.tag, implicit, flow_style=node.flow_style
109
+ )
110
+ )
111
+ index = 0
112
+ for item in node.value:
113
+ self.serialize_node(item, node, index)
114
+ index += 1
115
+ self.emit(SequenceEndEvent())
116
+ elif isinstance(node, MappingNode):
117
+ implicit = node.tag == self.resolve(MappingNode, node.value, True)
118
+ self.emit(
119
+ MappingStartEvent(
120
+ alias, node.tag, implicit, flow_style=node.flow_style
121
+ )
122
+ )
123
+ for key, value in node.value:
124
+ self.serialize_node(key, node, None)
125
+ self.serialize_node(value, node, key)
126
+ self.emit(MappingEndEvent())
127
+ self.ascend_resolver()
@@ -0,0 +1,129 @@
1
+ class Token(object):
2
+ def __init__(self, start_mark, end_mark):
3
+ self.start_mark = start_mark
4
+ self.end_mark = end_mark
5
+
6
+ def __repr__(self):
7
+ attributes = [key for key in self.__dict__ if not key.endswith("_mark")]
8
+ attributes.sort()
9
+ arguments = ", ".join(
10
+ ["%s=%r" % (key, getattr(self, key)) for key in attributes]
11
+ )
12
+ return "%s(%s)" % (self.__class__.__name__, arguments)
13
+
14
+
15
+ # class BOMToken(Token):
16
+ # id = '<byte order mark>'
17
+
18
+
19
+ class DirectiveToken(Token):
20
+ id = "<directive>"
21
+
22
+ def __init__(self, name, value, start_mark, end_mark):
23
+ self.name = name
24
+ self.value = value
25
+ self.start_mark = start_mark
26
+ self.end_mark = end_mark
27
+
28
+
29
+ class DocumentStartToken(Token):
30
+ id = "<document start>"
31
+
32
+
33
+ class DocumentEndToken(Token):
34
+ id = "<document end>"
35
+
36
+
37
+ class StreamStartToken(Token):
38
+ id = "<stream start>"
39
+
40
+ def __init__(self, start_mark=None, end_mark=None, encoding=None):
41
+ self.start_mark = start_mark
42
+ self.end_mark = end_mark
43
+ self.encoding = encoding
44
+
45
+
46
+ class StreamEndToken(Token):
47
+ id = "<stream end>"
48
+
49
+
50
+ class BlockSequenceStartToken(Token):
51
+ id = "<block sequence start>"
52
+
53
+
54
+ class BlockMappingStartToken(Token):
55
+ id = "<block mapping start>"
56
+
57
+
58
+ class BlockEndToken(Token):
59
+ id = "<block end>"
60
+
61
+
62
+ class FlowSequenceStartToken(Token):
63
+ id = "["
64
+
65
+
66
+ class FlowMappingStartToken(Token):
67
+ id = "{"
68
+
69
+
70
+ class FlowSequenceEndToken(Token):
71
+ id = "]"
72
+
73
+
74
+ class FlowMappingEndToken(Token):
75
+ id = "}"
76
+
77
+
78
+ class KeyToken(Token):
79
+ id = "?"
80
+
81
+
82
+ class ValueToken(Token):
83
+ id = ":"
84
+
85
+
86
+ class BlockEntryToken(Token):
87
+ id = "-"
88
+
89
+
90
+ class FlowEntryToken(Token):
91
+ id = ","
92
+
93
+
94
+ class AliasToken(Token):
95
+ id = "<alias>"
96
+
97
+ def __init__(self, value, start_mark, end_mark):
98
+ self.value = value
99
+ self.start_mark = start_mark
100
+ self.end_mark = end_mark
101
+
102
+
103
+ class AnchorToken(Token):
104
+ id = "<anchor>"
105
+
106
+ def __init__(self, value, start_mark, end_mark):
107
+ self.value = value
108
+ self.start_mark = start_mark
109
+ self.end_mark = end_mark
110
+
111
+
112
+ class TagToken(Token):
113
+ id = "<tag>"
114
+
115
+ def __init__(self, value, start_mark, end_mark):
116
+ self.value = value
117
+ self.start_mark = start_mark
118
+ self.end_mark = end_mark
119
+
120
+
121
+ class ScalarToken(Token):
122
+ id = "<scalar>"
123
+
124
+ def __init__(self, value, plain, start_mark, end_mark, style=None):
125
+ self.value = value
126
+ self.plain = plain
127
+ self.start_mark = start_mark
128
+ self.end_mark = end_mark
129
+ self.style = style
@@ -0,0 +1,450 @@
1
+ import os
2
+ from os import path
3
+ from outerbounds._vendor import click
4
+ import requests
5
+ import time
6
+ import random
7
+
8
+ from ..utils import metaflowconfig
9
+
10
+ APP_READY_POLL_TIMEOUT_SECONDS = 300
11
+ # Even after our backend validates that the app routes are ready, it takes a few seconds for
12
+ # the app to be accessible via the browser. Till we hunt down this delay, add an extra buffer.
13
+ APP_READY_EXTRA_BUFFER_SECONDS = 30
14
+
15
+
16
+ @click.group()
17
+ def cli(**kwargs):
18
+ pass
19
+
20
+
21
+ @click.group(help="Manage apps")
22
+ def app(**kwargs):
23
+ pass
24
+
25
+
26
+ @app.command(help="Start an app using a port and a name")
27
+ @click.option(
28
+ "-d",
29
+ "--config-dir",
30
+ default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
31
+ help="Path to Metaflow configuration directory",
32
+ show_default=True,
33
+ )
34
+ @click.option(
35
+ "-p",
36
+ "--profile",
37
+ default=os.environ.get("METAFLOW_PROFILE", ""),
38
+ help="The named metaflow profile in which your workstation exists",
39
+ )
40
+ @click.option(
41
+ "--port",
42
+ required=True,
43
+ help="Port number where you want to start your app",
44
+ type=int,
45
+ )
46
+ @click.option(
47
+ "--name",
48
+ required=True,
49
+ help="Name of your app",
50
+ type=str,
51
+ )
52
+ def start(config_dir=None, profile=None, port=-1, name=""):
53
+ if len(name) == 0 or len(name) >= 20:
54
+ click.secho(
55
+ "App name should not be more than 20 characters long.",
56
+ fg="red",
57
+ err=True,
58
+ )
59
+ return
60
+ elif not name.isalnum() or not name.islower():
61
+ click.secho(
62
+ "App name can only contain lowercase alphanumeric characters.",
63
+ fg="red",
64
+ err=True,
65
+ )
66
+ return
67
+
68
+ if "WORKSTATION_ID" not in os.environ:
69
+ click.secho(
70
+ "All outerbounds app commands can only be run from a workstation.",
71
+ fg="red",
72
+ err=True,
73
+ )
74
+ return
75
+
76
+ workstation_id = os.environ["WORKSTATION_ID"]
77
+
78
+ try:
79
+ try:
80
+ metaflow_token = metaflowconfig.get_metaflow_token_from_config(
81
+ config_dir, profile
82
+ )
83
+ api_url = metaflowconfig.get_sanitized_url_from_config(
84
+ config_dir, profile, "OBP_API_SERVER"
85
+ )
86
+
87
+ workstations_response = requests.get(
88
+ f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
89
+ )
90
+ workstations_response.raise_for_status()
91
+ except:
92
+ click.secho("Failed to list workstations!", fg="red", err=True)
93
+ return
94
+
95
+ workstations_json = workstations_response.json()["workstations"]
96
+ for workstation in workstations_json:
97
+ if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
98
+ if "named_ports" in workstation["spec"]:
99
+ try:
100
+ ensure_app_start_request_is_valid(
101
+ workstation["spec"]["named_ports"], port, name
102
+ )
103
+ except ValueError as e:
104
+ click.secho(str(e), fg="red", err=True)
105
+ return
106
+
107
+ for named_port in workstation["spec"]["named_ports"]:
108
+ if int(named_port["port"]) == port:
109
+ if named_port["enabled"] and named_port["name"] == name:
110
+ click.secho(
111
+ f"App {name} already running on port {port}!",
112
+ fg="green",
113
+ err=True,
114
+ )
115
+ click.secho(
116
+ f"App URL: {api_url.replace('api', 'ui')}/apps/{workstation_id}/{name}/",
117
+ fg="green",
118
+ err=True,
119
+ )
120
+ return
121
+ else:
122
+ try:
123
+ response = requests.put(
124
+ f"{api_url}/v1/workstations/update/{workstation_id}/namedports",
125
+ headers={"x-api-key": metaflow_token},
126
+ json={
127
+ "port": port,
128
+ "name": name,
129
+ "enabled": True,
130
+ },
131
+ )
132
+
133
+ response.raise_for_status()
134
+ poll_success = wait_for_app_port_to_be_accessible(
135
+ api_url,
136
+ metaflow_token,
137
+ workstation_id,
138
+ name,
139
+ APP_READY_POLL_TIMEOUT_SECONDS,
140
+ )
141
+ if poll_success:
142
+ click.secho(
143
+ f"App {name} started on port {port}!",
144
+ fg="green",
145
+ err=True,
146
+ )
147
+ click.secho(
148
+ f"App URL: {api_url.replace('api', 'ui')}/apps/{os.environ['WORKSTATION_ID']}/{name}/",
149
+ fg="green",
150
+ err=True,
151
+ )
152
+ else:
153
+ click.secho(
154
+ f"The app could not be deployed in {APP_READY_POLL_TIMEOUT_SECONDS / 60} minutes. Please try again later.",
155
+ fg="red",
156
+ err=True,
157
+ )
158
+ return
159
+ except Exception:
160
+ click.secho(
161
+ f"Failed to start app {name} on port {port}!",
162
+ fg="red",
163
+ err=True,
164
+ )
165
+ return
166
+ except Exception as e:
167
+ click.secho(f"Failed to start app {name} on port {port}!", fg="red", err=True)
168
+
169
+
170
+ @app.command(help="Stop an app using its port number")
171
+ @click.option(
172
+ "-d",
173
+ "--config-dir",
174
+ default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
175
+ help="Path to Metaflow configuration directory",
176
+ show_default=True,
177
+ )
178
+ @click.option(
179
+ "-p",
180
+ "--profile",
181
+ default=os.environ.get("METAFLOW_PROFILE", ""),
182
+ help="The named metaflow profile in which your workstation exists",
183
+ )
184
+ @click.option(
185
+ "--port",
186
+ required=False,
187
+ default=-1,
188
+ help="Port number where you want to start your app.",
189
+ type=int,
190
+ )
191
+ @click.option(
192
+ "--name",
193
+ required=False,
194
+ help="Name of your app",
195
+ default="",
196
+ type=str,
197
+ )
198
+ def stop(config_dir=None, profile=None, port=-1, name=""):
199
+ if port == -1 and not name:
200
+ click.secho(
201
+ "Please provide either a port number or a name to stop the app.",
202
+ fg="red",
203
+ err=True,
204
+ )
205
+ return
206
+ elif port > 0 and name:
207
+ click.secho(
208
+ "Please provide either a port number or a name to stop the app, not both.",
209
+ fg="red",
210
+ err=True,
211
+ )
212
+ return
213
+
214
+ if "WORKSTATION_ID" not in os.environ:
215
+ click.secho(
216
+ "All outerbounds app commands can only be run from a workstation.",
217
+ fg="red",
218
+ err=True,
219
+ )
220
+
221
+ return
222
+
223
+ try:
224
+ try:
225
+ metaflow_token = metaflowconfig.get_metaflow_token_from_config(
226
+ config_dir, profile
227
+ )
228
+ api_url = metaflowconfig.get_sanitized_url_from_config(
229
+ config_dir, profile, "OBP_API_SERVER"
230
+ )
231
+
232
+ workstations_response = requests.get(
233
+ f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
234
+ )
235
+ workstations_response.raise_for_status()
236
+ except:
237
+ click.secho("Failed to list workstations!", fg="red", err=True)
238
+ return
239
+
240
+ app_found = False
241
+ workstations_json = workstations_response.json()["workstations"]
242
+ for workstation in workstations_json:
243
+ if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
244
+ if "named_ports" in workstation["spec"]:
245
+ for named_port in workstation["spec"]["named_ports"]:
246
+ if (
247
+ int(named_port["port"]) == port
248
+ or named_port["name"] == name
249
+ ):
250
+ app_found = True
251
+ if named_port["enabled"]:
252
+ try:
253
+ response = requests.put(
254
+ f"{api_url}/v1/workstations/update/{os.environ['WORKSTATION_ID']}/namedports",
255
+ headers={"x-api-key": metaflow_token},
256
+ json={
257
+ "port": named_port["port"],
258
+ "name": named_port["name"],
259
+ "enabled": False,
260
+ },
261
+ )
262
+ response.raise_for_status()
263
+ click.secho(
264
+ f"App {named_port['name']} stopped on port {named_port['port']}!",
265
+ fg="green",
266
+ err=True,
267
+ )
268
+ except Exception as e:
269
+ click.secho(
270
+ f"Failed to stop app {named_port['name']} on port {named_port['port']}!",
271
+ fg="red",
272
+ err=True,
273
+ )
274
+ return
275
+
276
+ if app_found:
277
+ already_stopped_message = (
278
+ f"No deployed app named {name} found."
279
+ if name
280
+ else f"There is no app deployed on port {port}"
281
+ )
282
+ click.secho(
283
+ already_stopped_message,
284
+ fg="green",
285
+ err=True,
286
+ )
287
+ return
288
+
289
+ err_message = (
290
+ (f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}")
291
+ if port != -1
292
+ else f"App {name} not found on workstation {os.environ['WORKSTATION_ID']}"
293
+ )
294
+
295
+ click.secho(
296
+ err_message,
297
+ fg="red",
298
+ err=True,
299
+ )
300
+ except Exception as e:
301
+ click.secho(f"Failed to stop app on port {port}!", fg="red", err=True)
302
+
303
+
304
+ @app.command(help="List all apps on the workstation")
305
+ @click.option(
306
+ "-d",
307
+ "--config-dir",
308
+ default=path.expanduser(os.environ.get("METAFLOW_HOME", "~/.metaflowconfig")),
309
+ help="Path to Metaflow configuration directory",
310
+ show_default=True,
311
+ )
312
+ @click.option(
313
+ "-p",
314
+ "--profile",
315
+ default=os.environ.get("METAFLOW_PROFILE", ""),
316
+ help="The named metaflow profile in which your workstation exists",
317
+ )
318
+ def list(config_dir=None, profile=None):
319
+ if "WORKSTATION_ID" not in os.environ:
320
+ click.secho(
321
+ "All outerbounds app commands can only be run from a workstation.",
322
+ fg="red",
323
+ err=True,
324
+ )
325
+
326
+ return
327
+
328
+ try:
329
+ try:
330
+ metaflow_token = metaflowconfig.get_metaflow_token_from_config(
331
+ config_dir, profile
332
+ )
333
+ api_url = metaflowconfig.get_sanitized_url_from_config(
334
+ config_dir, profile, "OBP_API_SERVER"
335
+ )
336
+
337
+ workstations_response = requests.get(
338
+ f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
339
+ )
340
+ workstations_response.raise_for_status()
341
+ except:
342
+ click.secho("Failed to list workstations!", fg="red", err=True)
343
+ return
344
+
345
+ workstations_json = workstations_response.json()["workstations"]
346
+ for workstation in workstations_json:
347
+ if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
348
+ if "named_ports" in workstation["spec"]:
349
+ for named_port in workstation["spec"]["named_ports"]:
350
+ if named_port["enabled"]:
351
+ click.secho(
352
+ f"App Name: {named_port['name']}", fg="green", err=True
353
+ )
354
+ click.secho(
355
+ f"App Port on Workstation: {named_port['port']}",
356
+ fg="green",
357
+ err=True,
358
+ )
359
+ click.secho(f"App Status: Deployed", fg="green", err=True)
360
+ click.secho(
361
+ f"App URL: {api_url.replace('api', 'ui')}/apps/{os.environ['WORKSTATION_ID']}/{named_port['name']}/",
362
+ fg="green",
363
+ err=True,
364
+ )
365
+ else:
366
+ click.secho(
367
+ f"App Port on Workstation: {named_port['port']}",
368
+ fg="yellow",
369
+ err=True,
370
+ )
371
+ click.secho(
372
+ f"App Status: Not Deployed", fg="yellow", err=True
373
+ )
374
+
375
+ click.echo("\n", err=True)
376
+ except Exception as e:
377
+ click.secho(f"Failed to list apps!", fg="red", err=True)
378
+
379
+
380
+ def ensure_app_start_request_is_valid(existing_named_ports, port: int, name: str):
381
+ existing_apps_by_port = {np["port"]: np for np in existing_named_ports}
382
+
383
+ if port not in existing_apps_by_port:
384
+ raise ValueError(f"Port {port} not found on workstation")
385
+
386
+ for existing_named_port in existing_named_ports:
387
+ if (
388
+ name == existing_named_port["name"]
389
+ and existing_named_port["port"] != port
390
+ and existing_named_port["enabled"]
391
+ ):
392
+ raise ValueError(
393
+ f"App with name '{name}' is already deployed on port {existing_named_port['port']}"
394
+ )
395
+
396
+
397
+ def wait_for_app_port_to_be_accessible(
398
+ api_url, metaflow_token, workstation_id, app_name, poll_timeout_seconds
399
+ ) -> bool:
400
+ num_retries_per_request = 3
401
+ start_time = time.time()
402
+ retry_delay = 1.0
403
+ poll_interval = 10
404
+ wait_message = f"App {app_name} is currently being deployed..."
405
+ while time.time() - start_time < poll_timeout_seconds:
406
+ for _ in range(num_retries_per_request):
407
+ try:
408
+ workstations_response = requests.get(
409
+ f"{api_url}/v1/workstations", headers={"x-api-key": metaflow_token}
410
+ )
411
+ workstations_response.raise_for_status()
412
+ if is_app_ready(workstations_response.json(), workstation_id, app_name):
413
+ click.secho(
414
+ wait_message,
415
+ fg="yellow",
416
+ err=True,
417
+ )
418
+ time.sleep(APP_READY_EXTRA_BUFFER_SECONDS)
419
+ return True
420
+ else:
421
+ click.secho(
422
+ wait_message,
423
+ fg="yellow",
424
+ err=True,
425
+ )
426
+ time.sleep(poll_interval)
427
+ except (
428
+ requests.exceptions.ConnectionError,
429
+ requests.exceptions.ReadTimeout,
430
+ ):
431
+ time.sleep(retry_delay)
432
+ retry_delay *= 2 # Double the delay for the next attempt
433
+ retry_delay += random.uniform(0, 1) # Add jitter
434
+ retry_delay = min(retry_delay, 10)
435
+ return False
436
+
437
+
438
+ def is_app_ready(response_json: dict, workstation_id: str, app_name: str) -> bool:
439
+ """Checks if the app is ready in the given workstation's response."""
440
+ workstations = response_json.get("workstations", [])
441
+ for workstation in workstations:
442
+ if workstation.get("instance_id") == workstation_id:
443
+ hosted_apps = workstation.get("status", {}).get("hosted_apps", [])
444
+ for hosted_app in hosted_apps:
445
+ if hosted_app.get("name") == app_name:
446
+ return bool(hosted_app.get("ready"))
447
+ return False
448
+
449
+
450
+ cli.add_command(app, name="app")