Repository: PrefectHQ/prefect

Issue:
Prefect2.19.4 "AttributeError: 'JoinedStr' object has no attribute 'value'. Did you mean: 'values'?"
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the Prefect documentation for this issue.
- [X] I checked that this issue is related to Prefect and not one of its dependencies.

### Bug summary

I'm trying "prefect deploy --all" for CI/CD without installing all environment only prefect



### Reproduction

```python3
flow.py

...
@flow(
    name=(
        f'{os.getenv("DEPLOYMENT_NAME")}'
        f'-{os.getenv("PREFECT_WORK_POOL_NAME")}'
    ),
    log_prints=True,
)
def spark_kubernetes_flow(
    script_path: str,
    config_path: str,
    object_name: str,
    prefect_deployment_name: str,
) -> None:
    upload_script_to_s3(script_path, object_name)
    create_spark_application(object_name, config_path, prefect_deployment_name)
    monitor_spark_application(prefect_deployment_name)

prefect.yaml

# Generic metadata about this project
name: '{{ $DEPLOYMENT_NAME }}'
prefect-version: '{{ $PREFECT_VERSION }}'

# build section allows you to manage and build docker images
build:
- prefect_docker.deployments.steps.build_docker_image:
    id: build-image
    requires: prefect-kubernetes
    image_name: '{{ $REGISTRY }}/{{ $DEPLOYMENT_NAME }}'
    tag: '{{ $DEPLOYMENT_TAG }}'
    dockerfile: Dockerfile
    additional_tags: [latest]

push:
- prefect_docker.deployments.steps.push_docker_image:
    requires: prefect-kubernetes
    image_name: '{{ build-image.image_name }}'
    tag: '{{ build-image.tag }}'

pull:
- prefect.deployments.steps.set_working_directory:
    directory: /opt/prefect

# the definitions section allows you to define reusable components for your deployments
definitions:
  tags: &common_tags
  - spark
  - '{{ $PREFECT_WORK_POOL_NAME }}'
  work_pool: &common_work_pool
    name: '{{ $PREFECT_WORK_POOL_NAME }}'
    work_queue_name: default
    job_variables:
      image: '{{ build-image.image }}'
  parameters: &parameters
    prefect_deployment_name: '{{ $DEPLOYMENT_NAME }}'
    script_path: /application/{{ $SPARK_FILE_NAME }}
    config_path: /application/spark_application.yaml
    object_name: '{{ $PREFECT_WORK_POOL_NAME }}/{{ $DEPLOYMENT_NAME }}/{{ build-image.tag}}.py'

# the deployments section allows you to provide configuration for deploying flows
deployments:
- name: '{{ $DEPLOYMENT_NAME }}'
  version: '{{ build-image.tag }}'
  description:
  tags: *common_tags
  schedule:
  work_pool: *common_work_pool
      # flow-specific fields
  entrypoint: flows/flow.py:spark_kubernetes_flow
  parameters: *parameters
  schedules: []
```


### Error

```python3
Traceback (most recent call last):
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/cli/_utilities.py", line 42, in wrapper
    return fn(*args, **kwargs)
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/utilities/asyncutils.py", line 304, in coroutine_wrapper
    return call()
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/_internal/concurrency/calls.py", line 432, in __call__
    return self.result()
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/_internal/concurrency/calls.py", line 318, in result
    return self.future.result(timeout=timeout)
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/_internal/concurrency/calls.py", line 179, in result
    return self.__get_result()
  File "/usr/lib/python3.10/concurrent/futures/_base.py", line 403, in __get_result
    raise self._exception
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/_internal/concurrency/calls.py", line 389, in _run_async
    result = await coro
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/cli/deploy.py", line 429, in deploy
    await _run_single_deploy(
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/client/utilities.py", line 100, in with_injected_client
    return await fn(*args, **kwargs)
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/cli/deploy.py", line 479, in _run_single_deploy
    deploy_config["flow_name"] = load_flow_argument_from_entrypoint(
  File "/home/dmitry/projects/spark-prefect-template/.venv/lib/python3.10/site-packages/prefect/flows.py", line 1847, in load_flow_argument_from_entrypoint
    keyword.value.value
AttributeError: 'JoinedStr' object has no attribute 'value'. Did you mean: 'values'?
```


### Versions

```Text
pyproject.toml

[tool.poetry]
name = "spark"
version = "2.0.0"
description = ""
authors = ["Nesmeyanov Dmitry <nesmeyanov.dv@avgd.pro>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.10"
kubernetes = "<30.0.0"
boto3 = "^1.34.121"
pyyaml = "^6.0.1"

[tool.poetry.group.lint.dependencies]
isort = "^5.12.0"
black = "^24.3.0"
flake8 = "^6.1.0"
flake8-annotations = "^3.0.1"
mypy = "^1.7.0"
pylint = "^3.0.2"

[tool.poetry.group.addons.dependencies]
pre-commit = "^3.7.1"

[tool.poetry.group.build.dependencies]
requests = "^2.32.3"
prefect = "^2.19.4"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"


[tool.black]
line-length = 80
```


### Additional context

_No response_


Discussion:
Hi @smeyanoff, thanks for filing this issue. I can repro with the following simplified example on `prefect==2.19.4`  and `prefect==3.0.0rc2`. 

```python
# my_flow.py
import os
from prefect import flow

@flow(name=f'{os.getenv("FLOW_NAME")}')
def my_flow():
    pass
```    
```
prefect deploy my_flow.py:my_flow
```

I've marked it as high priority.

Resolving patch:
diff --git a/src/prefect/flows.py b/src/prefect/flows.py
--- a/src/prefect/flows.py
+++ b/src/prefect/flows.py
@@ -102,7 +102,7 @@
 from prefect.utilities.collections import listrepr
 from prefect.utilities.filesystem import relative_path_to_current_platform
 from prefect.utilities.hashing import file_hash
-from prefect.utilities.importtools import import_object
+from prefect.utilities.importtools import import_object, safe_load_namespace
 
 from ._internal.pydantic.v2_schema import is_v2_type
 from ._internal.pydantic.v2_validated_func import V2ValidatedFunction
@@ -1933,11 +1933,33 @@ def load_flow_argument_from_entrypoint(
         ):
             for keyword in decorator.keywords:
                 if keyword.arg == arg:
-                    return (
-                        keyword.value.value
-                    )  # Return the string value of the argument
+                    if isinstance(keyword.value, ast.Constant):
+                        return (
+                            keyword.value.value
+                        )  # Return the string value of the argument
+
+                    # if the arg value is not a raw str (i.e. a variable or expression),
+                    # then attempt to evaluate it
+                    namespace = safe_load_namespace(source_code)
+                    literal_arg_value = ast.get_source_segment(
+                        source_code, keyword.value
+                    )
+                    try:
+                        evaluated_value = eval(literal_arg_value, namespace)  # type: ignore
+                    except Exception as e:
+                        logger.info(
+                            "Failed to parse @flow argument: `%s=%s` due to the following error. Ignoring and falling back to default behavior.",
+                            arg,
+                            literal_arg_value,
+                            exc_info=e,
+                        )
+                        # ignore the decorator arg and fallback to default behavior
+                        break
+                    return str(evaluated_value)
 
     if arg == "name":
         return func_name.replace(
             "_", "-"
         )  # If no matching decorator or keyword argument is found
+
+    return None


Test patch:
diff --git a/tests/test_flows.py b/tests/test_flows.py
--- a/tests/test_flows.py
+++ b/tests/test_flows.py
@@ -4166,6 +4166,76 @@ def flow_function(name: str) -> str:
 
         assert result == "flow-function"
 
+    def test_load_flow_name_from_entrypoint_dynamic_name_fstring(self, tmp_path: Path):
+        flow_source = dedent(
+            """
+
+        from prefect import flow
+
+        version = "1.0"
+
+        @flow(name=f"flow-function-{version}")
+        def flow_function(name: str) -> str:
+            return name
+        """
+        )
+
+        tmp_path.joinpath("flow.py").write_text(flow_source)
+
+        entrypoint = f"{tmp_path.joinpath('flow.py')}:flow_function"
+
+        result = load_flow_argument_from_entrypoint(entrypoint, "name")
+
+        assert result == "flow-function-1.0"
+
+    def test_load_flow_name_from_entrypoint_dyanmic_name_function(self, tmp_path: Path):
+        flow_source = dedent(
+            """
+
+        from prefect import flow
+
+        def get_name():
+            return "from-a-function"
+
+        @flow(name=get_name())
+        def flow_function(name: str) -> str:
+            return name
+        """
+        )
+
+        tmp_path.joinpath("flow.py").write_text(flow_source)
+
+        entrypoint = f"{tmp_path.joinpath('flow.py')}:flow_function"
+
+        result = load_flow_argument_from_entrypoint(entrypoint, "name")
+
+        assert result == "from-a-function"
+
+    def test_load_flow_name_from_entrypoint_dynamic_name_depends_on_missing_import(
+        self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+    ):
+        flow_source = dedent(
+            """
+
+        from prefect import flow
+
+        from non_existent import get_name
+
+        @flow(name=get_name())
+        def flow_function(name: str) -> str:
+            return name
+        """
+        )
+
+        tmp_path.joinpath("flow.py").write_text(flow_source)
+
+        entrypoint = f"{tmp_path.joinpath('flow.py')}:flow_function"
+
+        result = load_flow_argument_from_entrypoint(entrypoint, "name")
+
+        assert result == "flow-function"
+        assert "Failed to parse @flow argument: `name=get_name()`" in caplog.text
+
     def test_load_async_flow_from_entrypoint_no_name(self, tmp_path: Path):
         flow_source = dedent(
             """