Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
761c08d
Adjust tests for how we want it to work
freider Oct 13, 2025
fe730c6
Claude first pass
freider Oct 14, 2025
85613dd
Claude second pass
freider Oct 14, 2025
12f923e
Claude third pass
freider Oct 14, 2025
9636c3e
Claude fourth pass
freider Oct 14, 2025
2a688c6
wip
freider Oct 14, 2025
8df8a97
Most tests passing
freider Oct 14, 2025
a2cef05
test fixes
freider Oct 14, 2025
72f72a8
Remaining edge case to fix is to not make 'conditionally lazy' loader…
freider Oct 14, 2025
383f2cf
Make _from_loader always explicitly provide context overrides
freider Oct 15, 2025
4073520
Merge remote-tracking branch 'origin/main' into freider/remove-unavoi…
freider Oct 15, 2025
b151f64
Forgot file
freider Oct 15, 2025
40d2b6b
types
freider Oct 15, 2025
00de1d6
Fix volume
freider Oct 15, 2025
97fc242
Skip enforcement of client injection in a couple of edge cases
freider Oct 15, 2025
21b9e0e
More fix
freider Oct 15, 2025
5e577b9
Renames
freider Oct 15, 2025
24acd45
Cleanup
freider Oct 15, 2025
1652204
fix
freider Oct 15, 2025
97a9f80
types
freider Oct 15, 2025
46af298
More fixes
freider Oct 15, 2025
8d47cd2
Proxy fix
freider Oct 15, 2025
bcf376c
Remove unnecessary assertions
freider Oct 15, 2025
c324d25
Type
freider Oct 15, 2025
38ae900
Make cls.with_* actually lazy
freider Oct 17, 2025
98bc369
remove set_env_client from a bunch
freider Oct 17, 2025
333cf74
Remove set_env_client from almost all tests
freider Oct 17, 2025
ea83ede
Comments
freider Oct 17, 2025
3b60952
Merge branch 'main' into freider/remove-unavoidable-envclient
freider Oct 27, 2025
fe10c23
Import LoadContext at top
freider Oct 27, 2025
f6da666
Adjusting some stuff
freider Oct 29, 2025
130d560
Reset root context whenever starting an app anew
freider Nov 7, 2025
baf2cad
Merge remote-tracking branch 'origin/main' into freider/remove-unavoi…
freider Nov 7, 2025
c5012df
Renames
freider Nov 7, 2025
e84061d
Final? test fix
freider Nov 7, 2025
03921ce
Fix types
freider Nov 7, 2025
4d6a6fe
App.lookup fix
freider Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 69 additions & 37 deletions modal/_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
from modal_proto import api_pb2
from modal_proto.modal_api_grpc import ModalClientModal

from ._object import _get_environment_name, _Object, live_method, live_method_gen
from ._load_context import LoadContext
from ._object import _Object, live_method, live_method_gen
from ._pty import get_pty_info
from ._resolver import Resolver
from ._resources import convert_fn_config_to_resources_config
Expand Down Expand Up @@ -656,7 +657,7 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
@staticmethod
def from_local(
info: FunctionInfo,
app,
app: Optional["modal.app._App"], # App here should only be None in case of Image.run_function
image: _Image,
env: Optional[dict[str, Optional[str]]] = None,
secrets: Optional[Collection[_Secret]] = None,
Expand Down Expand Up @@ -882,12 +883,12 @@ def _deps(only_explicit_mounts=False) -> list[_Object]:
is_web_endpoint, is_generator, restrict_output
)

async def _preload(self: _Function, resolver: Resolver, existing_object_id: Optional[str]):
assert resolver.client and resolver.client.stub

assert resolver.app_id
async def _preload(
self: _Function, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
):
assert load_context.app_id
req = api_pb2.FunctionPrecreateRequest(
app_id=resolver.app_id,
app_id=load_context.app_id,
function_name=info.function_name,
function_type=function_type,
existing_function_id=existing_object_id or "",
Expand All @@ -903,11 +904,12 @@ async def _preload(self: _Function, resolver: Resolver, existing_object_id: Opti
elif webhook_config:
req.webhook_config.CopyFrom(webhook_config)

response = await resolver.client.stub.FunctionPrecreate(req)
self._hydrate(response.function_id, resolver.client, response.handle_metadata)
response = await load_context.client.stub.FunctionPrecreate(req)
self._hydrate(response.function_id, load_context.client, response.handle_metadata)

async def _load(self: _Function, resolver: Resolver, existing_object_id: Optional[str]):
assert resolver.client and resolver.client.stub
async def _load(
self: _Function, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
):
with FunctionCreationStatus(resolver, tag) as function_creation_status:
timeout_secs = timeout

Expand Down Expand Up @@ -1103,16 +1105,16 @@ async def _load(self: _Function, resolver: Resolver, existing_object_id: Optiona
),
)

assert resolver.app_id
assert load_context.app_id
assert (function_definition is None) != (function_data is None) # xor
request = api_pb2.FunctionCreateRequest(
app_id=resolver.app_id,
app_id=load_context.app_id,
function=function_definition,
function_data=function_data,
existing_function_id=existing_object_id or "",
)
try:
response: api_pb2.FunctionCreateResponse = await resolver.client.stub.FunctionCreate(request)
response: api_pb2.FunctionCreateResponse = await load_context.client.stub.FunctionCreate(request)
except GRPCError as exc:
if exc.status == Status.INVALID_ARGUMENT:
raise InvalidError(exc.message)
Expand All @@ -1127,10 +1129,14 @@ async def _load(self: _Function, resolver: Resolver, existing_object_id: Optiona
serve_mounts = {m for m in all_mounts if m.is_local()}
serve_mounts |= image._serve_mounts
obj._serve_mounts = frozenset(serve_mounts)
self._hydrate(response.function_id, resolver.client, response.handle_metadata)
self._hydrate(response.function_id, load_context.client, response.handle_metadata)

rep = f"Function({tag})"
obj = _Function._from_loader(_load, rep, preload=_preload, deps=_deps)
# Pass a *reference* to the App's LoadContext - this is important since the App is
# the only way to infer a LoadContext for an `@app.function`, and the App doesn't
# get its client until *after* the Function is created.
load_context = app._root_load_context if app else LoadContext.empty()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: LoadContext Sharing Breaks When App Is None

When app is None (e.g., for Image.run_function), each Function gets a separate LoadContext.empty() instance instead of sharing a reference. This breaks the intended reference-sharing behavior described in the comment above, where the LoadContext should be passed by reference so it can be updated in place. For Image.run_function scenarios where multiple functions might be created without an app, they won't share the same LoadContext instance, preventing coordinated updates to client/environment settings.

Fix in Cursor Fix in Web

obj = _Function._from_loader(_load, rep, preload=_preload, deps=_deps, load_context_overrides=load_context)

obj._raw_f = info.raw_f
obj._info = info
Expand Down Expand Up @@ -1172,7 +1178,12 @@ def _bind_parameters(

parent = self

async def _load(param_bound_func: _Function, resolver: Resolver, existing_object_id: Optional[str]):
async def _load(
param_bound_func: _Function,
resolver: Resolver,
load_context: LoadContext,
existing_object_id: Optional[str],
):
if not parent.is_hydrated:
# While the base Object.hydrate() method appears to be idempotent, it's not always safe
await parent.hydrate()
Expand Down Expand Up @@ -1205,7 +1216,6 @@ async def _load(param_bound_func: _Function, resolver: Resolver, existing_object
param_bound_func._hydrate_from_other(parent)
return

environment_name = _get_environment_name(None, resolver)
assert parent is not None and parent.is_hydrated

if options:
Expand Down Expand Up @@ -1245,7 +1255,7 @@ async def _load(param_bound_func: _Function, resolver: Resolver, existing_object
function_id=parent.object_id,
serialized_params=serialized_params,
function_options=options_pb,
environment_name=environment_name
environment_name=load_context.environment_name
or "", # TODO: investigate shouldn't environment name always be specified here?
)

Expand All @@ -1262,7 +1272,13 @@ def _deps():
return [dep for dep in all_deps if not dep.is_hydrated]
return []

fun: _Function = _Function._from_loader(_load, "Function(parametrized)", hydrate_lazily=True, deps=_deps)
fun: _Function = _Function._from_loader(
_load,
"Function(parametrized)",
hydrate_lazily=True,
deps=_deps,
load_context_overrides=self._load_context_overrides,
)

fun._info = self._info
fun._obj = obj
Expand Down Expand Up @@ -1360,34 +1376,43 @@ def _from_name(
cls,
app_name: str,
name: str,
namespace=None, # mdmd:line-hidden
environment_name: Optional[str] = None,
*,
load_context_overrides: LoadContext,
):
# internal function lookup implementation that allows lookup of class "service functions"
# in addition to non-class functions
async def _load_remote(self: _Function, resolver: Resolver, existing_object_id: Optional[str]):
assert resolver.client and resolver.client.stub
async def _load_remote(
self: _Function, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
):
request = api_pb2.FunctionGetRequest(
app_name=app_name,
object_tag=name,
environment_name=_get_environment_name(environment_name, resolver) or "",
environment_name=load_context.environment_name,
)
try:
response = await resolver.client.stub.FunctionGet(request)
response = await load_context.client.stub.FunctionGet(request)
except NotFoundError as exc:
# refine the error message
env_context = f" (in the '{environment_name}' environment)" if environment_name else ""
env_context = (
f" (in the '{load_context.environment_name}' environment)" if load_context.environment_name else ""
)
raise NotFoundError(
f"Lookup failed for Function '{name}' from the '{app_name}' app{env_context}: {exc}."
) from None

print_server_warnings(response.server_warnings)

self._hydrate(response.function_id, resolver.client, response.handle_metadata)
self._hydrate(response.function_id, load_context.client, response.handle_metadata)

environment_rep = f", environment_name={environment_name!r}" if environment_name else ""
environment_rep = (
f", environment_name={load_context_overrides.environment_name!r}"
if load_context_overrides._environment_name # slightly ugly - checking if _environment_name is overridden
else ""
)
rep = f"modal.Function.from_name('{app_name}', '{name}'{environment_rep})"
return cls._from_loader(_load_remote, rep, is_another_app=True, hydrate_lazily=True)
return cls._from_loader(
_load_remote, rep, is_another_app=True, hydrate_lazily=True, load_context_overrides=load_context_overrides
)

@classmethod
def from_name(
Expand All @@ -1397,6 +1422,7 @@ def from_name(
*,
namespace=None, # mdmd:line-hidden
environment_name: Optional[str] = None,
client: Optional[_Client] = None,
) -> "_Function":
"""Reference a Function from a deployed App by its name.

Expand All @@ -1420,7 +1446,9 @@ def from_name(
)

warn_if_passing_namespace(namespace, "modal.Function.from_name")
return cls._from_name(app_name, name, environment_name=environment_name)
return cls._from_name(
app_name, name, load_context_overrides=LoadContext(environment_name=environment_name, client=client)
)

@property
def tag(self) -> str:
Expand Down Expand Up @@ -2018,16 +2046,20 @@ async def from_id(function_call_id: str, client: Optional[_Client] = None) -> "_
if you no longer have access to the original object returned from `Function.spawn`.

"""
if client is None:
client = await _Client.from_env()

async def _load(self: _FunctionCall, resolver: Resolver, existing_object_id: Optional[str]):
async def _load(
self: _FunctionCall, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
):
request = api_pb2.FunctionCallFromIdRequest(function_call_id=function_call_id)
resp = await resolver.client.stub.FunctionCallFromId(request)
self._hydrate(function_call_id, resolver.client, resp)
resp = await load_context.client.stub.FunctionCallFromId(request)
self._hydrate(function_call_id, load_context.client, resp)

rep = f"FunctionCall.from_id({function_call_id!r})"
fc: _FunctionCall[Any] = _FunctionCall._from_loader(_load, rep, hydrate_lazily=True)
fc: _FunctionCall[Any] = _FunctionCall._from_loader(
_load, rep, hydrate_lazily=True, load_context_overrides=LoadContext(client=client)
)

# TODO(elias): Remove these
# We already know the object ID, so we can set it directly
fc._object_id = function_call_id
fc._client = client
Expand Down
105 changes: 105 additions & 0 deletions modal/_load_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Copyright Modal Labs 2025
from typing import Optional

from .client import _Client
from .config import config


class LoadContext:
"""Encapsulates optional metadata values used during object loading.

This metadata is set during object construction and propagated through
parent-child relationships (e.g., App -> Function, Cls -> Obj -> bound methods).
"""

_client: Optional[_Client] = None
Copy link
Contributor

@thomasjpfan thomasjpfan Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a glance, it looks like LoadContext is used as a "builder" to combine contexts. Can we have LoadContext.apply_defaults return a ResolvedContext, where the parameters are not None:

@dataclass
class ResolvedContext:
   client: _Client
   environment_name: str
   app_id: Optiona[str]

I would even rename LoadContext into ContextBuilder. With ResolvedContext, we can be sure that the client is defined with the type system.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that could be nice!

One argument against would be if we think ahead to when the Resolver doesn't exist anymore and all contexts are just propagated from their "parents" explicitly (instead of using deps). In that scenario, I don't think we want to apply_default() anymore, and instead just apply each property as it's needed from within the loader (if it's not already set on the load_context_overrides.

Making each property resolve individually would fix the case where we have loaders that may raise exceptions immediately without actually needing a functional context (like calling methods directly on un-instantiated classes). Maybe we should actually already make that change, e.g. having LoadContext have async def get_client() -> Client etc. so that loaders use those (even though we currently have to make the resolver apply the defaults first due to deps...)

_environment_name: Optional[str] = None
_app_id: Optional[str] = None

def __init__(
self,
*,
client: Optional[_Client] = None,
environment_name: Optional[str] = None,
app_id: Optional[str] = None,
):
self._client = client
self._environment_name = environment_name
self._app_id = app_id

@property
def client(self) -> _Client:
assert self._client is not None
return self._client

@property
def environment_name(self) -> str:
assert self._environment_name is not None
return self._environment_name

@property
def app_id(self) -> Optional[str]:
return self._app_id

@classmethod
def empty(cls) -> "LoadContext":
"""Create an empty LoadContext with all fields set to None.

Used when loading objects that don't have a parent context.
"""
return cls(client=None, environment_name=None, app_id=None)

def merged_with(self, parent: "LoadContext") -> "LoadContext":
"""Create a new LoadContext with parent values filling in None fields.

Returns a new LoadContext without mutating self or parent.
Values from self take precedence over values from parent.
"""
return LoadContext(
client=self._client if self._client is not None else parent._client,
environment_name=self._environment_name if self._environment_name is not None else parent._environment_name,
app_id=self._app_id if self._app_id is not None else parent._app_id,
) # TODO (elias): apply_defaults?

async def apply_defaults(self) -> "LoadContext":
"""Infer default client and environment_name if not present

Returns a new instance (no in place mutation)"""

return LoadContext(
client=await _Client.from_env() if self._client is None else self.client,
environment_name=config.get("environment") if self._environment_name is None else self._environment_name,
app_id=self._app_id,
)

def reset(self) -> "LoadContext":
self._client = None
self._environment_name = None
self._app_id = None
return self

async def in_place_upgrade(
self, client: Optional[_Client] = None, environment_name: Optional[str] = None, app_id: Optional[str] = None
) -> "LoadContext":
"""In-place set values if they aren't already set, or set default values

Intended for Function/Cls hydration specifically

In those cases, it's important to in-place upgrade/apply_defaults since any "sibling" of the function/cls
would share the load context with its parent, and the initial load context overrides may not be sufficient
since an `app.deploy()` etc could get arguments that set a new client etc.

E.g.
@app.function()
def f():
...

f2 = Function.with_options(...)

with app.run(client=...): # hydrates f and f2 at this point
...
"""
self._client = self._client or client or await _Client.from_env()
self._environment_name = self._environment_name or environment_name or config.get("environment")
self._app_id = self._app_id or app_id
return self
Loading
Loading