outerbounds 0.3.92__py3-none-any.whl → 0.3.94__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.
@@ -17,7 +17,7 @@ def cli(**kwargs):
17
17
  pass
18
18
 
19
19
 
20
- @click.group(help="Manage perimeters")
20
+ @click.group(help="Manage apps")
21
21
  def app(**kwargs):
22
22
  pass
23
23
 
@@ -56,6 +56,21 @@ def app(**kwargs):
56
56
  type=click.Choice(["json", ""]),
57
57
  )
58
58
  def start(config_dir=None, profile=None, port=-1, name="", output=""):
59
+ if len(name) == 0 or len(name) >= 20:
60
+ click.secho(
61
+ "App name should not be more than 20 characters long.",
62
+ fg="red",
63
+ err=True,
64
+ )
65
+ return
66
+ elif not name.isalnum() or not name.islower():
67
+ click.secho(
68
+ "App name can only contain lowercase alphanumeric characters.",
69
+ fg="red",
70
+ err=True,
71
+ )
72
+ return
73
+
59
74
  start_app_response = OuterboundsCommandResponse()
60
75
 
61
76
  validate_workstation_step = CommandStatus(
@@ -70,10 +85,10 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
70
85
  "List of workstations fetched.",
71
86
  )
72
87
 
73
- validate_port_exists = CommandStatus(
74
- "ValidatePortExists",
88
+ validate_request = CommandStatus(
89
+ "ValidateRequest",
75
90
  OuterboundsCommandStatus.OK,
76
- "Port exists on workstation",
91
+ "Start app request is valid.",
77
92
  )
78
93
 
79
94
  start_app_step = CommandStatus(
@@ -127,12 +142,36 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
127
142
  for workstation in workstations_json:
128
143
  if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
129
144
  if "named_ports" in workstation["spec"]:
145
+ try:
146
+ ensure_app_start_request_is_valid(
147
+ workstation["spec"]["named_ports"], port, name
148
+ )
149
+ except ValueError as e:
150
+ click.secho(str(e), fg="red", err=True)
151
+ validate_request.update(
152
+ OuterboundsCommandStatus.FAIL,
153
+ str(e),
154
+ "",
155
+ )
156
+ start_app_response.add_step(validate_request)
157
+ if output == "json":
158
+ click.echo(
159
+ json.dumps(start_app_response.as_dict(), indent=4)
160
+ )
161
+ return
162
+
163
+ start_app_response.add_step(validate_request)
164
+
130
165
  for named_port in workstation["spec"]["named_ports"]:
131
166
  if int(named_port["port"]) == port:
132
- start_app_response.add_step(validate_port_exists)
133
- if named_port["enabled"] and named_port["name"] == name:
167
+ if named_port["enabled"]:
168
+ already_running_message = (
169
+ f"App {name} already running on port {port}!"
170
+ if named_port["name"] == name
171
+ else f"App {named_port['name']} already running on port {port} is now renamed to {name}!"
172
+ )
134
173
  click.secho(
135
- f"App {name} started on port {port}!",
174
+ already_running_message,
136
175
  fg="green",
137
176
  err=True,
138
177
  )
@@ -167,7 +206,12 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
167
206
  fg="green",
168
207
  err=True,
169
208
  )
170
- except:
209
+ click.secho(
210
+ f"App URL: {api_url.replace('api', 'ui')}/apps/{os.environ['WORKSTATION_ID']}/{name}/",
211
+ fg="green",
212
+ err=True,
213
+ )
214
+ except Exception:
171
215
  click.secho(
172
216
  f"Failed to start app {name} on port {port}!",
173
217
  fg="red",
@@ -187,20 +231,6 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
187
231
  )
188
232
  )
189
233
  return
190
-
191
- click.secho(
192
- f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}",
193
- fg="red",
194
- err=True,
195
- )
196
- validate_port_exists.update(
197
- OuterboundsCommandStatus.FAIL,
198
- f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}",
199
- "",
200
- )
201
- start_app_response.add_step(validate_port_exists)
202
- if output == "json":
203
- click.echo(json.dumps(start_app_response.as_dict(), indent=4))
204
234
  except Exception as e:
205
235
  click.secho(f"Failed to start app {name} on port {port}!", fg="red", err=True)
206
236
  start_app_step.update(
@@ -229,10 +259,18 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
229
259
  )
230
260
  @click.option(
231
261
  "--port",
232
- required=True,
262
+ required=False,
263
+ default=-1,
233
264
  help="Port number where you want to start your app.",
234
265
  type=int,
235
266
  )
267
+ @click.option(
268
+ "--name",
269
+ required=False,
270
+ help="Name of your app",
271
+ default="",
272
+ type=str,
273
+ )
236
274
  @click.option(
237
275
  "-o",
238
276
  "--output",
@@ -240,7 +278,22 @@ def start(config_dir=None, profile=None, port=-1, name="", output=""):
240
278
  help="Show output in the specified format.",
241
279
  type=click.Choice(["json", ""]),
242
280
  )
243
- def stop(config_dir=None, profile=None, port=-1, output=""):
281
+ def stop(config_dir=None, profile=None, port=-1, name="", output=""):
282
+ if port == -1 and not name:
283
+ click.secho(
284
+ "Please provide either a port number or a name to stop the app.",
285
+ fg="red",
286
+ err=True,
287
+ )
288
+ return
289
+ elif port > 0 and name:
290
+ click.secho(
291
+ "Please provide either a port number or a name to stop the app, not both.",
292
+ fg="red",
293
+ err=True,
294
+ )
295
+ return
296
+
244
297
  stop_app_response = OuterboundsCommandResponse()
245
298
 
246
299
  validate_workstation_step = CommandStatus(
@@ -313,11 +366,21 @@ def stop(config_dir=None, profile=None, port=-1, output=""):
313
366
  if workstation["instance_id"] == os.environ["WORKSTATION_ID"]:
314
367
  if "named_ports" in workstation["spec"]:
315
368
  for named_port in workstation["spec"]["named_ports"]:
316
- if int(named_port["port"]) == port:
369
+ if (
370
+ int(named_port["port"]) == port
371
+ or named_port["name"] == name
372
+ ):
317
373
  stop_app_response.add_step(validate_port_exists)
318
374
  if not named_port["enabled"]:
375
+ already_stopped_message = (
376
+ f"No deployed app named {named_port['name']} found."
377
+ if name
378
+ else f"There is no app deployed on port {port}"
379
+ )
319
380
  click.secho(
320
- f"App stopped on port {port}!", fg="green", err=True
381
+ already_stopped_message,
382
+ fg="green",
383
+ err=True,
321
384
  )
322
385
  stop_app_response.add_step(stop_app_step)
323
386
  if output == "json":
@@ -333,26 +396,26 @@ def stop(config_dir=None, profile=None, port=-1, output=""):
333
396
  f"{api_url}/v1/workstations/update/{os.environ['WORKSTATION_ID']}/namedports",
334
397
  headers={"x-api-key": metaflow_token},
335
398
  json={
336
- "port": port,
399
+ "port": named_port["port"],
337
400
  "name": named_port["name"],
338
401
  "enabled": False,
339
402
  },
340
403
  )
341
404
  response.raise_for_status()
342
405
  click.secho(
343
- f"App stopped on port {port}!",
406
+ f"App {named_port['name']} stopped on port {named_port['port']}!",
344
407
  fg="green",
345
408
  err=True,
346
409
  )
347
- except:
410
+ except Exception as e:
348
411
  click.secho(
349
- f"Failed to stop app on port {port}!",
412
+ f"Failed to stop app {named_port['name']} on port {named_port['port']}!",
350
413
  fg="red",
351
414
  err=True,
352
415
  )
353
416
  stop_app_step.update(
354
417
  OuterboundsCommandStatus.FAIL,
355
- f"Failed to stop app on port {port}!",
418
+ f"Failed to stop app {named_port['name']} on port {named_port['port']}!",
356
419
  "",
357
420
  )
358
421
 
@@ -365,14 +428,21 @@ def stop(config_dir=None, profile=None, port=-1, output=""):
365
428
  )
366
429
  return
367
430
 
431
+ err_message = (
432
+ (f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}")
433
+ if port != -1
434
+ else f"App {name} not found on workstation {os.environ['WORKSTATION_ID']}"
435
+ )
436
+
368
437
  click.secho(
369
- f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}",
438
+ err_message,
370
439
  fg="red",
371
440
  err=True,
372
441
  )
442
+
373
443
  validate_port_exists.update(
374
444
  OuterboundsCommandStatus.FAIL,
375
- f"Port {port} not found on workstation {os.environ['WORKSTATION_ID']}",
445
+ err_message,
376
446
  "",
377
447
  )
378
448
  stop_app_response.add_step(validate_port_exists)
@@ -502,4 +572,21 @@ def list(config_dir=None, profile=None, output=""):
502
572
  click.echo(json.dumps(list_app_response.as_dict(), indent=4))
503
573
 
504
574
 
575
+ def ensure_app_start_request_is_valid(existing_named_ports, port: int, name: str):
576
+ existing_apps_by_port = {np["port"]: np for np in existing_named_ports}
577
+
578
+ if port not in existing_apps_by_port:
579
+ raise ValueError(f"Port {port} not found on workstation")
580
+
581
+ for existing_named_port in existing_named_ports:
582
+ if (
583
+ name == existing_named_port["name"]
584
+ and existing_named_port["port"] != port
585
+ and existing_named_port["enabled"]
586
+ ):
587
+ raise ValueError(
588
+ f"App with name '{name}' is already deployed on port {existing_named_port['port']}"
589
+ )
590
+
591
+
505
592
  cli.add_command(app, name="app")
@@ -1,8 +1,5 @@
1
1
  from outerbounds._vendor import click
2
- from . import local_setup_cli
3
- from . import workstations_cli
4
- from . import perimeters_cli
5
- from . import apps_cli
2
+ from . import local_setup_cli, workstations_cli, perimeters_cli, apps_cli, tutorials_cli
6
3
 
7
4
 
8
5
  @click.command(
@@ -12,6 +9,7 @@ from . import apps_cli
12
9
  workstations_cli.cli,
13
10
  perimeters_cli.cli,
14
11
  apps_cli.cli,
12
+ tutorials_cli.cli,
15
13
  ],
16
14
  )
17
15
  def cli(**kwargs):
@@ -0,0 +1,111 @@
1
+ import os
2
+ from outerbounds._vendor import click
3
+ import requests
4
+
5
+ import tarfile
6
+ import hashlib
7
+ import tempfile
8
+
9
+
10
+ @click.group()
11
+ def cli(**kwargs):
12
+ pass
13
+
14
+
15
+ @click.group(help="Manage tutorials curated by Outerbounds.", hidden=True)
16
+ def tutorials(**kwargs):
17
+ pass
18
+
19
+
20
+ @tutorials.command(help="Pull Outerbounds tutorials.")
21
+ @click.option(
22
+ "--url",
23
+ required=True,
24
+ help="URL to pull the tutorials from.",
25
+ type=str,
26
+ )
27
+ @click.option(
28
+ "--destination-dir",
29
+ help="Show output in the specified format.",
30
+ type=str,
31
+ required=True,
32
+ )
33
+ @click.option(
34
+ "--force-overwrite",
35
+ is_flag=True,
36
+ help="Overwrite all existing files across all tutorials.",
37
+ type=bool,
38
+ required=False,
39
+ default=False,
40
+ )
41
+ def pull(url="", destination_dir="", force_overwrite=False):
42
+ try:
43
+ secure_download_and_extract(
44
+ url, destination_dir, force_overwrite=force_overwrite
45
+ )
46
+ click.secho("Tutorials pulled successfully.", fg="green", err=True)
47
+ except Exception as e:
48
+ print(e)
49
+ click.secho(f"Failed to pull tutorials: {e}", fg="red", err=True)
50
+
51
+
52
+ def secure_download_and_extract(
53
+ url, dest_dir, expected_hash=None, force_overwrite=False
54
+ ):
55
+ """
56
+ Download a tar.gz file from a URL, verify its integrity, and extract its contents.
57
+
58
+ :param url: URL of the tar.gz file to download
59
+ :param dest_dir: Destination directory to extract the contents
60
+ :param expected_hash: Expected SHA256 hash of the file (optional)
61
+ """
62
+
63
+ with tempfile.TemporaryDirectory() as temp_dir:
64
+ temp_file = os.path.join(
65
+ temp_dir, hashlib.md5(url.encode()).hexdigest() + ".tar.gz"
66
+ )
67
+
68
+ # Download the file
69
+ try:
70
+ response = requests.get(url, stream=True, verify=True)
71
+ response.raise_for_status()
72
+
73
+ with open(temp_file, "wb") as f:
74
+ for chunk in response.iter_content(chunk_size=8192):
75
+ f.write(chunk)
76
+ except requests.exceptions.RequestException as e:
77
+ raise Exception(f"Failed to download file: {e}")
78
+
79
+ if expected_hash:
80
+ with open(temp_file, "rb") as f:
81
+ file_hash = hashlib.sha256(f.read()).hexdigest()
82
+ if file_hash != expected_hash:
83
+ raise Exception("File integrity check failed")
84
+
85
+ os.makedirs(dest_dir, exist_ok=True)
86
+
87
+ try:
88
+ with tarfile.open(temp_file, "r:gz") as tar:
89
+ # Keep track of new journeys to extract.
90
+ to_extract = []
91
+ members = tar.getmembers()
92
+ for member in members:
93
+ member_path = os.path.join(dest_dir, member.name)
94
+ # Check for any files trying to write outside the destination
95
+ if not os.path.abspath(member_path).startswith(
96
+ os.path.abspath(dest_dir)
97
+ ):
98
+ raise Exception("Attempted path traversal in tar file")
99
+ if not os.path.exists(member_path):
100
+ # The user might have modified the existing files, leave them untouched.
101
+ to_extract.append(member)
102
+
103
+ if force_overwrite:
104
+ tar.extractall(path=dest_dir)
105
+ else:
106
+ tar.extractall(path=dest_dir, members=to_extract)
107
+ except tarfile.TarError as e:
108
+ raise Exception(f"Failed to extract tar file: {e}")
109
+
110
+
111
+ cli.add_command(tutorials, name="tutorials")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: outerbounds
3
- Version: 0.3.92
3
+ Version: 0.3.94
4
4
  Summary: More Data Science, Less Administration
5
5
  License: Proprietary
6
6
  Keywords: data science,machine learning,MLOps
@@ -41,10 +41,11 @@ outerbounds/_vendor/yaml/serializer.py,sha256=8wFZRy9SsQSktF_f9OOroroqsh4qVUe53r
41
41
  outerbounds/_vendor/yaml/tokens.py,sha256=JBSu38wihGr4l73JwbfMA7Ks1-X84g8-NskTz7KwPmA,2578
42
42
  outerbounds/cli_main.py,sha256=e9UMnPysmc7gbrimq2I4KfltggyU7pw59Cn9aEguVcU,74
43
43
  outerbounds/command_groups/__init__.py,sha256=QPWtj5wDRTINDxVUL7XPqG3HoxHNvYOg08EnuSZB2Hc,21
44
- outerbounds/command_groups/apps_cli.py,sha256=XdGRitYWyKGDZXlGSbcP7U6F3DclqzgBG7ZGuZK8tzY,19361
45
- outerbounds/command_groups/cli.py,sha256=3NyxnczfANtxKh-KnJHGWAEC5akdXux_hylfGXTs2A0,362
44
+ outerbounds/command_groups/apps_cli.py,sha256=sgRPYMdL_c7UUcFqy1pTQno0Q2aT1LHT7POB_VUuKoE,22612
45
+ outerbounds/command_groups/cli.py,sha256=q0hdJO4biD3iEOdyJcxnRkeleA8AKAhx842kQ49I6kk,365
46
46
  outerbounds/command_groups/local_setup_cli.py,sha256=tuuqJRXQ_guEwOuQSIf9wkUU0yg8yAs31myGViAK15s,36364
47
47
  outerbounds/command_groups/perimeters_cli.py,sha256=mrJfFIRYFOjuiz-9h4OKg2JT8Utmbs72z6wvPzDss3s,18685
48
+ outerbounds/command_groups/tutorials_cli.py,sha256=UInFyiMqtscHFfi8YQwiY_6Sdw9quJOtRu5OukEBccw,3522
48
49
  outerbounds/command_groups/workstations_cli.py,sha256=V5Jbj1cVb4IRllI7fOgNgL6OekRpuFDv6CEhDb4xC6w,22016
49
50
  outerbounds/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
51
  outerbounds/utils/kubeconfig.py,sha256=yvcyRXGR4AhQuqUDqmbGxEOHw5ixMFV0AZIDg1LI_Qo,7981
@@ -52,7 +53,7 @@ outerbounds/utils/metaflowconfig.py,sha256=l2vJbgPkLISU-XPGZFaC8ZKmYFyJemlD6bwB-
52
53
  outerbounds/utils/schema.py,sha256=lMUr9kNgn9wy-sO_t_Tlxmbt63yLeN4b0xQXbDUDj4A,2331
53
54
  outerbounds/utils/utils.py,sha256=4Z8cszNob_8kDYCLNTrP-wWads_S_MdL3Uj3ju4mEsk,501
54
55
  outerbounds/vendor.py,sha256=gRLRJNXtZBeUpPEog0LOeIsl6GosaFFbCxUvR4bW6IQ,5093
55
- outerbounds-0.3.92.dist-info/entry_points.txt,sha256=7ye0281PKlvqxu15rjw60zKg2pMsXI49_A8BmGqIqBw,47
56
- outerbounds-0.3.92.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
57
- outerbounds-0.3.92.dist-info/METADATA,sha256=enOUPH2DJjaqvzpywbTNnD1aosIzZq8JlsBne9w84o8,1632
58
- outerbounds-0.3.92.dist-info/RECORD,,
56
+ outerbounds-0.3.94.dist-info/entry_points.txt,sha256=7ye0281PKlvqxu15rjw60zKg2pMsXI49_A8BmGqIqBw,47
57
+ outerbounds-0.3.94.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
58
+ outerbounds-0.3.94.dist-info/METADATA,sha256=ipH_wW6z5ITcDg1vBorq7clnY8ajMt-WMBgOcfNbxyA,1632
59
+ outerbounds-0.3.94.dist-info/RECORD,,