From 743799a5063025cb750e9754b19366d91fccadb6 Mon Sep 17 00:00:00 2001 From: Prajakta Date: Sat, 11 Jul 2026 09:53:55 +0530 Subject: [PATCH] Fix mutable default argument in get_resource_tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using seen_resources=set() as a default argument is a classic Python bug: the set is created once when the function is defined, not on each call. All calls share the same set object. This causes resources visited in one call to be silently skipped in subsequent calls. When multiple projects are processed in the same Python process, the second project's resource tree can be silently incomplete — missing resources already seen in the first. Fix: use seen_resources=None and initialize a fresh set() inside the function body when seen_resources is None. Add a regression test that calls get_resource_tree twice with separate projects to verify the seen_resources set is not shared between calls. Signed-off-by: Prajakta --- scanpipe/pipes/codebase.py | 4 ++- scanpipe/tests/pipes/test_codebase.py | 47 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/scanpipe/pipes/codebase.py b/scanpipe/pipes/codebase.py index 8f1bc61b30..ea26f7e371 100644 --- a/scanpipe/pipes/codebase.py +++ b/scanpipe/pipes/codebase.py @@ -34,7 +34,7 @@ def get_resource_fields(resource, fields): return {field: getattr(resource, field) for field in fields} -def get_resource_tree(resource, fields, codebase=None, seen_resources=set()): +def get_resource_tree(resource, fields, codebase=None, seen_resources=None): """ Return a tree as a dictionary structure starting from the provided `resource`. @@ -51,6 +51,8 @@ def get_resource_tree(resource, fields, codebase=None, seen_resources=set()): get_codebase_tree(). We keep track of child Resources we visit in `seen_resources`, so we don't visit them again in get_codebase_tree(). """ + if seen_resources is None: + seen_resources = set() resource_dict = get_resource_fields(resource, fields) if resource.is_dir: diff --git a/scanpipe/tests/pipes/test_codebase.py b/scanpipe/tests/pipes/test_codebase.py index 9dea648a9b..001b6a8c67 100644 --- a/scanpipe/tests/pipes/test_codebase.py +++ b/scanpipe/tests/pipes/test_codebase.py @@ -207,3 +207,50 @@ def test_scanpipe_pipes_codebase_get_basic_virtual_codebase(self): self.assertEqual("c03f67211a311b13d1294ac8af7cb139ee34c4f9", resource.sha1) self.assertEqual(19948, resource.size) self.assertEqual(True, resource.is_file) + + def test_get_resource_tree_does_not_share_seen_resources_across_calls(self): + """ + Regression test: get_resource_tree must NOT share the seen_resources + set across multiple calls. + + Bug: seen_resources=set() as a mutable default argument means Python + creates the set ONCE when the function is defined. All subsequent calls + share the SAME set object, causing resources from previous calls to be + silently skipped in later calls. + + Fix: use seen_resources=None and create a fresh set() inside the + function when seen_resources is None. + """ + from scanpipe.pipes.codebase import get_resource_tree + from scanpipe.models import CodebaseResource + + project1 = Project.objects.create(name="Project1") + project2 = Project.objects.create(name="Project2") + + dir1 = project1.codebaseresources.create( + path="dir1", type=CodebaseResource.Type.DIRECTORY + ) + project1.codebaseresources.create( + path="dir1/file1.py", type=CodebaseResource.Type.FILE + ) + + dir2 = project2.codebaseresources.create( + path="dir1", type=CodebaseResource.Type.DIRECTORY + ) + project2.codebaseresources.create( + path="dir1/file2.py", type=CodebaseResource.Type.FILE + ) + + fields = ["path"] + + tree1 = get_resource_tree(dir1, fields) + self.assertIn("children", tree1) + self.assertEqual(len(tree1["children"]), 1) + + tree2 = get_resource_tree(dir2, fields) + self.assertIn("children", tree2) + self.assertEqual( + len(tree2["children"]), + 1, + "Second call returned empty children — mutable default argument bug!", + ) \ No newline at end of file