Repository: PrefectHQ/prefect

Issue:
Prefect serializes flows based on flow name ignoring project
Hi,

I searched the slack channel and did not find anything that pertained directly to this.  We use Prefect for two departments that have virtually the same flows, but different sources and different processing rules.  What we have found is that flows with the same name overwrite each other regardless of the project.  I am not sure if that is intended or if there is a way to work around this issue.

This is a minimal example that I confirmed in a fresh install of Prefect 0.14.0 running the server backend in docker with a local agent all on the same machine.

```
jaykae@DT-JKNICKERBOCKER: ~: ls -1 prefect_test prefect_test/proj*
prefect_test:
proj1
proj2

prefect_test/proj1:
WriteHello.py

prefect_test/proj2:
WriteHello.py
```
Just a folder with two subfolders, projects 1 and 2, each folder holds a file with nearly the same content.  Note the file paths are different and also the project names are different.

**proj1/WriteHello.py**
```
jaykae@DT-JKNICKERBOCKER: ~/prefect_test cat proj1/WriteHello.py
import prefect
from prefect import task, Flow

@task
def write_hello():
        with open("proj1/hello.txt","w") as hello_file:
                hello_file.write("Hello Project 1!!!")

with Flow("WriteHello") as flow:
        write_it = write_hello()

flow.register(project_name="Project1")
```

**proj2/WriteHello.py**
```
jaykae@DT-JKNICKERBOCKER: ~/prefect_test cat proj2/WriteHello.py
import prefect
from prefect import task, Flow

@task
def write_hello():
        with open("proj2/hello.txt","w") as hello_file:
                hello_file.write("Hello Project 2!!!")

with Flow("WriteHello") as flow:
        write_it = write_hello()

flow.register(project_name="Project2")
```

I register proj1/HelloWorld.py and then proj2/HelloWorld.py, I would expect that registering two flows with different file paths, as well as different project names would lead result in writing a text file in the directory for each flow when it is called.  However, when I run proj1/HelloWorld.py the file is written in the proj2 directory and says Hello Project 2!!!.

```
jaykae@DT-JKNICKERBOCKER: ~/prefect_test ls proj*
proj1:
WriteHello.py

proj2:
WriteHello.py
jaykae@DT-JKNICKERBOCKER: ~/prefect_test prefect run flow --name WriteHello --project Project1
Flow Run: http://localhost:8080/default/flow-run/e05b8f99-945d-4954-ab0d-7e5e89df0965
jaykae@DT-JKNICKERBOCKER: ~/prefect_test ls proj*
proj1:
WriteHello.py

proj2:
WriteHello.py  hello.txt
```

I tried changing the .py file name and that did not help at all, so I assume it has to do with the flow name.  Is there a way to have two flows with the same name, but different content, in separate projects and peacefully coexist?

Comments:

Patch:
diff --git a/changes/pr3923.yaml b/changes/pr3923.yaml
new file mode 100644
index 0000000000..a17d5f3adb
--- /dev/null
+++ b/changes/pr3923.yaml
@@ -0,0 +1,2 @@
+fix:
+  - "Support storing multiple local flows with the same name when using `Local` storage - [#3923](https://github.com/PrefectHQ/prefect/pull/3923)"
diff --git a/src/prefect/storage/local.py b/src/prefect/storage/local.py
index 6f9ef87b44..25f6c3b620 100644
--- a/src/prefect/storage/local.py
+++ b/src/prefect/storage/local.py
@@ -2,6 +2,8 @@ import os
 import socket
 from typing import TYPE_CHECKING, Any, Dict, List
 
+import cloudpickle
+import pendulum
 from slugify import slugify
 
 import prefect
@@ -99,7 +101,8 @@ class Local(Storage):
                 if self.stored_as_script:
                     return extract_flow_from_file(file_path=flow_location)
                 else:
-                    return prefect.core.flow.Flow.load(flow_location)
+                    with open(flow_location, "rb") as f:
+                        return cloudpickle.load(f)
             # otherwise the path is given in the module format
             else:
                 return extract_flow_from_module(module_str=flow_location)
@@ -138,9 +141,13 @@ class Local(Storage):
                 flow_location = self.path
             else:
                 flow_location = os.path.join(
-                    self.directory, "{}.prefect".format(slugify(flow.name))
+                    self.directory,
+                    slugify(flow.name),
+                    slugify(pendulum.now("utc").isoformat()),
                 )
-            flow_location = flow.save(flow_location)
+            os.makedirs(os.path.dirname(flow_location), exist_ok=True)
+            with open(flow_location, "wb") as f:
+                cloudpickle.dump(flow, f)
 
         self.flows[flow.name] = flow_location
         self._flows[flow.name] = flow


Test patch:
diff --git a/tests/storage/test_local_storage.py b/tests/storage/test_local_storage.py
index a9381ac1f3..ea4173fc3b 100644
--- a/tests/storage/test_local_storage.py
+++ b/tests/storage/test_local_storage.py
@@ -1,6 +1,5 @@
 import os
 import socket
-import tempfile
 
 import cloudpickle
 import pytest
@@ -41,21 +40,22 @@ def test_create_local_storage_without_validation():
     assert storage.result.dir == storage.directory
 
 
-def test_add_flow_to_storage():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        storage = Local(directory=tmpdir)
-        f = Flow("test")
-        assert f.name not in storage
+def test_add_flow_to_storage(tmpdir):
+    storage = Local(directory=str(tmpdir))
+    f = Flow("test")
+    assert f.name not in storage
 
-        res = storage.add_flow(f)
-        assert res.endswith("test.prefect")
-        assert f.name in storage
+    res = storage.add_flow(f)
 
-        with open(os.path.join(tmpdir, "test.prefect"), "rb") as f:
-            wat = f.read()
+    flow_dir = os.path.join(tmpdir, "test")
+    assert os.path.exists(flow_dir)
+    assert len(os.listdir(flow_dir)) == 1
+    assert res.startswith(flow_dir)
 
-    assert isinstance(wat, bytes)
-    assert cloudpickle.loads(wat).name == "test"
+    assert f.name in storage
+
+    f2 = storage.get_flow(res)
+    assert f2.name == "test"
 
 
 def test_add_flow_file_to_storage(tmpdir):
@@ -79,14 +79,13 @@ def test_add_flow_file_to_storage(tmpdir):
     assert f.name in storage
 
 
-def test_add_flow_raises_if_name_conflict():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        storage = Local(directory=tmpdir)
-        f = Flow("test")
-        res = storage.add_flow(f)
-        g = Flow("test")
-        with pytest.raises(ValueError, match='name "test"'):
-            storage.add_flow(g)
+def test_add_flow_raises_if_name_conflict(tmpdir):
+    storage = Local(directory=str(tmpdir))
+    f = Flow("test")
+    res = storage.add_flow(f)
+    g = Flow("test")
+    with pytest.raises(ValueError, match='name "test"'):
+        storage.add_flow(g)
 
 
 def test_get_env_runner_raises():
@@ -102,13 +101,12 @@ def test_get_flow_raises_if_flow_not_present():
         s.get_flow("test")
 
 
-def test_get_flow_returns_flow():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        storage = Local(directory=tmpdir)
-        f = Flow("test")
-        loc = storage.add_flow(f)
-        runner = storage.get_flow(loc)
-        assert runner == f
+def test_get_flow_returns_flow(tmpdir):
+    storage = Local(directory=str(tmpdir))
+    f = Flow("test")
+    loc = storage.add_flow(f)
+    runner = storage.get_flow(loc)
+    assert runner == f
 
 
 def test_get_flow_from_file_returns_flow(tmpdir):
@@ -127,81 +125,73 @@ def test_get_flow_from_file_returns_flow(tmpdir):
     assert flow.run()
 
 
-def test_containment():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        s = Local(directory=tmpdir)
-        f = Flow("test")
-        s.add_flow(f)
+def test_containment(tmpdir):
+    s = Local(directory=str(tmpdir))
+    f = Flow("test")
+    s.add_flow(f)
 
-        assert True not in s
-        assert f not in s
-        assert "test" in s
-        assert Flow("other") not in s
-        assert "other" not in s
+    assert True not in s
+    assert f not in s
+    assert "test" in s
+    assert Flow("other") not in s
+    assert "other" not in s
 
 
-def test_build_returns_self():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        s = Local(directory=tmpdir)
-        assert s.build() is s
+def test_build_returns_self(tmpdir):
+    s = Local(directory=str(tmpdir))
+    assert s.build() is s
 
-        f = Flow("test")
-        s.add_flow(f)
-        assert s.build() is s
+    f = Flow("test")
+    s.add_flow(f)
+    assert s.build() is s
 
 
-def test_multiple_flows_in_storage():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        s = Local(directory=tmpdir)
-        f = Flow("test")
-        g = Flow("other")
-        z = Flow("not")
-        f_loc = s.add_flow(f)
-        g_loc = s.add_flow(g)
+def test_multiple_flows_in_storage(tmpdir):
+    s = Local(directory=str(tmpdir))
+    f = Flow("test")
+    g = Flow("other")
+    z = Flow("not")
+    f_loc = s.add_flow(f)
+    g_loc = s.add_flow(g)
 
-        assert "test" in s
-        assert "other" in s
-        assert "not" not in s
+    assert "test" in s
+    assert "other" in s
+    assert "not" not in s
 
-        assert s.get_flow(f_loc) == f
-        assert s.get_flow(g_loc) == g
+    assert s.get_flow(f_loc) == f
+    assert s.get_flow(g_loc) == g
 
-        assert s.flows["test"] == f_loc
-        assert s.flows["other"] == g_loc
+    assert s.flows["test"] == f_loc
+    assert s.flows["other"] == g_loc
 
 
-def test_add_flow_with_weird_name_is_cleaned():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        s = Local(directory=tmpdir)
-        flow = Flow("WELL what do you know?!~? looks like a test!!!!")
-        loc = s.add_flow(flow)
-        assert "?" not in loc
-        assert "!" not in loc
-        assert " " not in loc
-        assert "~" not in loc
+def test_add_flow_with_weird_name_is_cleaned(tmpdir):
+    s = Local(directory=str(tmpdir))
+    flow = Flow("WELL what do you know?!~? looks like a test!!!!")
+    loc = s.add_flow(flow)
+    assert "?" not in loc
+    assert "!" not in loc
+    assert " " not in loc
+    assert "~" not in loc
 
 
-def test_build_healthchecks():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        s = Local(directory=tmpdir)
-        flow = Flow("TestFlow")
-        s.add_flow(flow)
-        assert s.build()
+def test_build_healthchecks(tmpdir):
+    s = Local(directory=str(tmpdir))
+    flow = Flow("TestFlow")
+    s.add_flow(flow)
+    assert s.build()
 
 
-def test_build_healthcheck_returns_on_no_flows():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        s = Local(directory=tmpdir)
-        assert s.build()
+def test_build_healthcheck_returns_on_no_flows(tmpdir):
+    s = Local(directory=str(tmpdir))
+    assert s.build()
 
 
-def test_labels_includes_hostname():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        s = Local(directory=tmpdir)
-        assert socket.gethostname() in s.labels
+def test_labels_includes_hostname(tmpdir):
+    s = Local(directory=str(tmpdir))
+    assert socket.gethostname() in s.labels
 
 
-def test_opt_out_of_hostname_label():
-    with tempfile.TemporaryDirectory() as tmpdir:
-        s = Local(directory=tmpdir, add_default_labels=False)
-        assert socket.gethostname() not in s.labels
+def test_opt_out_of_hostname_label(tmpdir):
+    s = Local(directory=str(tmpdir), add_default_labels=False)
+    assert socket.gethostname() not in s.labels