modal 1.6.2.dev7__py3-none-any.whl → 1.6.2.dev9__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/_utils/async_utils.py +3 -1
- modal/_utils/blob_utils.py +2 -0
- modal/_workspace.py +176 -0
- modal/cli/_logs.py +5 -5
- modal/cli/container.py +2 -2
- modal/cli/function.py +241 -3
- modal/cli/import_refs.py +36 -7
- modal/cli/run.py +51 -1
- modal/cli/server.py +280 -3
- modal/cli/utils.py +29 -1
- modal/client.pyi +2 -2
- modal/cls.py +1 -1
- modal/cls.pyi +1 -1
- modal/mount.py +12 -1
- modal/queue.py +1 -1
- modal/queue.pyi +2 -2
- modal/types.py +37 -0
- modal/volume.py +10 -0
- modal/volume.pyi +3 -0
- modal/workspace.py +3 -0
- modal/workspace.pyi +248 -0
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev9.dist-info}/METADATA +1 -1
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev9.dist-info}/RECORD +34 -34
- modal_proto/api_grpc.py +0 -16
- modal_proto/api_pb2.py +1273 -1321
- modal_proto/api_pb2.pyi +32 -131
- modal_proto/api_pb2_grpc.py +0 -33
- modal_proto/api_pb2_grpc.pyi +0 -10
- modal_proto/modal_api_grpc.py +0 -1
- modal_version/__init__.py +1 -1
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev9.dist-info}/WHEEL +0 -0
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev9.dist-info}/entry_points.txt +0 -0
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev9.dist-info}/licenses/LICENSE +0 -0
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev9.dist-info}/top_level.txt +0 -0
modal/_utils/async_utils.py
CHANGED
|
@@ -378,7 +378,7 @@ def retry(
|
|
|
378
378
|
|
|
379
379
|
Usage:
|
|
380
380
|
|
|
381
|
-
```
|
|
381
|
+
```python notest
|
|
382
382
|
@retry
|
|
383
383
|
async def may_fail_default():
|
|
384
384
|
# ...
|
|
@@ -907,8 +907,10 @@ async def asyncnullcontext(*args, **kwargs):
|
|
|
907
907
|
Note that for Python 3.10+ you can use contextlib.nullcontext() instead.
|
|
908
908
|
|
|
909
909
|
Usage:
|
|
910
|
+
```python notest
|
|
910
911
|
async with asyncnullcontext():
|
|
911
912
|
pass
|
|
913
|
+
```
|
|
912
914
|
"""
|
|
913
915
|
yield
|
|
914
916
|
|
modal/_utils/blob_utils.py
CHANGED
|
@@ -18,6 +18,7 @@ from typing import (
|
|
|
18
18
|
)
|
|
19
19
|
from urllib.parse import urlparse
|
|
20
20
|
|
|
21
|
+
from google.protobuf.timestamp_pb2 import Timestamp
|
|
21
22
|
from typing_extensions import Self
|
|
22
23
|
|
|
23
24
|
from modal_proto import api_pb2
|
|
@@ -451,6 +452,7 @@ class FileUploadSpec:
|
|
|
451
452
|
size: int
|
|
452
453
|
content: bytes | None = None # Set for very small files to avoid double-read
|
|
453
454
|
exists_in_store: bool | None = None # set after existence check
|
|
455
|
+
checked_at: Timestamp | None = None # server time of the existence check, set alongside exists_in_store
|
|
454
456
|
|
|
455
457
|
def read_content(self) -> bytes:
|
|
456
458
|
"""Read content from source."""
|
modal/_workspace.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import Optional
|
|
|
9
9
|
from google.protobuf.empty_pb2 import Empty
|
|
10
10
|
|
|
11
11
|
from modal.exception import DeprecationError, InvalidError
|
|
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,169 @@ 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. Must be one of the strings `"no-access"`,
|
|
633
|
+
`"viewer"`, or `"contributor"`.
|
|
634
|
+
|
|
635
|
+
Examples:
|
|
636
|
+
|
|
637
|
+
```python notest
|
|
638
|
+
ws = modal.Workspace.from_context()
|
|
639
|
+
# Grants Service User "test-service-user" the Viewer role in Environment "production"
|
|
640
|
+
ws.service_users.set_role("test-service-user", "production", role="viewer")
|
|
641
|
+
|
|
642
|
+
# Revokes access to "production" from Service User "test-service-user"
|
|
643
|
+
ws.service_users.set_role("test-service-user", "production", role="no-access")
|
|
644
|
+
```
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
if not self._workspace._is_hydrated:
|
|
648
|
+
await self._workspace.hydrate()
|
|
649
|
+
|
|
650
|
+
from ._environments import _Environment
|
|
651
|
+
|
|
652
|
+
env = _Environment.from_name(environment_name, client=self._workspace.client)
|
|
653
|
+
await env.hydrate()
|
|
654
|
+
|
|
655
|
+
await self._workspace.client._stub.EnvironmentRoleSet(
|
|
656
|
+
api_pb2.EnvironmentRoleSetRequest(
|
|
657
|
+
service_user_name=service_user_name,
|
|
658
|
+
environment_id=env.object_id,
|
|
659
|
+
role_str=role,
|
|
660
|
+
)
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
async def info(self, name: str) -> ServiceUserInfo:
|
|
664
|
+
"""Get information about a specific Service User.
|
|
665
|
+
|
|
666
|
+
To call this function, you are required to have Manager permissions in the Workspace.
|
|
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
|
+
To call this function, you are required to have Manager permissions in the Workspace.
|
|
695
|
+
|
|
696
|
+
Returns:
|
|
697
|
+
A list of `ServiceUserInfo` dataclasses.
|
|
698
|
+
|
|
699
|
+
Examples:
|
|
700
|
+
|
|
701
|
+
```python notest
|
|
702
|
+
ws = modal.Workspace.from_context()
|
|
703
|
+
for service_user in ws.service_users.list():
|
|
704
|
+
print(service_user.name)
|
|
705
|
+
```
|
|
706
|
+
"""
|
|
707
|
+
|
|
708
|
+
if not self._workspace._is_hydrated:
|
|
709
|
+
await self._workspace.hydrate()
|
|
710
|
+
|
|
711
|
+
resp = await self._workspace.client._stub.ServiceUserList(Empty())
|
|
712
|
+
|
|
713
|
+
return [ServiceUserInfo._from_proto(su) for su in resp.service_users]
|
|
714
|
+
|
|
715
|
+
async def delete(self, name: str) -> None:
|
|
716
|
+
"""Permanently delete a Service User.
|
|
717
|
+
|
|
718
|
+
To call this function, you are required to have Manager permissions in the Workspace.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
name: The name of the Service User to delete.
|
|
722
|
+
|
|
723
|
+
Examples:
|
|
724
|
+
|
|
725
|
+
```python notest
|
|
726
|
+
ws = modal.Workspace.from_context()
|
|
727
|
+
ws.service_users.delete("test-service-user")
|
|
728
|
+
```
|
|
729
|
+
"""
|
|
730
|
+
|
|
731
|
+
if not self._workspace._is_hydrated:
|
|
732
|
+
await self._workspace.hydrate()
|
|
733
|
+
|
|
734
|
+
await self._workspace.client._stub.ServiceUserDelete(api_pb2.ServiceUserDeleteRequest(service_user_name=name))
|
modal/cli/_logs.py
CHANGED
|
@@ -19,7 +19,7 @@ _SOURCE_OPTIONS = {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def _parse_time_arg(value: str | None, default: datetime, *, now: datetime
|
|
22
|
+
def _parse_time_arg(value: str | None, default: datetime, *, now: datetime) -> datetime:
|
|
23
23
|
"""Parse a time argument that can be a relative duration (e.g. '2h', '30m') or ISO 8601 datetime.
|
|
24
24
|
|
|
25
25
|
Naive datetime values are interpreted in the user's local timezone.
|
|
@@ -33,7 +33,7 @@ def _parse_time_arg(value: str | None, default: datetime, *, now: datetime | Non
|
|
|
33
33
|
except ValueError:
|
|
34
34
|
pass
|
|
35
35
|
else:
|
|
36
|
-
return
|
|
36
|
+
return now - duration
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
39
|
dt = datetime.fromisoformat(value)
|
|
@@ -62,7 +62,7 @@ def _parse_fetch_range(since: str | None, until: str | None) -> tuple[datetime,
|
|
|
62
62
|
try:
|
|
63
63
|
duration = parse_duration(value)
|
|
64
64
|
except ValueError:
|
|
65
|
-
result = _parse_time_arg(value, default=default)
|
|
65
|
+
result = _parse_time_arg(value, default=default, now=now)
|
|
66
66
|
else:
|
|
67
67
|
result = now - duration
|
|
68
68
|
if result.astimezone(timezone.utc).microsecond:
|
|
@@ -124,8 +124,8 @@ async def _run_logs_command(
|
|
|
124
124
|
return
|
|
125
125
|
|
|
126
126
|
now = datetime.now(timezone.utc)
|
|
127
|
-
since_dt = _parse_time_arg(since, default=now) if since else None
|
|
128
|
-
until_dt = _parse_time_arg(until, default=now) if until else None
|
|
127
|
+
since_dt = _parse_time_arg(since, default=now, now=now) if since else None
|
|
128
|
+
until_dt = _parse_time_arg(until, default=now, now=now) if until else None
|
|
129
129
|
if since_dt is not None:
|
|
130
130
|
effective_until = until_dt or now
|
|
131
131
|
if since_dt >= effective_until:
|
modal/cli/container.py
CHANGED
|
@@ -248,8 +248,8 @@ async def logs(
|
|
|
248
248
|
since_dt = default_since_dt
|
|
249
249
|
until_dt = default_until_dt
|
|
250
250
|
else:
|
|
251
|
-
since_dt = _parse_time_arg(since, default=default_since_dt)
|
|
252
|
-
until_dt = _parse_time_arg(until, default=default_until_dt)
|
|
251
|
+
since_dt = _parse_time_arg(since, default=default_since_dt, now=now)
|
|
252
|
+
until_dt = _parse_time_arg(until, default=default_until_dt, now=now)
|
|
253
253
|
|
|
254
254
|
if since is not None and until is not None and since_dt >= until_dt:
|
|
255
255
|
# User provided both --since and --until, but did so incorrectly
|
modal/cli/function.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import Any, AsyncGenerator, Sequence, cast
|
|
|
7
7
|
|
|
8
8
|
import click
|
|
9
9
|
from click import UsageError
|
|
10
|
+
from rich.box import SIMPLE
|
|
10
11
|
from rich.table import Column, Table
|
|
11
12
|
from rich.text import Text
|
|
12
13
|
|
|
@@ -16,7 +17,7 @@ from modal._functions import _Function
|
|
|
16
17
|
from modal._logs import _fetch_time_range, _seconds_to_timestamp, _timestamp_to_seconds
|
|
17
18
|
from modal._object import _get_environment_name
|
|
18
19
|
from modal._utils.async_utils import async_map_ordered, synchronizer
|
|
19
|
-
from modal._utils.time_utils import parse_duration
|
|
20
|
+
from modal._utils.time_utils import parse_duration, timestamp_to_localized_str
|
|
20
21
|
from modal.client import _Client
|
|
21
22
|
from modal.exception import LogsFetchError, NotFoundError
|
|
22
23
|
from modal.output import OutputManager
|
|
@@ -50,10 +51,12 @@ from .utils import (
|
|
|
50
51
|
env_option,
|
|
51
52
|
grouped_utc_timestamp,
|
|
52
53
|
humanize_filesize,
|
|
54
|
+
parse_int,
|
|
53
55
|
)
|
|
54
56
|
|
|
55
57
|
function_cli = ModalGroup(name="function", help="Inspect Modal Functions.")
|
|
56
58
|
|
|
59
|
+
|
|
57
60
|
_INPUT_METRIC_ORDER = ("Execution time (s)", "End-to-end latency (s)")
|
|
58
61
|
_CONTAINER_METRIC_ORDER = (
|
|
59
62
|
"Startup time (s)",
|
|
@@ -705,8 +708,8 @@ async def stats(
|
|
|
705
708
|
```
|
|
706
709
|
"""
|
|
707
710
|
now = datetime.now(timezone.utc)
|
|
708
|
-
until_dt = _parse_time_arg(until, default=now)
|
|
709
|
-
since_dt = _parse_time_arg(since, default=until_dt - _DEFAULT_STATS_WINDOW)
|
|
711
|
+
until_dt = _parse_time_arg(until, default=now, now=now)
|
|
712
|
+
since_dt = _parse_time_arg(since, default=until_dt - _DEFAULT_STATS_WINDOW, now=now)
|
|
710
713
|
if since_dt >= until_dt:
|
|
711
714
|
raise UsageError("--since must be before --until.")
|
|
712
715
|
|
|
@@ -1288,6 +1291,241 @@ async def variants(
|
|
|
1288
1291
|
)
|
|
1289
1292
|
|
|
1290
1293
|
|
|
1294
|
+
autoscaler_cli = ModalGroup(name="autoscaler", help="View and Configure Autoscaler settings for Modal Functions.")
|
|
1295
|
+
function_cli.add_command(autoscaler_cli)
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
@autoscaler_cli.command("settings", no_args_is_help=True)
|
|
1299
|
+
@click.argument("function_identifier", metavar="FUNCTION")
|
|
1300
|
+
@click.option("--json", "json", is_flag=True, default=False, help="Output as JSON.")
|
|
1301
|
+
@env_option
|
|
1302
|
+
@synchronizer.create_blocking
|
|
1303
|
+
async def autoscaler_info(function_identifier: str, json: bool = False, env: str | None = None):
|
|
1304
|
+
"""Show the current Autsocaling settings for the provided Function.
|
|
1305
|
+
|
|
1306
|
+
In addition to the current settings, this command also shows any active overrides to these
|
|
1307
|
+
settings, along with metadata about who overrode the setting, and when. FUNCTION may be a
|
|
1308
|
+
Function ID or a deployed Function name in the form ``APP_NAME/FUNCTION_NAME``.
|
|
1309
|
+
|
|
1310
|
+
If a setting's value is the default and not overridden, its value is omitted in the output.
|
|
1311
|
+
|
|
1312
|
+
Examples:
|
|
1313
|
+
|
|
1314
|
+
Show autoscaling settings for a deployed Function:
|
|
1315
|
+
|
|
1316
|
+
```
|
|
1317
|
+
modal function autoscaler settings my-app/my-function
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
Show autoscaling settings using a Function ID:
|
|
1321
|
+
|
|
1322
|
+
```
|
|
1323
|
+
modal function autoscaler settings fu-1234
|
|
1324
|
+
```
|
|
1325
|
+
"""
|
|
1326
|
+
|
|
1327
|
+
environment_name = _get_environment_name(ensure_env(env))
|
|
1328
|
+
client = await _Client.from_env()
|
|
1329
|
+
function_id, handle_metadata, _ = await _resolve_function_id(
|
|
1330
|
+
client,
|
|
1331
|
+
function_identifier,
|
|
1332
|
+
environment_name,
|
|
1333
|
+
object_type="Function",
|
|
1334
|
+
command="autoscaler settings",
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
if not handle_metadata.base_function_id and len(handle_metadata.class_parameter_info.schema) > 0:
|
|
1338
|
+
raise ValueError(
|
|
1339
|
+
f"`{function_identifier}` is a modal.Cls with parameters. Provide a specific "
|
|
1340
|
+
"parameterization via a Function ID. See `modal function variants`."
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
autoscaler_response = await client._stub.FunctionGetSchedulingParams(
|
|
1344
|
+
api_pb2.FunctionGetSchedulingParamsRequest(function_id=function_id)
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
autoscaler_settings = FunctionAutoscalerSettings._from_proto(autoscaler_response.autoscaler_configuration.settings)
|
|
1348
|
+
override_events = autoscaler_response.autoscaler_configuration.override_events
|
|
1349
|
+
|
|
1350
|
+
output_manager = OutputManager.get()
|
|
1351
|
+
|
|
1352
|
+
if json:
|
|
1353
|
+
current_settings = dataclasses.asdict(autoscaler_settings)
|
|
1354
|
+
|
|
1355
|
+
override_events_json: list[dict[str, str]] = []
|
|
1356
|
+
for k, v in override_events.items():
|
|
1357
|
+
if v.user_id:
|
|
1358
|
+
obj = {
|
|
1359
|
+
"setting": k,
|
|
1360
|
+
"updated_at": str(v.timestamp),
|
|
1361
|
+
"actor_name": v.requested_by,
|
|
1362
|
+
"actor_id": v.user_id,
|
|
1363
|
+
"actor_type": "user",
|
|
1364
|
+
}
|
|
1365
|
+
elif v.service_user_id:
|
|
1366
|
+
obj = {
|
|
1367
|
+
"setting": k,
|
|
1368
|
+
"updated_at": str(v.timestamp),
|
|
1369
|
+
"actor_name": v.requested_by,
|
|
1370
|
+
"actor_id": v.service_user_id,
|
|
1371
|
+
"actor_type": "service-user",
|
|
1372
|
+
}
|
|
1373
|
+
else:
|
|
1374
|
+
obj = {
|
|
1375
|
+
"setting": k,
|
|
1376
|
+
"updated_at": str(v.timestamp),
|
|
1377
|
+
"actor_id": "unknown",
|
|
1378
|
+
"actor_name": "unknown",
|
|
1379
|
+
"actor_type": "unknown",
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
override_events_json.append(obj)
|
|
1383
|
+
|
|
1384
|
+
output_manager.print_json(
|
|
1385
|
+
json_lib.dumps(
|
|
1386
|
+
{
|
|
1387
|
+
"current_settings": current_settings,
|
|
1388
|
+
"override_events": override_events_json,
|
|
1389
|
+
}
|
|
1390
|
+
)
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
return
|
|
1394
|
+
|
|
1395
|
+
human_readable = {
|
|
1396
|
+
"min_containers": "Min Containers",
|
|
1397
|
+
"max_containers": "Max Containers",
|
|
1398
|
+
"buffer_containers": "Buffer Containers",
|
|
1399
|
+
"scaledown_window": "Scaledown Window",
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if override_events:
|
|
1403
|
+
t = Table("Setting", "Value", "Overridden By", box=SIMPLE)
|
|
1404
|
+
else:
|
|
1405
|
+
t = Table("Setting", "Value", box=SIMPLE)
|
|
1406
|
+
|
|
1407
|
+
for key, pretty in human_readable.items():
|
|
1408
|
+
value = getattr(autoscaler_settings, key)
|
|
1409
|
+
value_str = str(value)
|
|
1410
|
+
|
|
1411
|
+
override_meta = "-"
|
|
1412
|
+
if key in override_events:
|
|
1413
|
+
override = override_events[key]
|
|
1414
|
+
override_meta = f"{override.requested_by} at {timestamp_to_localized_str(override.timestamp, isotz=False)}"
|
|
1415
|
+
|
|
1416
|
+
if key == "scaledown_window":
|
|
1417
|
+
value_str = f"{value} seconds"
|
|
1418
|
+
|
|
1419
|
+
if key not in override_events and not value:
|
|
1420
|
+
value_str = "-"
|
|
1421
|
+
|
|
1422
|
+
if override_events:
|
|
1423
|
+
t.add_row(pretty, value_str, override_meta)
|
|
1424
|
+
else:
|
|
1425
|
+
t.add_row(pretty, value_str)
|
|
1426
|
+
|
|
1427
|
+
output_manager.print(t)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
@autoscaler_cli.command("update", no_args_is_help=True)
|
|
1431
|
+
@click.argument("function_identifier", metavar="FUNCTION")
|
|
1432
|
+
@click.option("--max-containers", "max_containers", default=None, help="Maximum concurrent containers.")
|
|
1433
|
+
@click.option("--min-containers", "min_containers", default=None, help="Minimum number of containers to keep running.")
|
|
1434
|
+
@click.option(
|
|
1435
|
+
"--buffer-containers",
|
|
1436
|
+
"buffer_containers",
|
|
1437
|
+
default=None,
|
|
1438
|
+
help="Extra containers to keep warm beyond current demand.",
|
|
1439
|
+
)
|
|
1440
|
+
@click.option(
|
|
1441
|
+
"--scaledown-window",
|
|
1442
|
+
"scaledown_window",
|
|
1443
|
+
default=None,
|
|
1444
|
+
help="Maximum duration (in seconds) idle containers wait before scaling down.",
|
|
1445
|
+
)
|
|
1446
|
+
@env_option
|
|
1447
|
+
@synchronizer.create_blocking
|
|
1448
|
+
async def autoscaler_update(
|
|
1449
|
+
function_identifier: str,
|
|
1450
|
+
max_containers: str | None = None,
|
|
1451
|
+
min_containers: str | None = None,
|
|
1452
|
+
buffer_containers: str | None = None,
|
|
1453
|
+
scaledown_window: str | None = None,
|
|
1454
|
+
env: str | None = None,
|
|
1455
|
+
):
|
|
1456
|
+
"""Update the current Autsocaling settings for the provided Function.
|
|
1457
|
+
|
|
1458
|
+
FUNCTION may be a Function ID or a deployed Function name in the form ``APP_NAME/FUNCTION_NAME``.
|
|
1459
|
+
|
|
1460
|
+
Settings can be modified using the `--max-containers`, `--min-containers`, `--buffer-containers`,
|
|
1461
|
+
and `--scaledown-window` options.
|
|
1462
|
+
|
|
1463
|
+
If provided, Max Containers should be a positive integer, Min/Buffer Containers should be
|
|
1464
|
+
nonnegative integers, and Scaledown window should be a positive integer measured in seconds.
|
|
1465
|
+
|
|
1466
|
+
Examples:
|
|
1467
|
+
|
|
1468
|
+
Update Max Containers for a deployed Function:
|
|
1469
|
+
|
|
1470
|
+
```
|
|
1471
|
+
modal function autoscaler update my-app/my-function --max-containers 10
|
|
1472
|
+
```
|
|
1473
|
+
|
|
1474
|
+
Update both Min and Buffer Containers for a Function by ID:
|
|
1475
|
+
|
|
1476
|
+
```
|
|
1477
|
+
modal function autoscaler update fu-1234 --min-containers 3 --buffer-containers 2
|
|
1478
|
+
```
|
|
1479
|
+
"""
|
|
1480
|
+
|
|
1481
|
+
environment_name = _get_environment_name(ensure_env(env))
|
|
1482
|
+
client = await _Client.from_env()
|
|
1483
|
+
|
|
1484
|
+
updates: dict[str, int] = {}
|
|
1485
|
+
for k, v in {
|
|
1486
|
+
"min_containers": min_containers,
|
|
1487
|
+
"max_containers": max_containers,
|
|
1488
|
+
"buffer_containers": buffer_containers,
|
|
1489
|
+
"scaledown_window": scaledown_window,
|
|
1490
|
+
}.items():
|
|
1491
|
+
if v is None:
|
|
1492
|
+
continue
|
|
1493
|
+
|
|
1494
|
+
try:
|
|
1495
|
+
updates[k] = parse_int(
|
|
1496
|
+
v,
|
|
1497
|
+
allow_negative=False,
|
|
1498
|
+
allow_zero=k in {"min_containers", "buffer_containers"},
|
|
1499
|
+
)
|
|
1500
|
+
except ValueError as e:
|
|
1501
|
+
option_name = f"--{k.replace('_', '-')}"
|
|
1502
|
+
raise click.UsageError(f"Invalid input for {option_name!r}: {e}")
|
|
1503
|
+
|
|
1504
|
+
output_manager = OutputManager.get()
|
|
1505
|
+
if not updates:
|
|
1506
|
+
output_manager.print("No autoscaler settings specified.")
|
|
1507
|
+
return
|
|
1508
|
+
|
|
1509
|
+
function_id, handle_metadata, _ = await _resolve_function_id(
|
|
1510
|
+
client,
|
|
1511
|
+
function_identifier,
|
|
1512
|
+
environment_name,
|
|
1513
|
+
object_type="Function",
|
|
1514
|
+
command="autoscaler update",
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
if not handle_metadata.base_function_id and len(handle_metadata.class_parameter_info.schema) > 0:
|
|
1518
|
+
raise ValueError(
|
|
1519
|
+
f"`{function_identifier}` is a modal.Cls with parameters. Provide a specific "
|
|
1520
|
+
"parameterization via a Function ID. See `modal function variants`."
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
f: _Function = _Function._new_hydrated(function_id, client, handle_metadata)
|
|
1524
|
+
await f.update_autoscaler(**updates)
|
|
1525
|
+
|
|
1526
|
+
output_manager.print(f"[green]✓[/green] Updated autoscaler settings for Function {handle_metadata.function_name}")
|
|
1527
|
+
|
|
1528
|
+
|
|
1291
1529
|
@function_cli.command("metrics", no_args_is_help=True)
|
|
1292
1530
|
@click.argument("identifier", metavar="FUNCTION")
|
|
1293
1531
|
@click.option("--since", help="Start time: ISO 8601 or relative duration (e.g. 2h). Naive times are local.")
|
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,
|