modAL 1.6.2.dev8__py3-none-any.whl → 1.6.2.dev10__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.
modal/_workspace.py CHANGED
@@ -8,7 +8,8 @@ from typing import Optional
8
8
 
9
9
  from google.protobuf.empty_pb2 import Empty
10
10
 
11
- from modal.exception import DeprecationError, InvalidError
11
+ from modal.exception import DeprecationError, InvalidError, NotFoundError
12
+ from modal.types import ServiceUserInfo
12
13
  from modal_proto import api_pb2
13
14
 
14
15
  from ._load_context import LoadContext
@@ -129,6 +130,15 @@ class _Workspace(_Object, type_prefix="ac"):
129
130
  """Namespace for Workspace settings APIs."""
130
131
  return _WorkspaceSettingsManager(self)
131
132
 
133
+ @property
134
+ def service_users(self) -> "_WorkspaceServiceUserManager":
135
+ """Namespace for managing Service Users in a Workspace.
136
+
137
+ See [the guide](https://modal.com/docs/guide/service-users) for more information about Service
138
+ Users.
139
+ """
140
+ return _WorkspaceServiceUserManager(self)
141
+
132
142
 
133
143
  class _WorkspaceProxyTokenManager:
134
144
  """mdmd:namespace"""
@@ -556,3 +566,172 @@ class _WorkspaceSettingsManager:
556
566
  await self._set_default_environment(value)
557
567
  case _:
558
568
  raise ValueError(f"Unknown setting {name!r}. Valid settings: {', '.join(self._valid_settings())}")
569
+
570
+
571
+ class _WorkspaceServiceUserManager:
572
+ """mdmd:namespace"""
573
+
574
+ def __init__(self, workspace: _Workspace):
575
+ """mdmd:hidden"""
576
+ self._workspace = workspace
577
+
578
+ async def create(self, name: str, *, expires_in: int | None) -> TokenData:
579
+ """Create a new Service User in the current workspace.
580
+
581
+ To call this function, you are required to have Manager permissions in the Workspace.
582
+
583
+ Args:
584
+ name: The name of the new Service User. Must consistent only of alphanumeric, '-', or '_'
585
+ characters.
586
+ expires_in: An expiration time, in seconds, after which the token will stop working. If
587
+ `None`, the token will never expire.
588
+
589
+ Returns:
590
+ A `TokenData` dataclass containing the credentials for the Service User. Note that this
591
+ is the only time you will be able to view these credentials.
592
+
593
+ Examples:
594
+
595
+ ```python notest
596
+ ws = modal.Workspace.from_context()
597
+
598
+ # Creates a Service User named "test-service-user", whose credentials expire after one year.
599
+ service_user_token_data = ws.service_users.create(
600
+ "test-service-user",
601
+ expires_in=datetime.timedelta(days=365).total_seconds(),
602
+ )
603
+ ```
604
+ """
605
+ if not self._workspace._is_hydrated:
606
+ await self._workspace.hydrate()
607
+
608
+ if expires_in is None:
609
+ req = api_pb2.ServiceUserCreateRequest(name=name)
610
+ else:
611
+ req = api_pb2.ServiceUserCreateRequest(name=name, expires_in_seconds=expires_in)
612
+
613
+ resp = await self._workspace.client._stub.ServiceUserCreate(req)
614
+
615
+ return TokenData(token_id=resp.token_id, token_secret=resp.token_secret)
616
+
617
+ async def set_role(
618
+ self,
619
+ service_user_name: str,
620
+ environment_name: str,
621
+ *,
622
+ role: str,
623
+ ) -> None:
624
+ """Set the role of a specified Service User within an Environment.
625
+
626
+ To call this function, you are required to have Manager permissions in the Workspace.
627
+
628
+ Args:
629
+ service_user_name: The name of the Service User.
630
+ environment_name: The name of the Environment to which the Service User's new role will
631
+ apply.
632
+ role: The access level to grant this Service User.
633
+
634
+ Examples:
635
+
636
+ ```python notest
637
+ ws = modal.Workspace.from_context()
638
+ # Grants Service User "test-service-user" the Viewer role in Environment "production"
639
+ ws.service_users.set_role("test-service-user", "production", role="viewer")
640
+
641
+ # Revokes access to "production" from Service User "test-service-user"
642
+ ws.service_users.set_role("test-service-user", "production", role="no-access")
643
+ ```
644
+ """
645
+
646
+ if not role:
647
+ raise InvalidError("Invalid argument: `role` must not be empty.")
648
+
649
+ if not self._workspace._is_hydrated:
650
+ await self._workspace.hydrate()
651
+
652
+ from ._environments import _Environment
653
+
654
+ env = _Environment.from_name(environment_name, client=self._workspace.client)
655
+ await env.hydrate()
656
+
657
+ await self._workspace.client._stub.EnvironmentRoleSet(
658
+ api_pb2.EnvironmentRoleSetRequest(
659
+ service_user_name=service_user_name,
660
+ environment_id=env.object_id,
661
+ role_str=role,
662
+ )
663
+ )
664
+
665
+ async def info(self, name: str) -> ServiceUserInfo:
666
+ """Get information about a specific Service User.
667
+
668
+ Args:
669
+ name: The name of the Service User to introspect.
670
+
671
+ Returns:
672
+ A `ServiceUserInfo` dataclass containing information about the Service User.
673
+
674
+ Examples:
675
+
676
+ ```python notest
677
+ ws = modal.Workspace.from_context()
678
+ service_user = ws.service_users.info("test-service-user")
679
+ ```
680
+ """
681
+
682
+ if not self._workspace._is_hydrated:
683
+ await self._workspace.hydrate()
684
+
685
+ resp = await self._workspace.client._stub.ServiceUserInfo(
686
+ api_pb2.ServiceUserInfoRequest(service_user_name=name)
687
+ )
688
+
689
+ return ServiceUserInfo._from_proto(resp.service_user)
690
+
691
+ async def list(self) -> list[ServiceUserInfo]:
692
+ """List all Service Users in the current workspace.
693
+
694
+ Returns:
695
+ A list of `ServiceUserInfo` dataclasses.
696
+
697
+ Examples:
698
+
699
+ ```python notest
700
+ ws = modal.Workspace.from_context()
701
+ for service_user in ws.service_users.list():
702
+ print(service_user.name)
703
+ ```
704
+ """
705
+
706
+ if not self._workspace._is_hydrated:
707
+ await self._workspace.hydrate()
708
+
709
+ resp = await self._workspace.client._stub.ServiceUserList(Empty())
710
+
711
+ return [ServiceUserInfo._from_proto(su) for su in resp.service_users]
712
+
713
+ async def delete(self, name: str, *, allow_missing: bool = False) -> None:
714
+ """Permanently delete a Service User.
715
+
716
+ Args:
717
+ name: The name of the Service User to delete.
718
+ allow_missing: If `True`, do not throw an exception if the Service User does not exist.
719
+
720
+ Examples:
721
+
722
+ ```python notest
723
+ ws = modal.Workspace.from_context()
724
+ ws.service_users.delete("test-service-user")
725
+ ```
726
+ """
727
+
728
+ if not self._workspace._is_hydrated:
729
+ await self._workspace.hydrate()
730
+
731
+ try:
732
+ await self._workspace.client._stub.ServiceUserDelete(
733
+ api_pb2.ServiceUserDeleteRequest(service_user_name=name)
734
+ )
735
+ except NotFoundError:
736
+ if not allow_missing:
737
+ raise
modal/cli/import_refs.py CHANGED
@@ -7,11 +7,14 @@ or the app lookup of `modal deploy some_file.py`.
7
7
  These functions are only called by the Modal CLI, not in tasks.
8
8
  """
9
9
 
10
+ import atexit
10
11
  import dataclasses
11
12
  import importlib
12
13
  import importlib.util
13
14
  import inspect
15
+ import shutil
14
16
  import sys
17
+ import tempfile
15
18
  import typing
16
19
  from collections import defaultdict
17
20
  from dataclasses import dataclass
@@ -60,15 +63,40 @@ def parse_import_ref(object_ref: str, use_module_mode: bool = False, command: st
60
63
 
61
64
  DEFAULT_APP_NAME = "app"
62
65
 
66
+ STDIN_REF = "-"
67
+ STDIN_MODULE_NAME = "modal_stdin_app"
63
68
 
64
- def import_file_or_module(import_ref: ImportRef, base_cmd: str = ""):
69
+
70
+ def _write_stdin_script() -> str:
71
+ if sys.stdin is None or sys.stdin.isatty():
72
+ raise InvalidError("No script was piped on stdin. Usage: `cat my_app.py | modal run -`")
73
+ source = sys.stdin.read()
74
+ # The file must outlive the import: the entrypoint mount is built from it when the App runs.
75
+ tmp_dir = tempfile.mkdtemp(prefix="modal-stdin-")
76
+ atexit.register(shutil.rmtree, tmp_dir, ignore_errors=True)
77
+ script_path = Path(tmp_dir) / f"{STDIN_MODULE_NAME}.py"
78
+ script_path.write_text(source)
79
+ return str(script_path)
80
+
81
+
82
+ def import_file_or_module(import_ref: ImportRef, base_cmd: str = "", allow_stdin: bool = False):
65
83
  if "" not in sys.path:
66
84
  # When running from a CLI like `modal run`
67
85
  # the current working directory isn't added to sys.path
68
86
  # so we add it in order to make module path specification possible
69
87
  sys.path.insert(0, "") # "" means the current working directory
70
88
 
71
- if not import_ref.file_or_module.endswith(".py") or import_ref.use_module_mode:
89
+ from_stdin = import_ref.file_or_module == STDIN_REF
90
+ if from_stdin:
91
+ if not allow_stdin:
92
+ raise InvalidError(f"`{base_cmd}` does not support reading a script from stdin.")
93
+ if import_ref.use_module_mode:
94
+ raise InvalidError("Reading a script from stdin (`-`) can't be combined with the -m flag.")
95
+ script_path = _write_stdin_script()
96
+ else:
97
+ script_path = import_ref.file_or_module
98
+
99
+ if not script_path.endswith(".py") or import_ref.use_module_mode:
72
100
  if not import_ref.use_module_mode:
73
101
  raise InvalidError(
74
102
  "Python module paths must be specified with the -m flag. "
@@ -82,7 +110,7 @@ def import_file_or_module(import_ref: ImportRef, base_cmd: str = ""):
82
110
  else:
83
111
  # when using a script path, that scripts directory should also be on the path as it is
84
112
  # with `python some/script.py`
85
- full_path = Path(import_ref.file_or_module).resolve()
113
+ full_path = Path(script_path).resolve()
86
114
  if "." in full_path.name.removesuffix(".py"):
87
115
  raise InvalidError(
88
116
  f"Invalid Modal source filename: {full_path.name!r}."
@@ -90,10 +118,10 @@ def import_file_or_module(import_ref: ImportRef, base_cmd: str = ""):
90
118
  )
91
119
  sys.path.insert(0, str(full_path.parent))
92
120
 
93
- module_name = inspect.getmodulename(import_ref.file_or_module)
121
+ module_name = inspect.getmodulename(script_path)
94
122
  assert module_name is not None
95
123
  # Import the module - see https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
96
- spec = importlib.util.spec_from_file_location(module_name, import_ref.file_or_module)
124
+ spec = importlib.util.spec_from_file_location(module_name, script_path)
97
125
  assert spec is not None
98
126
  module = importlib.util.module_from_spec(spec)
99
127
  sys.modules[module_name] = module
@@ -103,7 +131,7 @@ def import_file_or_module(import_ref: ImportRef, base_cmd: str = ""):
103
131
  spec.loader.exec_module(module)
104
132
  except Exception as exc:
105
133
  if isinstance(exc, ImportError) and "attempted relative import with no known parent package" in str(exc):
106
- if import_ref.file_or_module and import_ref.command and not import_ref.use_module_mode:
134
+ if import_ref.command and not import_ref.use_module_mode and not from_stdin:
107
135
  file_path = Path(import_ref.file_or_module)
108
136
  suggested_module_path = ".".join(file_path.parts).removesuffix(".py").lstrip(".")
109
137
  suffix = f"::{import_ref.object_path}" if import_ref.object_path else ""
@@ -356,6 +384,7 @@ def import_and_filter(
356
384
  accept_local_entrypoint=True,
357
385
  accept_webhook=False,
358
386
  accept_service_functions=False,
387
+ allow_stdin=False,
359
388
  ) -> tuple[Runnable | None, list[CLICommand]]:
360
389
  """Takes a function ref string and returns a single determined "runnable" to use, and a list of all available
361
390
  runnables.
@@ -371,7 +400,7 @@ def import_and_filter(
371
400
  3. if there is a single method (within a class) that one is used
372
401
  """
373
402
  # all commands:
374
- module = import_file_or_module(import_ref, base_cmd)
403
+ module = import_file_or_module(import_ref, base_cmd, allow_stdin=allow_stdin)
375
404
  cli_commands = list_cli_commands(
376
405
  dict(inspect.getmembers(module)),
377
406
  include_service_functions=accept_service_functions,
modal/cli/run.py CHANGED
@@ -2,7 +2,9 @@
2
2
  import asyncio
3
3
  import functools
4
4
  import inspect
5
+ import os
5
6
  import re
7
+ import stat
6
8
  import sys
7
9
  import time
8
10
  import typing
@@ -25,6 +27,7 @@ from ..runner import DEPLOYMENT_STRATEGY_TYPE, deploy_app, run_app
25
27
  from ..serving import serve_app
26
28
  from ._help import ModalCommand, ModalGroup, _consume_global_options, _global_option_token_length
27
29
  from .import_refs import (
30
+ STDIN_REF,
28
31
  CLICommand,
29
32
  MethodReference,
30
33
  _get_runnable_app,
@@ -399,6 +402,14 @@ def _get_click_command_for_local_entrypoint(app: App, entrypoint: LocalEntrypoin
399
402
  return click.command(cls=ModalCommand)(with_click_options)
400
403
 
401
404
 
405
+ def _stdin_is_piped() -> bool:
406
+ try:
407
+ mode = os.fstat(sys.stdin.fileno()).st_mode
408
+ except (AttributeError, OSError, ValueError):
409
+ return False
410
+ return stat.S_ISFIFO(mode) or stat.S_ISREG(mode)
411
+
412
+
402
413
  def _get_runnable_list(all_usable_commands: list[CLICommand]) -> str:
403
414
  usable_command_lines = []
404
415
  for cmd in all_usable_commands:
@@ -428,6 +439,8 @@ class RunGroup(ModalGroup):
428
439
  elif arg == "--":
429
440
  func_ref_index += 1
430
441
  break
442
+ elif arg == STDIN_REF:
443
+ break
431
444
  elif param := option_params.get(arg.split("=", 1)[0]):
432
445
  func_ref_index += 1 if "=" in arg or param.is_flag else param.nargs + 1
433
446
  elif arg.startswith("-"):
@@ -435,6 +448,14 @@ class RunGroup(ModalGroup):
435
448
  else:
436
449
  break
437
450
 
451
+ missing_func_ref = func_ref_index >= len(args)
452
+ requests_help = any(arg in ctx.help_option_names for arg in args)
453
+ if missing_func_ref and not requests_help and not ctx.resilient_parsing and _stdin_is_piped():
454
+ raise click.UsageError(
455
+ f"Missing FUNC_REF. To read the script from stdin, pass `{STDIN_REF}`: `cat my_app.py | modal run -`",
456
+ ctx,
457
+ )
458
+
438
459
  args[:] = _consume_global_options(ctx, args[:func_ref_index]) + args[func_ref_index:]
439
460
  return click.Group.parse_args(self, ctx, args)
440
461
 
@@ -448,9 +469,23 @@ class RunGroup(ModalGroup):
448
469
  func_ref = cmd_name
449
470
 
450
471
  import_ref = parse_import_ref(func_ref, use_module_mode=ctx.params["m"], command="modal run")
472
+ from_stdin = import_ref.file_or_module == STDIN_REF
473
+ if from_stdin and ctx.params["interactive"]:
474
+ raise ClickException("Interactive mode can't be used when reading a script from stdin.")
451
475
  runnable, all_usable_commands = import_and_filter(
452
- import_ref, base_cmd="modal run", accept_local_entrypoint=True, accept_webhook=False
476
+ import_ref, base_cmd="modal run", accept_local_entrypoint=True, accept_webhook=False, allow_stdin=True
453
477
  )
478
+ if not runnable and from_stdin:
479
+ help_header = (
480
+ "Couldn't determine what to run from the script on stdin. Define a single "
481
+ "`@app.local_entrypoint()`, or save the script to a file and pass `my_app.py::my_function`."
482
+ )
483
+ if all_usable_commands:
484
+ help_footer = "The script has the following functions and local entrypoints:\n"
485
+ help_footer += _get_runnable_list(all_usable_commands)
486
+ else:
487
+ help_footer = "The script has no functions or local entrypoints."
488
+ raise ClickException(f"{help_header}\n\n{help_footer}")
454
489
  if not runnable:
455
490
  help_header = (
456
491
  "Specify a Modal Function or local entrypoint to run. E.g.\n"
@@ -535,6 +570,21 @@ def run(
535
570
  ```
536
571
  modal run -m my_project.my_app
537
572
  ```
573
+
574
+ Pass `-` to read the script from stdin. The script must have a single local
575
+ entrypoint (or a single function):
576
+
577
+ ```
578
+ modal run - <<'EOF'
579
+ import modal
580
+
581
+ app = modal.App()
582
+
583
+ @app.function()
584
+ def hello():
585
+ print("hello from Modal")
586
+ EOF
587
+ ```
538
588
  """
539
589
  ctx.ensure_object(dict)
540
590
  ctx.obj["name"] = name
modal/cli/workspace.py CHANGED
@@ -1,15 +1,21 @@
1
1
  # Copyright Modal Labs 2025
2
2
  import json as json_mod
3
+ from datetime import timezone
3
4
 
4
5
  import click
5
6
  import rich
6
7
  import rich.box
7
8
  import rich.table
9
+ from rich.box import SIMPLE, SIMPLE_HEAD
10
+ from rich.text import Text
8
11
 
9
- from modal._utils.time_utils import timestamp_to_localized_str
12
+ from modal._utils.async_utils import synchronizer
13
+ from modal._utils.time_utils import parse_duration, timestamp_to_localized_str
14
+ from modal._workspace import _Workspace
10
15
  from modal.cli.utils import confirm_or_suggest_yes, display_table, yes_option
11
16
  from modal.output import OutputManager
12
17
  from modal.workspace import Workspace
18
+ from modal_proto import api_pb2
13
19
 
14
20
  from ._help import ModalGroup
15
21
 
@@ -267,3 +273,361 @@ def settings_set(setting: str, value: str):
267
273
  except Exception as e:
268
274
  rich.print(f"[red]✗[/red] {setting}: {e}")
269
275
  raise SystemExit(1)
276
+
277
+
278
+ SERVICE_USERS_HELP_TEXT = """Manage Service Users. Must be workspace manager or owner.
279
+
280
+ Service users are programmatic accounts that allow automated systems to interact with Modal. They're
281
+ ideal for CI/CD pipelines, automated deployments, and other workflows that need to authenticate.
282
+
283
+ Service User credentials are typically made available to a process as environment variables:
284
+
285
+ ```
286
+ export MODAL_TOKEN_ID=your-token-id
287
+ export MODAL_TOKEN_SECRET=your-token-secret
288
+ ```
289
+
290
+ A newly created Service User has no permissions by default. In order to interact with a specific
291
+ Environment, Service Users must be granted explicit permissions.
292
+
293
+ See https://modal.com/docs/guide/service-users for more information.
294
+ """
295
+
296
+ service_users_cli = ModalGroup(name="service-users", help=SERVICE_USERS_HELP_TEXT)
297
+ workspace_cli.add_command(service_users_cli)
298
+
299
+
300
+ @service_users_cli.command("create", no_args_is_help=True)
301
+ @click.argument("name")
302
+ @click.option(
303
+ "--expires-in",
304
+ "expires_in_str",
305
+ default=None,
306
+ help="Expiration time. Accepts integers (interpreted as seconds), as well as values suffixed by "
307
+ "`s`, `m`, `h`, or `d`.",
308
+ )
309
+ @click.option("--json", "json", is_flag=True, default=False, help="Output as JSON.")
310
+ @synchronizer.create_blocking
311
+ async def service_users_create(name: str, expires_in_str: str | None, json: bool):
312
+ """Create a Service User in this workspace.
313
+
314
+ In order to run this command, you must have at least Manager level permissions in the Workspace.
315
+
316
+ By default, Service Users have no permissions. To explicitly grant the Service User permissions
317
+ to interact with a given environment, use [`modal workspace service-users set-role`](https://modal.com/docs/cli/latest/workspace#modal-workspace-service-users-set-role)
318
+ or [`modal environment roles update`](https://modal.com/docs/cli/latest/environment#modal-environment-roles-update).
319
+
320
+ Optionally, you can also specify an expiration window by passing `--expires-in`. After the specified
321
+ amount of time elapses, this Service User will be deleted, and any credentials associated with
322
+ this Service User will stop working. The option expects a positive integer value, optionally
323
+ suffixed by the units `s`, `m`, `h`, or `d`, meaning seconds, minutes, hours, and days respectively.
324
+ If no suffix is provided, the value is interpreted as seconds. If `--expires-in` is not provided,
325
+ the newly created Service User will never expire.
326
+
327
+ Examples:
328
+
329
+ ```bash
330
+ # Create a Service User named `test-1`, which never expires:
331
+ modal workspace service-users create test-1
332
+
333
+ # Two ways of creating a Service User which is valid for an hour:
334
+ modal workspace service-users create test-2 --expires-in 3600
335
+ modal workspace service-users create test-3 --expires-in 1h
336
+
337
+ # Create a Service User which is valid for 1 year, outputting JSON:
338
+ modal workspace service-users create test-4 --expires-in 365d --json
339
+ ```
340
+ """
341
+
342
+ expires_in: int | None
343
+ if expires_in_str is None:
344
+ expires_in = None
345
+ else:
346
+ try:
347
+ expires_in = int(expires_in_str)
348
+ except ValueError:
349
+ try:
350
+ expires_in = int(parse_duration(expires_in_str).total_seconds())
351
+ except ValueError as e:
352
+ raise click.UsageError(str(e)) from e
353
+
354
+ if expires_in is not None and expires_in <= 0:
355
+ raise click.UsageError("Value passed to `--expires-in` must be a positive integer.")
356
+
357
+ if expires_in is None:
358
+ req = api_pb2.ServiceUserCreateRequest(name=name)
359
+ else:
360
+ req = api_pb2.ServiceUserCreateRequest(name=name, expires_in_seconds=expires_in)
361
+
362
+ ws = _Workspace.from_context()
363
+ await ws.hydrate()
364
+
365
+ resp = await ws.client._stub.ServiceUserCreate(req)
366
+
367
+ output_manager = OutputManager.get()
368
+
369
+ if json:
370
+ output_manager.print_json(
371
+ json_mod.dumps(
372
+ {
373
+ "token_id": resp.token_id,
374
+ "token_secret": resp.token_secret,
375
+ "expires_at": timestamp_to_localized_str(resp.expires_at.ToDatetime(timezone.utc).timestamp())
376
+ if expires_in
377
+ else None,
378
+ }
379
+ )
380
+ )
381
+ return
382
+
383
+ output_manager.print(f"[green]✓[/green] Successfully created Service User `{name}`, token details below:")
384
+
385
+ table = rich.table.Table("", pad_edge=False, box=None)
386
+ table.add_row("Token ID:", resp.token_id)
387
+ table.add_row("Token Secret:", resp.token_secret)
388
+
389
+ if expires_in:
390
+ table.add_row(
391
+ "Expires At:", timestamp_to_localized_str(resp.expires_at.ToDatetime(timezone.utc).timestamp(), isotz=False)
392
+ )
393
+
394
+ output_manager.print(table)
395
+ output_manager.print("")
396
+
397
+ output_manager.print("[yellow]:warning:[/yellow] This is the only time you will be able to see the Token Secret.")
398
+
399
+
400
+ @service_users_cli.command("list")
401
+ @click.option("--json", is_flag=True, default=False, help="Output as JSON.")
402
+ def service_users_list(json: bool):
403
+ """List all Service Users in this workspace.
404
+
405
+ In order to run this command, you must have at least Manager level permissions in the Workspace.
406
+
407
+ Examples:
408
+
409
+ ```bash
410
+ # List all Service Users with a human-readable output format
411
+ modal workspace service-users list
412
+
413
+ # List all Service Users and return JSON output
414
+ modal workspace service-users list --json
415
+ ```
416
+ """
417
+
418
+ ws = Workspace.from_context()
419
+ service_users = ws.service_users.list()
420
+
421
+ output_manager = OutputManager.get()
422
+
423
+ if json:
424
+ output_manager.print_json(
425
+ json_mod.dumps(
426
+ [
427
+ {
428
+ "name": su.name,
429
+ "service_user_id": su.service_user_id,
430
+ "token_id": su.token_id,
431
+ "created_by": su.created_by,
432
+ "created_at": timestamp_to_localized_str(su.created_at.timestamp()),
433
+ "last_used_at": timestamp_to_localized_str(su.last_used_at.timestamp())
434
+ if su.last_used_at is not None
435
+ else None,
436
+ "expires_at": timestamp_to_localized_str(su.expires_at.timestamp())
437
+ if su.expires_at is not None
438
+ else None,
439
+ "environment_roles": su.environment_roles,
440
+ }
441
+ for su in service_users
442
+ ]
443
+ )
444
+ )
445
+ return
446
+
447
+ table = rich.table.Table(box=SIMPLE_HEAD)
448
+ table.add_column("Name")
449
+ table.add_column("Token ID")
450
+ table.add_column("Created By")
451
+ table.add_column("Expires")
452
+
453
+ for su in service_users:
454
+ table.add_row(
455
+ su.name,
456
+ su.token_id,
457
+ su.created_by,
458
+ timestamp_to_localized_str(su.expires_at.timestamp(), isotz=False) if su.expires_at is not None else "-",
459
+ )
460
+
461
+ output_manager.print(table)
462
+
463
+
464
+ @service_users_cli.command("delete", no_args_is_help=True)
465
+ @click.argument("name")
466
+ @click.option("--allow-missing", is_flag=True, default=False, help="Don't error if the Volume doesn't exist.")
467
+ @yes_option
468
+ def service_users_delete(name: str, *, allow_missing: bool, yes: bool):
469
+ """Delete a Service User in this Workspace.
470
+
471
+ Note that this action is irreversible. Any existing processes / scripts that use the credentials
472
+ associated with the target Service User will stop being able to authenticate with Modal.
473
+
474
+ ```bash
475
+ # Delete the Service User named `test-service-user`
476
+ modal workspace service-users delete test-service-user
477
+ ```
478
+ """
479
+ if not yes:
480
+ confirm_or_suggest_yes(
481
+ f"Are you sure you want to irrevocably delete the Service User '{name}' and its associated credentials?"
482
+ )
483
+
484
+ ws = Workspace.from_context()
485
+ ws.service_users.delete(name, allow_missing=allow_missing)
486
+
487
+ output_manager = OutputManager.get()
488
+ output_manager.print(f"[green]✓[/green] Deleted Service User `{name}`.")
489
+
490
+
491
+ @service_users_cli.command("info", no_args_is_help=True)
492
+ @click.argument("name")
493
+ @click.option("--json", is_flag=True, default=False, help="Output as JSON.")
494
+ def service_users_info(name: str, json: bool):
495
+ """Inspect a Service User in this workspace.
496
+
497
+ Examples:
498
+
499
+ ```bash
500
+ # Inspect the Service User named `test-service-user`
501
+ modal workspace service-users info test-service-user
502
+
503
+ # Inspect the Service User named `test-service-user-2`, outputting JSON
504
+ modal workspace service-users info test-service-user-2 --json
505
+ ```
506
+ """
507
+
508
+ ws = Workspace.from_context()
509
+ su = ws.service_users.info(name)
510
+
511
+ output_manager = OutputManager.get()
512
+ if json:
513
+ output_manager.print_json(
514
+ json_mod.dumps(
515
+ {
516
+ "name": su.name,
517
+ "service_user_id": su.service_user_id,
518
+ "token_id": su.token_id,
519
+ "created_by": su.created_by,
520
+ "created_at": timestamp_to_localized_str(su.created_at.timestamp()),
521
+ "last_used_at": timestamp_to_localized_str(su.last_used_at.timestamp())
522
+ if su.last_used_at is not None
523
+ else None,
524
+ "expires_at": timestamp_to_localized_str(su.expires_at.timestamp())
525
+ if su.expires_at is not None
526
+ else None,
527
+ "environment_roles": su.environment_roles,
528
+ }
529
+ )
530
+ )
531
+ return
532
+
533
+ rows: list[tuple[str, str]] = []
534
+
535
+ rows.append(("Name:", su.name))
536
+ rows.append(("Service User ID:", su.service_user_id))
537
+ rows.append(("Token ID:", su.token_id))
538
+
539
+ environment_roles: list[tuple[str, str]] = []
540
+ for env_name, role in su.environment_roles.items():
541
+ if role == "no-access":
542
+ continue
543
+
544
+ environment_roles.append((env_name, role))
545
+
546
+ rows.append(("Created By:", su.created_by))
547
+ rows.append(("Created At:", str(timestamp_to_localized_str(su.created_at.timestamp(), isotz=False))))
548
+ rows.append(
549
+ (
550
+ "Last Used:",
551
+ str(timestamp_to_localized_str(su.last_used_at.timestamp(), isotz=False))
552
+ if su.last_used_at is not None
553
+ else "-",
554
+ )
555
+ )
556
+ rows.append(
557
+ (
558
+ "Expires:",
559
+ str(timestamp_to_localized_str(su.expires_at.timestamp(), isotz=False))
560
+ if su.expires_at is not None
561
+ else "-",
562
+ )
563
+ )
564
+
565
+ if len(environment_roles) > 0:
566
+ rows.append(("", ""))
567
+ rows.append(("Environment Access:", ""))
568
+ for env_name, r in environment_roles:
569
+ rows.append((f" {env_name}", r.capitalize()))
570
+
571
+ if len(environment_roles) == 0:
572
+ output_manager.print("")
573
+ output_manager.print(" [yellow]:warning:[/yellow] This Service User does not have access to any Environments.")
574
+
575
+ table = rich.table.Table(box=SIMPLE, show_header=False)
576
+ for row in rows:
577
+ left, right = row
578
+ table.add_row(left, Text(right, justify="right"))
579
+
580
+ output_manager.print(table)
581
+
582
+
583
+ @service_users_cli.command("set-role", no_args_is_help=True)
584
+ @click.argument("service_user_name")
585
+ @click.argument("environment_name")
586
+ @click.argument("role")
587
+ def service_users_set_role(
588
+ service_user_name: str,
589
+ environment_name: str,
590
+ role: str,
591
+ ):
592
+ """Update a Service User's role in a specific Environment.
593
+
594
+ Examples:
595
+
596
+ ```bash
597
+ # Give Contributor access in Environment `main` to Service User `test-service-user`
598
+ modal workspace service-users set-role test-service-user main contributor
599
+
600
+ # Revoke access to Environment `prod` from Service User `test-service-user`
601
+ modal workspace service-users set-role test-service-user prod no-access
602
+ ```
603
+ """
604
+
605
+ ws = Workspace.from_context()
606
+ info = ws.service_users.info(service_user_name)
607
+
608
+ existing_role = info.environment_roles.get(environment_name) or "no-access"
609
+
610
+ ws.service_users.set_role(service_user_name, environment_name, role=role)
611
+
612
+ output_manager = OutputManager.get()
613
+ if role == existing_role:
614
+ if role == "no-access":
615
+ output_manager.print(
616
+ f"Service User [bold]{service_user_name}[/bold] does not have access to "
617
+ f"Environment [bold]{environment_name}[/bold] already. Role was not updated."
618
+ )
619
+ else:
620
+ output_manager.print(
621
+ f"Service User [bold]{service_user_name}[/bold] is already a {role.capitalize()} in "
622
+ f"Environment [bold]{environment_name}[/bold]. Role was not updated."
623
+ )
624
+ elif role == "no-access":
625
+ output_manager.print(
626
+ f"[green]✓[/green] Revoked Service User [bold]{service_user_name}[/bold]'s access to "
627
+ f"Environment [bold]{environment_name}[/bold]."
628
+ )
629
+ else:
630
+ output_manager.print(
631
+ f"[green]✓[/green] Updated Service User [bold]{service_user_name}[/bold]'s role in "
632
+ f"Environment [bold]{environment_name}[/bold] to {role.capitalize()}."
633
+ )
modal/client.pyi CHANGED
@@ -80,7 +80,7 @@ class _Client:
80
80
  server_url: str,
81
81
  client_type: int,
82
82
  credentials: typing.Optional[tuple[str, str]],
83
- version: str = "1.6.2.dev8",
83
+ version: str = "1.6.2.dev10",
84
84
  *,
85
85
  oauth_credentials: typing.Optional[_OAuthCredentials] = None,
86
86
  ):
@@ -290,7 +290,7 @@ class Client:
290
290
  server_url: str,
291
291
  client_type: int,
292
292
  credentials: typing.Optional[tuple[str, str]],
293
- version: str = "1.6.2.dev8",
293
+ version: str = "1.6.2.dev10",
294
294
  *,
295
295
  oauth_credentials: typing.Optional[_OAuthCredentials] = None,
296
296
  ):
modal/types.py CHANGED
@@ -441,6 +441,43 @@ class TokenData:
441
441
  token_secret: str
442
442
 
443
443
 
444
+ @dataclass(frozen=True)
445
+ class ServiceUserInfo:
446
+ """A lightweight dataclass containing metadata about a Modal Service User."""
447
+
448
+ service_user_id: str
449
+ name: str
450
+ token_id: str
451
+ created_by: str
452
+ created_at: datetime
453
+ last_used_at: datetime | None
454
+ expires_at: datetime | None
455
+
456
+ # Environment Name -> Role
457
+ environment_roles: dict[str, str]
458
+
459
+ @classmethod
460
+ def _from_proto(cls, proto: api_pb2.ServiceUser) -> "ServiceUserInfo":
461
+ created_at = datetime.fromtimestamp(proto.created_at, tz=timezone.utc)
462
+ last_used_at = datetime.fromtimestamp(proto.last_used_at, tz=timezone.utc) if proto.last_used_at else None
463
+ expires_at = datetime.fromtimestamp(proto.expires_at, tz=timezone.utc) if proto.expires_at else None
464
+
465
+ environment_roles: dict[str, str] = {}
466
+ for item in proto.environment_roles:
467
+ environment_roles[item.environment_name] = item.role_str
468
+
469
+ return cls(
470
+ service_user_id=proto.service_user_id,
471
+ name=proto.name,
472
+ token_id=proto.token_id,
473
+ created_by=proto.created_by,
474
+ created_at=created_at,
475
+ last_used_at=last_used_at,
476
+ expires_at=expires_at,
477
+ environment_roles=environment_roles,
478
+ )
479
+
480
+
444
481
  @dataclass(frozen=True)
445
482
  class ProxyTokenInfo:
446
483
  """Metadata about a proxy token, not including the token secret."""
modal/workspace.py CHANGED
@@ -9,6 +9,7 @@ from ._workspace import (
9
9
  _WorkspaceBillingManager,
10
10
  _WorkspaceMembersManager,
11
11
  _WorkspaceProxyTokenManager,
12
+ _WorkspaceServiceUserManager,
12
13
  _WorkspaceSettingsManager,
13
14
  )
14
15
 
@@ -17,6 +18,7 @@ WorkspaceMembersManager = synchronize_api(_WorkspaceMembersManager, target_modul
17
18
  WorkspaceBillingManager = synchronize_api(_WorkspaceBillingManager, target_module=__name__)
18
19
  WorkspaceProxyTokenManager = synchronize_api(_WorkspaceProxyTokenManager, target_module=__name__)
19
20
  WorkspaceSettingsManager = synchronize_api(_WorkspaceSettingsManager, target_module=__name__)
21
+ WorkspaceServiceUserManager = synchronize_api(_WorkspaceServiceUserManager, target_module=__name__)
20
22
 
21
23
  __all__ = [
22
24
  "ProxyTokenInfo",
@@ -25,6 +27,7 @@ __all__ = [
25
27
  "WorkspaceMemberInfo",
26
28
  "WorkspaceMembersManager",
27
29
  "WorkspaceProxyTokenManager",
30
+ "WorkspaceServiceUserManager",
28
31
  "WorkspaceSettingsManager",
29
32
  "WorkspaceSettings",
30
33
  ]
modal/workspace.pyi CHANGED
@@ -186,6 +186,15 @@ class Workspace(modal.object.Object):
186
186
  """Namespace for Workspace settings APIs."""
187
187
  ...
188
188
 
189
+ @property
190
+ def service_users(self) -> WorkspaceServiceUserManager:
191
+ """Namespace for managing Service Users in a Workspace.
192
+
193
+ See [the guide](https://modal.com/docs/guide/service-users) for more information about Service
194
+ Users.
195
+ """
196
+ ...
197
+
189
198
  class WorkspaceMembersManager:
190
199
  """mdmd:namespace"""
191
200
  def __init__(self, workspace: Workspace):
@@ -720,3 +729,230 @@ class WorkspaceSettingsManager:
720
729
  ...
721
730
 
722
731
  set: __set_spec
732
+
733
+ class WorkspaceServiceUserManager:
734
+ """mdmd:namespace"""
735
+ def __init__(self, workspace: Workspace):
736
+ """mdmd:hidden"""
737
+ ...
738
+
739
+ class __create_spec(typing_extensions.Protocol):
740
+ def __call__(self, /, name: str, *, expires_in: typing.Optional[int]) -> modal.types.TokenData:
741
+ """Create a new Service User in the current workspace.
742
+
743
+ To call this function, you are required to have Manager permissions in the Workspace.
744
+
745
+ Args:
746
+ name: The name of the new Service User. Must consistent only of alphanumeric, '-', or '_'
747
+ characters.
748
+ expires_in: An expiration time, in seconds, after which the token will stop working. If
749
+ `None`, the token will never expire.
750
+
751
+ Returns:
752
+ A `TokenData` dataclass containing the credentials for the Service User. Note that this
753
+ is the only time you will be able to view these credentials.
754
+
755
+ Examples:
756
+
757
+ ```python notest
758
+ ws = modal.Workspace.from_context()
759
+
760
+ # Creates a Service User named "test-service-user", whose credentials expire after one year.
761
+ service_user_token_data = ws.service_users.create(
762
+ "test-service-user",
763
+ expires_in=datetime.timedelta(days=365).total_seconds(),
764
+ )
765
+ ```
766
+ """
767
+ ...
768
+
769
+ async def aio(self, /, name: str, *, expires_in: typing.Optional[int]) -> modal.types.TokenData:
770
+ """Create a new Service User in the current workspace.
771
+
772
+ To call this function, you are required to have Manager permissions in the Workspace.
773
+
774
+ Args:
775
+ name: The name of the new Service User. Must consistent only of alphanumeric, '-', or '_'
776
+ characters.
777
+ expires_in: An expiration time, in seconds, after which the token will stop working. If
778
+ `None`, the token will never expire.
779
+
780
+ Returns:
781
+ A `TokenData` dataclass containing the credentials for the Service User. Note that this
782
+ is the only time you will be able to view these credentials.
783
+
784
+ Examples:
785
+
786
+ ```python notest
787
+ ws = modal.Workspace.from_context()
788
+
789
+ # Creates a Service User named "test-service-user", whose credentials expire after one year.
790
+ service_user_token_data = ws.service_users.create(
791
+ "test-service-user",
792
+ expires_in=datetime.timedelta(days=365).total_seconds(),
793
+ )
794
+ ```
795
+ """
796
+ ...
797
+
798
+ create: __create_spec
799
+
800
+ class __set_role_spec(typing_extensions.Protocol):
801
+ def __call__(self, /, service_user_name: str, environment_name: str, *, role: str) -> None:
802
+ """Set the role of a specified Service User within an Environment.
803
+
804
+ To call this function, you are required to have Manager permissions in the Workspace.
805
+
806
+ Args:
807
+ service_user_name: The name of the Service User.
808
+ environment_name: The name of the Environment to which the Service User's new role will
809
+ apply.
810
+ role: The access level to grant this Service User.
811
+
812
+ Examples:
813
+
814
+ ```python notest
815
+ ws = modal.Workspace.from_context()
816
+ # Grants Service User "test-service-user" the Viewer role in Environment "production"
817
+ ws.service_users.set_role("test-service-user", "production", role="viewer")
818
+
819
+ # Revokes access to "production" from Service User "test-service-user"
820
+ ws.service_users.set_role("test-service-user", "production", role="no-access")
821
+ ```
822
+ """
823
+ ...
824
+
825
+ async def aio(self, /, service_user_name: str, environment_name: str, *, role: str) -> None:
826
+ """Set the role of a specified Service User within an Environment.
827
+
828
+ To call this function, you are required to have Manager permissions in the Workspace.
829
+
830
+ Args:
831
+ service_user_name: The name of the Service User.
832
+ environment_name: The name of the Environment to which the Service User's new role will
833
+ apply.
834
+ role: The access level to grant this Service User.
835
+
836
+ Examples:
837
+
838
+ ```python notest
839
+ ws = modal.Workspace.from_context()
840
+ # Grants Service User "test-service-user" the Viewer role in Environment "production"
841
+ ws.service_users.set_role("test-service-user", "production", role="viewer")
842
+
843
+ # Revokes access to "production" from Service User "test-service-user"
844
+ ws.service_users.set_role("test-service-user", "production", role="no-access")
845
+ ```
846
+ """
847
+ ...
848
+
849
+ set_role: __set_role_spec
850
+
851
+ class __info_spec(typing_extensions.Protocol):
852
+ def __call__(self, /, name: str) -> modal.types.ServiceUserInfo:
853
+ """Get information about a specific Service User.
854
+
855
+ Args:
856
+ name: The name of the Service User to introspect.
857
+
858
+ Returns:
859
+ A `ServiceUserInfo` dataclass containing information about the Service User.
860
+
861
+ Examples:
862
+
863
+ ```python notest
864
+ ws = modal.Workspace.from_context()
865
+ service_user = ws.service_users.info("test-service-user")
866
+ ```
867
+ """
868
+ ...
869
+
870
+ async def aio(self, /, name: str) -> modal.types.ServiceUserInfo:
871
+ """Get information about a specific Service User.
872
+
873
+ Args:
874
+ name: The name of the Service User to introspect.
875
+
876
+ Returns:
877
+ A `ServiceUserInfo` dataclass containing information about the Service User.
878
+
879
+ Examples:
880
+
881
+ ```python notest
882
+ ws = modal.Workspace.from_context()
883
+ service_user = ws.service_users.info("test-service-user")
884
+ ```
885
+ """
886
+ ...
887
+
888
+ info: __info_spec
889
+
890
+ class __list_spec(typing_extensions.Protocol):
891
+ def __call__(self, /) -> list[modal.types.ServiceUserInfo]:
892
+ """List all Service Users in the current workspace.
893
+
894
+ Returns:
895
+ A list of `ServiceUserInfo` dataclasses.
896
+
897
+ Examples:
898
+
899
+ ```python notest
900
+ ws = modal.Workspace.from_context()
901
+ for service_user in ws.service_users.list():
902
+ print(service_user.name)
903
+ ```
904
+ """
905
+ ...
906
+
907
+ async def aio(self, /) -> list[modal.types.ServiceUserInfo]:
908
+ """List all Service Users in the current workspace.
909
+
910
+ Returns:
911
+ A list of `ServiceUserInfo` dataclasses.
912
+
913
+ Examples:
914
+
915
+ ```python notest
916
+ ws = modal.Workspace.from_context()
917
+ for service_user in ws.service_users.list():
918
+ print(service_user.name)
919
+ ```
920
+ """
921
+ ...
922
+
923
+ list: __list_spec
924
+
925
+ class __delete_spec(typing_extensions.Protocol):
926
+ def __call__(self, /, name: str, *, allow_missing: bool = False) -> None:
927
+ """Permanently delete a Service User.
928
+
929
+ Args:
930
+ name: The name of the Service User to delete.
931
+ allow_missing: If `True`, do not throw an exception if the Service User does not exist.
932
+
933
+ Examples:
934
+
935
+ ```python notest
936
+ ws = modal.Workspace.from_context()
937
+ ws.service_users.delete("test-service-user")
938
+ ```
939
+ """
940
+ ...
941
+
942
+ async def aio(self, /, name: str, *, allow_missing: bool = False) -> None:
943
+ """Permanently delete a Service User.
944
+
945
+ Args:
946
+ name: The name of the Service User to delete.
947
+ allow_missing: If `True`, do not throw an exception if the Service User does not exist.
948
+
949
+ Examples:
950
+
951
+ ```python notest
952
+ ws = modal.Workspace.from_context()
953
+ ws.service_users.delete("test-service-user")
954
+ ```
955
+ """
956
+ ...
957
+
958
+ delete: __delete_spec
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.6.2.dev8
3
+ Version: 1.6.2.dev10
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -32,14 +32,14 @@ modal/_tunnel.py,sha256=4M1qLFXvJLV2s_vr5vHXDtGdZr0OUNmXNj1C3lyF4qE,7360
32
32
  modal/_tunnel.pyi,sha256=WVP0dkoN__mM73ZQ-r3nHKCXvYlRMmEWW8iw2xrSY6M,14698
33
33
  modal/_type_manager.py,sha256=DWjgmjYJuOagw2erin506UUbG2H5UzZCFEekS-7hmfA,9087
34
34
  modal/_watcher.py,sha256=Ot1My6fhTp5v0NXR7-ESV8kNQqvpgozrIcvLBtf5r-U,3564
35
- modal/_workspace.py,sha256=IfjcknvmWKVqiltkvHFKVBAKi2EVo-nMOTVKlWCOll4,21849
35
+ modal/_workspace.py,sha256=HgV--qq7SDc4v5fcogoUdypPTA9MXh2w998J5iQ1Zao,27830
36
36
  modal/app.py,sha256=tAILXi3rk-d2KgdLKGMsB7awShiPBcPfEfiPLn1fGQY,74703
37
37
  modal/app.pyi,sha256=TjcJhoWwZTK2eQ_HCIChU62xSdLi6SvHVQYksy-eZ7w,88565
38
38
  modal/billing.py,sha256=E1-P_P8bJFLp-bTaggYd4BaaViImPB5UdtXiY8VeAZQ,390
39
39
  modal/billing.pyi,sha256=4m7VLYNS8IiJ2I3CJx0awvrOM6BILWRVLfdeqtAYr8M,7189
40
40
  modal/call_graph.py,sha256=XGALIDn1bLchWo_goyX23bcPjzCZcfqKUo8INIfDapo,1775
41
41
  modal/client.py,sha256=cstrL0wpXCZmcvRs7Sy3QRw9Vz8zkZy8NNk0tPe38sk,22852
42
- modal/client.pyi,sha256=5VcbRBy5EfVsMzw0sU9MFtyymwPi6LKigWuAYqzSums,23488
42
+ modal/client.pyi,sha256=FIs-LOX3P2SlgWbBi0iRAAeqRM-7pRxdSXrdFK2jai0,23490
43
43
  modal/cloud_bucket_mount.py,sha256=ZOg69dhIFcJcwim4JP-6s1tqwOYAfgS9806jRpspifw,7511
44
44
  modal/cloud_bucket_mount.pyi,sha256=HoPe_NG5jYdVfmTNY-ZJ3-C8Eo_FzJovl9orJRjkGbE,10252
45
45
  modal/cls.py,sha256=qoGJD590GnHHLvVhkn-BMl4ATMrw2Fg_V4evy5ZQbJQ,41784
@@ -92,11 +92,11 @@ modal/serving.pyi,sha256=nKgt3i2X93NpAAZzIpbj4fiR1TIJxSXnK659w4UD2sE,3962
92
92
  modal/stream_type.py,sha256=A6320qoAAWhEfwOCZfGtymQTu5AfLfJXXgARqooTPvY,417
93
93
  modal/token_flow.py,sha256=1Os7Zw41Fh_YU8CMSWRILDKRlUZcl9pExaX_EDVdBiU,7446
94
94
  modal/token_flow.pyi,sha256=nqs_KHmwM-ssXKeWUTnMVjOgliSzZLl30Tcg6VvMzG0,2605
95
- modal/types.py,sha256=dF7Tm2sV2gmi6Vv7oHzQdzNmVIHgUywIfqy7vYJ-Fh4,36199
95
+ modal/types.py,sha256=LuOQoaDt8T-1i2XOfXzy3Ym1yxHEZ08HTszhGddvCvI,37474
96
96
  modal/volume.py,sha256=wbTVEriIv3djjeUAuUWkLABgG4ppbNMmXLJeV0bev3Q,71117
97
97
  modal/volume.pyi,sha256=Rp2gw_zh8HAAHRbdDkF782tttQfyv3uzSriO-zpMXDU,67350
98
- modal/workspace.py,sha256=_phlb1Bxie_KR1Y9atBzkB21Q_qmRztf1JLzQYdK0qk,988
99
- modal/workspace.pyi,sha256=X3wZ5GWrIpxJVxya4NDKvzdxJqGRhsoc1wNgKSMOySg,26497
98
+ modal/workspace.py,sha256=83AGrCXEN1-QhC6XfQusgWO50GOIJX_c1QKsF52wjdA,1157
99
+ modal/workspace.pyi,sha256=IfyKAkNmnFi-lMVBDuykPu2UTi7kg7495kmZ4w7yiYY,35009
100
100
  modal/_output/__init__.py,sha256=xtGN6SPv2zvByrJ32JPao0yJKpxyCIZ-2JMTScrisuQ,28
101
101
  modal/_output/manager.py,sha256=DqEguniI8uWMFUZFX4U4gsCFpio1zQeWjJhJC_GZhi4,18286
102
102
  modal/_output/pty.py,sha256=ngkKVpU8zDOxg94LiWYmdwXavd305HQDXzjWGrHQBIM,10408
@@ -175,12 +175,12 @@ modal/cli/entry_point.py,sha256=Sy9w9C2RHG9XDalJ0Fjwr4yQc4dTEL9gVNQuDrj3woI,5050
175
175
  modal/cli/environment.py,sha256=rKnKQFsbcLXjBtYWLFRqhP1NeCjMnqvSPci7Md4ZjIM,18377
176
176
  modal/cli/function.py,sha256=gyY7cgwfxZfNRwyO1X4I1XK8xL97heoEp7CuVwPrRoI,57624
177
177
  modal/cli/image.py,sha256=Ve-PDT4XTH9KEQrZxvUc2ocAW4mLI5QfwQssaajK4MI,8073
178
- modal/cli/import_refs.py,sha256=gxoogHVixec1-qj_FUwhWQXqjy9yvqYCNuAYWCCFEWk,15994
178
+ modal/cli/import_refs.py,sha256=cI9njDM4alDawlgAGsFLBQ6eP7K06aFHOp0-K7OSd_8,17093
179
179
  modal/cli/logo.py,sha256=plv82syvPb8j3qrR1A_nojZwp823t3VEXaDlrx1jkt0,3221
180
180
  modal/cli/network_file_system.py,sha256=xNvau_6fziIfbPIlVGAh1-wOGVCNko28GweAjO51LCU,7681
181
181
  modal/cli/profile.py,sha256=JbpSo5bcK2gdVx3QDrGWwnfRRG-_QtrsLAaQXnwh4K0,4782
182
182
  modal/cli/queues.py,sha256=cpoza1mEwupQoaBy7kaTqsTaWPto9tHRQrOeLXxZAUU,6951
183
- modal/cli/run.py,sha256=rzLG_eDO4uxcIxPQsz_PbGe8MZTcPNzA3p83HDFEcUk,24508
183
+ modal/cli/run.py,sha256=rkAsn4aDa80JjMj1rwGwrVdlkJkazclSyvar7YSmi3Y,26410
184
184
  modal/cli/secret.py,sha256=lckWyas1J3uQm1f8D9nkWlRlmToNzmy5a-0q4IsJFCc,8254
185
185
  modal/cli/selector.py,sha256=OJr9Ua3K_l-qu3N3-I8z8oVq8lBakws2-XgfheR5p-c,4741
186
186
  modal/cli/server.py,sha256=H0u3u9S-DARMFKdYuvWJcu5s8egOQYxvRr79X_Lceeg,49723
@@ -189,7 +189,7 @@ modal/cli/skills.py,sha256=oniDafrmu_ARYTlButnlXbKE9XlEU1-EFz-eO4tdnFY,11954
189
189
  modal/cli/token.py,sha256=TOuauMzSe7PV1bMnmGQO7oJaAsYY03C1GY6Nww8ac8Y,4905
190
190
  modal/cli/utils.py,sha256=1tkDLqtVqYvj4xLoRaOuO_cl8WJxPpHQnIdTg5S2tjA,11670
191
191
  modal/cli/volume.py,sha256=nM5QKRjX-d6lQO4FPNDFbIZhuWfZ2O_RW8gtWWwgs4Q,11281
192
- modal/cli/workspace.py,sha256=wt11BRti_FMnWnztTrnPoXUAtLhFVDH194aImXyWCms,8980
192
+ modal/cli/workspace.py,sha256=JHIX9XyYYyrgq8GgjexSGmqwO6dSyPL5irYhecYRVZI,22323
193
193
  modal/experimental/__init__.py,sha256=iHj9XLSZ_J0ciUalr96hJ57GbOsi2v87u5QZ6mPiHtg,13205
194
194
  modal/experimental/_flash_container_entry.py,sha256=u0ciXgUain0tosulHFVh_Dp7vi51uHWWiThdQbj0whs,2318
195
195
  modal/experimental/flash.py,sha256=ZZJIlJ9lLv03CQr6muRUXQB-5tZII_zX7qiLqRuJWGM,13120
@@ -207,7 +207,7 @@ modal/sandbox/_task_command_router_client.py,sha256=qhFXy8eyGIxwxRFZkTsxJVxsAc8c
207
207
  modal/sandbox/sandbox.py,sha256=4ekghND9qUGf5wtIQH4mVa6PHGjHgY0UN5RKryOM05k,683
208
208
  modal/sandbox/sandbox.pyi,sha256=HYxopAUYMfMEVMWgzeb4qPATPFzLkTCnqpSl9lI-3ac,150321
209
209
  modal/skills/modal/SKILL.md,sha256=Bm-0BPTYppJoeQzA-N-Re8L6-nKnV6gspJXMl4cD0P0,5490
210
- modal-1.6.2.dev8.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
210
+ modal-1.6.2.dev10.dist-info/licenses/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
211
211
  modal_docs/__init__.py,sha256=svYKtV8HDwDCN86zbdWqyq5T8sMdGDj0PVlzc2tIxDM,28
212
212
  modal_docs/gen_cli_docs.py,sha256=9-b8vl2EsLWD9zJqpCK8IUi5h44Bjn3i3rSdedZV4bA,6415
213
213
  modal_docs/gen_cli_docs_main.py,sha256=UPGr7O1DYTIRLwfINe2rbdh89J3cPVTU1OWL-SjxnOQ,198
@@ -231,10 +231,10 @@ modal_proto/task_command_router_pb2.py,sha256=ysC0d8p3YxwyOpP3B-qDlvE81Yo4ntQiwi
231
231
  modal_proto/task_command_router_pb2.pyi,sha256=FDcd5t3ZCmQHUjefOzg07iglrgKYcBrzFvdKIWsEMHI,50628
232
232
  modal_proto/task_command_router_pb2_grpc.py,sha256=8iGUGwEVbqOsZWPJ7OsQ9OKUGaSCf9Dr2tvb04Ww6d4,44620
233
233
  modal_proto/task_command_router_pb2_grpc.pyi,sha256=26Fq9RQo09B9KLHsgdpRuw-I6Z4-2jrXLQkuDzB-moI,15097
234
- modal_version/__init__.py,sha256=WsuSyNuz9p14Rv35_HGOr5gTGppmiBErxZvIpLsh-Y4,120
234
+ modal_version/__init__.py,sha256=2lqb4ysefgTqscFMp9LWCNfAU0rKY3oVgW2wnWTy-pw,121
235
235
  modal_version/__main__.py,sha256=nLUbubxKx7GXNzJIkTB7M7_u8080LyLM0IS0rzFkC_c,119
236
- modal-1.6.2.dev8.dist-info/METADATA,sha256=Ra0MKB3MFjRNZdmVPh0Mc-HvN-Uw8yTC1onPfOiu-K0,2512
237
- modal-1.6.2.dev8.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
238
- modal-1.6.2.dev8.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
239
- modal-1.6.2.dev8.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
240
- modal-1.6.2.dev8.dist-info/RECORD,,
236
+ modal-1.6.2.dev10.dist-info/METADATA,sha256=9gYqJTJseHyFzcr4xOZ3H3E-6BriZbJuQKCaBfxANZ8,2513
237
+ modal-1.6.2.dev10.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
238
+ modal-1.6.2.dev10.dist-info/entry_points.txt,sha256=An-wYgeEUnm6xzrAP9_NTSTSciYvvEWsMZILtYrvpAI,46
239
+ modal-1.6.2.dev10.dist-info/top_level.txt,sha256=4BWzoKYREKUZ5iyPzZpjqx4G8uB5TWxXPDwibLcVa7k,43
240
+ modal-1.6.2.dev10.dist-info/RECORD,,
modal_version/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # Copyright Modal Labs 2026
2
2
  """Supplies the current version of the modal client library."""
3
3
 
4
- __version__ = "1.6.2.dev8"
4
+ __version__ = "1.6.2.dev10"