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 +180 -1
- modal/cli/import_refs.py +36 -7
- modal/cli/run.py +51 -1
- modal/cli/workspace.py +365 -1
- modal/client.pyi +2 -2
- modal/types.py +37 -0
- modal/workspace.py +3 -0
- modal/workspace.pyi +236 -0
- {modal-1.6.2.dev8.dist-info → modal-1.6.2.dev10.dist-info}/METADATA +1 -1
- {modal-1.6.2.dev8.dist-info → modal-1.6.2.dev10.dist-info}/RECORD +15 -15
- modal_version/__init__.py +1 -1
- {modal-1.6.2.dev8.dist-info → modal-1.6.2.dev10.dist-info}/WHEEL +0 -0
- {modal-1.6.2.dev8.dist-info → modal-1.6.2.dev10.dist-info}/entry_points.txt +0 -0
- {modal-1.6.2.dev8.dist-info → modal-1.6.2.dev10.dist-info}/licenses/LICENSE +0 -0
- {modal-1.6.2.dev8.dist-info → modal-1.6.2.dev10.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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=
|
|
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=
|
|
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=
|
|
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=
|
|
99
|
-
modal/workspace.pyi,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
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=
|
|
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.
|
|
237
|
-
modal-1.6.2.
|
|
238
|
-
modal-1.6.2.
|
|
239
|
-
modal-1.6.2.
|
|
240
|
-
modal-1.6.2.
|
|
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
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|