Skip to content

Commit 15274df

Browse files
authored
Add support for custom flow decorators to prefect deploy (#14782)
1 parent 067cbc6 commit 15274df

File tree

10 files changed

+682
-84
lines changed

10 files changed

+682
-84
lines changed

src/prefect/cli/deploy.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@
6565
from prefect.deployments.steps.core import run_steps
6666
from prefect.events import DeploymentTriggerTypes, TriggerTypes
6767
from prefect.exceptions import ObjectNotFound, PrefectHTTPStatusError
68-
from prefect.flows import load_flow_arguments_from_entrypoint
68+
from prefect.flows import load_flow_from_entrypoint
6969
from prefect.settings import (
7070
PREFECT_DEFAULT_WORK_POOL_NAME,
7171
PREFECT_UI_URL,
7272
)
7373
from prefect.utilities.annotations import NotSet
7474
from prefect.utilities.callables import (
75-
parameter_schema_from_entrypoint,
75+
parameter_schema,
7676
)
7777
from prefect.utilities.collections import get_from_dict
7878
from prefect.utilities.slugify import slugify
@@ -481,20 +481,17 @@ async def _run_single_deploy(
481481
)
482482
deploy_config["entrypoint"] = await prompt_entrypoint(app.console)
483483

484-
flow_decorator_arguments = load_flow_arguments_from_entrypoint(
485-
deploy_config["entrypoint"]
486-
)
484+
flow = load_flow_from_entrypoint(deploy_config["entrypoint"])
485+
486+
deploy_config["flow_name"] = flow.name
487487

488-
deploy_config["flow_name"] = flow_decorator_arguments["name"]
489488
deployment_name = deploy_config.get("name")
490489
if not deployment_name:
491490
if not is_interactive():
492491
raise ValueError("A deployment name must be provided.")
493492
deploy_config["name"] = prompt("Deployment name", default="default")
494493

495-
deploy_config["parameter_openapi_schema"] = parameter_schema_from_entrypoint(
496-
deploy_config["entrypoint"]
497-
)
494+
deploy_config["parameter_openapi_schema"] = parameter_schema(flow)
498495

499496
deploy_config["schedules"] = _construct_schedules(
500497
deploy_config,
@@ -675,7 +672,7 @@ async def _run_single_deploy(
675672
deploy_config["work_pool"]["job_variables"]["image"] = "{{ build-image.image }}"
676673

677674
if not deploy_config.get("description"):
678-
deploy_config["description"] = flow_decorator_arguments.get("description")
675+
deploy_config["description"] = flow.description
679676

680677
# save deploy_config before templating
681678
deploy_config_before_templating = deepcopy(deploy_config)

src/prefect/flows.py

Lines changed: 151 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,7 @@ def load_flow_from_entrypoint(
16761676
if ":" in entrypoint:
16771677
# split by the last colon once to handle Windows paths with drive letters i.e C:\path\to\file.py:do_stuff
16781678
path, func_name = entrypoint.rsplit(":", maxsplit=1)
1679+
16791680
else:
16801681
path, func_name = entrypoint.rsplit(".", maxsplit=1)
16811682
try:
@@ -1684,15 +1685,13 @@ def load_flow_from_entrypoint(
16841685
raise MissingFlowError(
16851686
f"Flow function with name {func_name!r} not found in {path!r}. "
16861687
) from exc
1687-
except ScriptError as exc:
1688+
except ScriptError:
16881689
# If the flow has dependencies that are not installed in the current
1689-
# environment, fallback to loading the flow via AST parsing. The
1690-
# drawback of this approach is that we're unable to actually load the
1691-
# function, so we create a placeholder flow that will re-raise this
1692-
# exception when called.
1693-
1690+
# environment, fallback to loading the flow via AST parsing.
16941691
if use_placeholder_flow:
1695-
flow = load_placeholder_flow(entrypoint=entrypoint, raises=exc)
1692+
flow = safe_load_flow_from_entrypoint(entrypoint)
1693+
if flow is None:
1694+
raise
16961695
else:
16971696
raise
16981697

@@ -1855,6 +1854,147 @@ async def async_placeholder_flow(*args, **kwargs):
18551854
return Flow(**arguments)
18561855

18571856

1857+
def safe_load_flow_from_entrypoint(entrypoint: str) -> Optional[Flow]:
1858+
"""
1859+
Load a flow from an entrypoint and return None if an exception is raised.
1860+
1861+
Args:
1862+
entrypoint: a string in the format `<path_to_script>:<flow_func_name>`
1863+
or a module path to a flow function
1864+
"""
1865+
func_def, source_code = _entrypoint_definition_and_source(entrypoint)
1866+
path = None
1867+
if ":" in entrypoint:
1868+
path = entrypoint.rsplit(":")[0]
1869+
namespace = safe_load_namespace(source_code, filepath=path)
1870+
if func_def.name in namespace:
1871+
return namespace[func_def.name]
1872+
else:
1873+
# If the function is not in the namespace, if may be due to missing dependencies
1874+
# for the function. We will attempt to compile each annotation and default value
1875+
# and remove them from the function definition to see if the function can be
1876+
# compiled without them.
1877+
1878+
return _sanitize_and_load_flow(func_def, namespace)
1879+
1880+
1881+
def _sanitize_and_load_flow(
1882+
func_def: Union[ast.FunctionDef, ast.AsyncFunctionDef], namespace: Dict[str, Any]
1883+
) -> Optional[Flow]:
1884+
"""
1885+
Attempt to load a flow from the function definition after sanitizing the annotations
1886+
and defaults that can't be compiled.
1887+
1888+
Args:
1889+
func_def: the function definition
1890+
namespace: the namespace to load the function into
1891+
1892+
Returns:
1893+
The loaded function or None if the function can't be loaded
1894+
after sanitizing the annotations and defaults.
1895+
"""
1896+
args = func_def.args.posonlyargs + func_def.args.args + func_def.args.kwonlyargs
1897+
if func_def.args.vararg:
1898+
args.append(func_def.args.vararg)
1899+
if func_def.args.kwarg:
1900+
args.append(func_def.args.kwarg)
1901+
# Remove annotations that can't be compiled
1902+
for arg in args:
1903+
if arg.annotation is not None:
1904+
try:
1905+
code = compile(
1906+
ast.Expression(arg.annotation),
1907+
filename="<ast>",
1908+
mode="eval",
1909+
)
1910+
exec(code, namespace)
1911+
except Exception as e:
1912+
logger.debug(
1913+
"Failed to evaluate annotation for argument %s due to the following error. Ignoring annotation.",
1914+
arg.arg,
1915+
exc_info=e,
1916+
)
1917+
arg.annotation = None
1918+
1919+
# Remove defaults that can't be compiled
1920+
new_defaults = []
1921+
for default in func_def.args.defaults:
1922+
try:
1923+
code = compile(ast.Expression(default), "<ast>", "eval")
1924+
exec(code, namespace)
1925+
new_defaults.append(default)
1926+
except Exception as e:
1927+
logger.debug(
1928+
"Failed to evaluate default value %s due to the following error. Ignoring default.",
1929+
default,
1930+
exc_info=e,
1931+
)
1932+
new_defaults.append(
1933+
ast.Constant(
1934+
value=None, lineno=default.lineno, col_offset=default.col_offset
1935+
)
1936+
)
1937+
func_def.args.defaults = new_defaults
1938+
1939+
# Remove kw_defaults that can't be compiled
1940+
new_kw_defaults = []
1941+
for default in func_def.args.kw_defaults:
1942+
if default is not None:
1943+
try:
1944+
code = compile(ast.Expression(default), "<ast>", "eval")
1945+
exec(code, namespace)
1946+
new_kw_defaults.append(default)
1947+
except Exception as e:
1948+
logger.debug(
1949+
"Failed to evaluate default value %s due to the following error. Ignoring default.",
1950+
default,
1951+
exc_info=e,
1952+
)
1953+
new_kw_defaults.append(
1954+
ast.Constant(
1955+
value=None,
1956+
lineno=default.lineno,
1957+
col_offset=default.col_offset,
1958+
)
1959+
)
1960+
else:
1961+
new_kw_defaults.append(
1962+
ast.Constant(
1963+
value=None,
1964+
lineno=func_def.lineno,
1965+
col_offset=func_def.col_offset,
1966+
)
1967+
)
1968+
func_def.args.kw_defaults = new_kw_defaults
1969+
1970+
if func_def.returns is not None:
1971+
try:
1972+
code = compile(
1973+
ast.Expression(func_def.returns), filename="<ast>", mode="eval"
1974+
)
1975+
exec(code, namespace)
1976+
except Exception as e:
1977+
logger.debug(
1978+
"Failed to evaluate return annotation due to the following error. Ignoring annotation.",
1979+
exc_info=e,
1980+
)
1981+
func_def.returns = None
1982+
1983+
# Attempt to compile the function without annotations and defaults that
1984+
# can't be compiled
1985+
try:
1986+
code = compile(
1987+
ast.Module(body=[func_def], type_ignores=[]),
1988+
filename="<ast>",
1989+
mode="exec",
1990+
)
1991+
exec(code, namespace)
1992+
except Exception as e:
1993+
logger.debug("Failed to compile: %s", e)
1994+
else:
1995+
return namespace.get(func_def.name)
1996+
1997+
18581998
def load_flow_arguments_from_entrypoint(
18591999
entrypoint: str, arguments: Optional[Union[List[str], Set[str]]] = None
18602000
) -> Dict[str, Any]:
@@ -1870,6 +2010,9 @@ def load_flow_arguments_from_entrypoint(
18702010
"""
18712011

18722012
func_def, source_code = _entrypoint_definition_and_source(entrypoint)
2013+
path = None
2014+
if ":" in entrypoint:
2015+
path = entrypoint.rsplit(":")[0]
18732016

18742017
if arguments is None:
18752018
# If no arguments are provided default to known arguments that are of
@@ -1905,7 +2048,7 @@ def load_flow_arguments_from_entrypoint(
19052048

19062049
# if the arg value is not a raw str (i.e. a variable or expression),
19072050
# then attempt to evaluate it
1908-
namespace = safe_load_namespace(source_code)
2051+
namespace = safe_load_namespace(source_code, filepath=path)
19092052
literal_arg_value = ast.get_source_segment(source_code, keyword.value)
19102053
cleaned_value = (
19112054
literal_arg_value.replace("\n", "") if literal_arg_value else ""

src/prefect/utilities/callables.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,17 +346,19 @@ def parameter_schema_from_entrypoint(entrypoint: str) -> ParameterSchema:
346346
Returns:
347347
ParameterSchema: The parameter schema for the function.
348348
"""
349+
filepath = None
349350
if ":" in entrypoint:
350351
# split by the last colon once to handle Windows paths with drive letters i.e C:\path\to\file.py:do_stuff
351352
path, func_name = entrypoint.rsplit(":", maxsplit=1)
352353
source_code = Path(path).read_text()
354+
filepath = path
353355
else:
354356
path, func_name = entrypoint.rsplit(".", maxsplit=1)
355357
spec = importlib.util.find_spec(path)
356358
if not spec or not spec.origin:
357359
raise ValueError(f"Could not find module {path!r}")
358360
source_code = Path(spec.origin).read_text()
359-
signature = _generate_signature_from_source(source_code, func_name)
361+
signature = _generate_signature_from_source(source_code, func_name, filepath)
360362
docstring = _get_docstring_from_source(source_code, func_name)
361363
return generate_parameter_schema(signature, parameter_docstrings(docstring))
362364

@@ -424,7 +426,7 @@ def raise_for_reserved_arguments(fn: Callable, reserved_arguments: Iterable[str]
424426

425427

426428
def _generate_signature_from_source(
427-
source_code: str, func_name: str
429+
source_code: str, func_name: str, filepath: Optional[str] = None
428430
) -> inspect.Signature:
429431
"""
430432
Extract the signature of a function from its source code.
@@ -440,7 +442,7 @@ def _generate_signature_from_source(
440442
"""
441443
# Load the namespace from the source code. Missing imports and exceptions while
442444
# loading local class definitions are ignored.
443-
namespace = safe_load_namespace(source_code)
445+
namespace = safe_load_namespace(source_code, filepath=filepath)
444446
# Parse the source code into an AST
445447
parsed_code = ast.parse(source_code)
446448

0 commit comments

Comments
 (0)