vizzpy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vizzpy-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atul Saurav
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
vizzpy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: vizzpy
3
+ Version: 0.1.0
4
+ Summary: Interactive Python call tree visualizer — upload a project, explore its call graph in the browser
5
+ Author: Atul
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/atulsaurav/vizzpy
8
+ Project-URL: Repository, https://github.com/atulsaurav/vizzpy
9
+ Project-URL: Bug Tracker, https://github.com/atulsaurav/vizzpy/issues
10
+ Keywords: python,call-tree,call-graph,ast,visualization,static-analysis
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: fastapi>=0.100
26
+ Requires-Dist: uvicorn[standard]>=0.23
27
+ Requires-Dist: python-multipart>=0.0.9
28
+ Requires-Dist: graphviz>=0.20
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8; extra == "dev"
31
+ Requires-Dist: pytest-cov; extra == "dev"
32
+ Requires-Dist: anyio[trio]; extra == "dev"
33
+ Requires-Dist: httpx; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # VizPy
37
+
38
+ Interactive Python call tree visualizer. Upload a Python project and explore its function call graph in the browser.
39
+
40
+ ## Features
41
+
42
+ - **Interactive web UI** — zoom, pan, drag nodes, collapse subtrees
43
+ - **Cross-module resolution** — follows imports across files to draw edges between modules
44
+ - **Dark mode** — toggle with one click
45
+ - **Headless mode** — render a static SVG from the command line (requires Graphviz)
46
+ - **Folder or zip upload** — drag-and-drop a `.zip` or browse for a local folder
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install vizzpy
52
+ ```
53
+
54
+ Headless SVG rendering additionally requires the [Graphviz](https://graphviz.org/download/) system package:
55
+
56
+ ```bash
57
+ # macOS
58
+ brew install graphviz
59
+
60
+ # Debian/Ubuntu
61
+ apt install graphviz
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ### Web UI (recommended)
67
+
68
+ ```bash
69
+ vizzpy --serve
70
+ # open http://127.0.0.1:8000
71
+ ```
72
+
73
+ Upload a `.zip` of your Python project or select a local folder, then click **Analyze**.
74
+
75
+ - **Scroll / pinch** to zoom
76
+ - **Drag background** to pan
77
+ - **Drag nodes** to rearrange
78
+ - **Double-click a node** to collapse/expand its call subtree
79
+ - **Hover** a node to see its docstring
80
+
81
+ ### Headless SVG
82
+
83
+ ```bash
84
+ vizzpy --headless ./myproject --output call_tree.svg
85
+ ```
86
+
87
+ ### Options
88
+
89
+ ```
90
+ vizzpy --serve [--host HOST] [--port PORT]
91
+ vizzpy --headless PROJECT_PATH [--output OUTPUT.svg]
92
+ ```
93
+
94
+ ## How it works
95
+
96
+ VizPy uses Python's `ast` module to do a two-pass analysis of your source files:
97
+
98
+ 1. **Scope pass** — collects every top-level function and class method with its qualified name (`module.ClassName.method`)
99
+ 2. **Edge pass** — walks each file again, resolves `self.method()`, `cls.method()`, imported names, and direct calls to project-internal functions
100
+
101
+ Results are rendered in the browser with [dagre-d3](https://github.com/dagrejs/dagre-d3) (vendored, works offline). Calls to stdlib or third-party libraries are silently excluded.
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ git clone https://github.com/atulsaurav/vizzpy
107
+ cd vizzpy
108
+ pip install -e ".[dev]"
109
+
110
+ # run tests
111
+ pytest
112
+
113
+ # run with coverage
114
+ pytest --cov=vizzpy --cov-report=term-missing
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT
vizzpy-0.1.0/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # VizPy
2
+
3
+ Interactive Python call tree visualizer. Upload a Python project and explore its function call graph in the browser.
4
+
5
+ ## Features
6
+
7
+ - **Interactive web UI** — zoom, pan, drag nodes, collapse subtrees
8
+ - **Cross-module resolution** — follows imports across files to draw edges between modules
9
+ - **Dark mode** — toggle with one click
10
+ - **Headless mode** — render a static SVG from the command line (requires Graphviz)
11
+ - **Folder or zip upload** — drag-and-drop a `.zip` or browse for a local folder
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install vizzpy
17
+ ```
18
+
19
+ Headless SVG rendering additionally requires the [Graphviz](https://graphviz.org/download/) system package:
20
+
21
+ ```bash
22
+ # macOS
23
+ brew install graphviz
24
+
25
+ # Debian/Ubuntu
26
+ apt install graphviz
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### Web UI (recommended)
32
+
33
+ ```bash
34
+ vizzpy --serve
35
+ # open http://127.0.0.1:8000
36
+ ```
37
+
38
+ Upload a `.zip` of your Python project or select a local folder, then click **Analyze**.
39
+
40
+ - **Scroll / pinch** to zoom
41
+ - **Drag background** to pan
42
+ - **Drag nodes** to rearrange
43
+ - **Double-click a node** to collapse/expand its call subtree
44
+ - **Hover** a node to see its docstring
45
+
46
+ ### Headless SVG
47
+
48
+ ```bash
49
+ vizzpy --headless ./myproject --output call_tree.svg
50
+ ```
51
+
52
+ ### Options
53
+
54
+ ```
55
+ vizzpy --serve [--host HOST] [--port PORT]
56
+ vizzpy --headless PROJECT_PATH [--output OUTPUT.svg]
57
+ ```
58
+
59
+ ## How it works
60
+
61
+ VizPy uses Python's `ast` module to do a two-pass analysis of your source files:
62
+
63
+ 1. **Scope pass** — collects every top-level function and class method with its qualified name (`module.ClassName.method`)
64
+ 2. **Edge pass** — walks each file again, resolves `self.method()`, `cls.method()`, imported names, and direct calls to project-internal functions
65
+
66
+ Results are rendered in the browser with [dagre-d3](https://github.com/dagrejs/dagre-d3) (vendored, works offline). Calls to stdlib or third-party libraries are silently excluded.
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ git clone https://github.com/atulsaurav/vizzpy
72
+ cd vizzpy
73
+ pip install -e ".[dev]"
74
+
75
+ # run tests
76
+ pytest
77
+
78
+ # run with coverage
79
+ pytest --cov=vizzpy --cov-report=term-missing
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vizzpy"
7
+ version = "0.1.0"
8
+ description = "Interactive Python call tree visualizer — upload a project, explore its call graph in the browser"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Atul" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["python", "call-tree", "call-graph", "ast", "visualization", "static-analysis"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Web Environment",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ ]
28
+ dependencies = [
29
+ "fastapi>=0.100",
30
+ "uvicorn[standard]>=0.23",
31
+ "python-multipart>=0.0.9",
32
+ "graphviz>=0.20",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=8",
38
+ "pytest-cov",
39
+ "anyio[trio]",
40
+ "httpx",
41
+ ]
42
+
43
+ [project.scripts]
44
+ vizzpy = "vizzpy.cli:cli"
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/atulsaurav/vizzpy"
48
+ Repository = "https://github.com/atulsaurav/vizzpy"
49
+ "Bug Tracker" = "https://github.com/atulsaurav/vizzpy/issues"
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["."]
53
+ include = ["vizzpy*"]
54
+
55
+ [tool.setuptools.package-data]
56
+ vizzpy = ["static/**/*"]
vizzpy-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,81 @@
1
+ """Tests for graph.py: build_graph, _fallback_label, _fallback_module."""
2
+ import pytest
3
+ from pathlib import Path
4
+
5
+ from vizzpy.graph import build_graph, _fallback_label, _fallback_module
6
+
7
+
8
+ # ── _fallback_label / _fallback_module ───────────────────────────────────────
9
+
10
+ def test_fallback_label_simple():
11
+ assert _fallback_label("pkg.utils.helper") == "helper"
12
+
13
+ def test_fallback_label_no_dot():
14
+ assert _fallback_label("main") == "main"
15
+
16
+ def test_fallback_module_with_dot():
17
+ assert _fallback_module("pkg.utils.helper") == "pkg.utils"
18
+
19
+ def test_fallback_module_no_dot():
20
+ assert _fallback_module("main") == "main"
21
+
22
+
23
+ # ── build_graph ───────────────────────────────────────────────────────────────
24
+
25
+ def test_build_graph_basic(tmp_path):
26
+ (tmp_path / "foo.py").write_text("def a(): pass\ndef b():\n a()")
27
+ g = build_graph(tmp_path)
28
+ node_ids = {n["id"] for n in g["nodes"]}
29
+ assert "foo.a" in node_ids
30
+ assert "foo.b" in node_ids
31
+ assert any(e["source"] == "foo.b" and e["target"] == "foo.a" for e in g["edges"])
32
+
33
+
34
+ def test_build_graph_edge_count(tmp_path):
35
+ # Two calls to the same callee → count == 2
36
+ (tmp_path / "m.py").write_text("def a(): pass\ndef b():\n a()\n a()")
37
+ g = build_graph(tmp_path)
38
+ edge = next(e for e in g["edges"] if e["source"] == "m.b" and e["target"] == "m.a")
39
+ assert edge["count"] == 2
40
+
41
+
42
+ def test_build_graph_modules_grouping(tmp_path):
43
+ (tmp_path / "pkg").mkdir()
44
+ (tmp_path / "pkg" / "utils.py").write_text("def helper(): pass\ndef run():\n helper()")
45
+ g = build_graph(tmp_path)
46
+ assert "pkg.utils" in g["modules"]
47
+ assert "pkg.utils.helper" in g["modules"]["pkg.utils"]
48
+
49
+
50
+ def test_build_graph_node_label_and_module(tmp_path):
51
+ (tmp_path / "svc.py").write_text("def process(): pass\ndef main():\n process()")
52
+ g = build_graph(tmp_path)
53
+ node = next(n for n in g["nodes"] if n["id"] == "svc.process")
54
+ assert node["label"] == "process"
55
+ assert node["module"] == "svc"
56
+
57
+
58
+ def test_build_graph_cross_module(tmp_path):
59
+ (tmp_path / "a.py").write_text("def helper(): pass")
60
+ (tmp_path / "b.py").write_text("from a import helper\ndef caller():\n helper()")
61
+ g = build_graph(tmp_path)
62
+ assert any(e["source"] == "b.caller" and e["target"] == "a.helper" for e in g["edges"])
63
+
64
+
65
+ def test_build_graph_fallback_node_for_unknown_callee(tmp_path):
66
+ # Callee comes from an external package — still shows up as a node if referenced
67
+ # within-project edges only (external callee won't appear unless it's in all_names)
68
+ # This test just verifies build_graph doesn't crash on an empty project.
69
+ g = build_graph(tmp_path)
70
+ assert g["nodes"] == []
71
+ assert g["edges"] == []
72
+ assert g["modules"] == {}
73
+
74
+
75
+ def test_build_graph_node_docstring(tmp_path):
76
+ (tmp_path / "m.py").write_text(
77
+ 'def a():\n """Does something."""\n pass\ndef b():\n a()'
78
+ )
79
+ g = build_graph(tmp_path)
80
+ node = next(n for n in g["nodes"] if n["id"] == "m.a")
81
+ assert node["docstring"] == "Does something."
@@ -0,0 +1,97 @@
1
+ """Tests for project-level analysis: module naming, test exclusion, edge collection."""
2
+ import pytest
3
+ from pathlib import Path
4
+
5
+ from vizzpy.parser.project import _is_test_file, get_module_name, analyze_project
6
+
7
+
8
+ # ── _is_test_file ─────────────────────────────────────────────────────────────
9
+
10
+ def test_is_test_by_prefix():
11
+ assert _is_test_file(Path("/proj/test_foo.py"))
12
+
13
+ def test_is_test_by_suffix():
14
+ assert _is_test_file(Path("/proj/foo_test.py"))
15
+
16
+ def test_is_test_by_dir():
17
+ assert _is_test_file(Path("/proj/tests/helper.py"))
18
+
19
+ def test_is_test_nested_dir():
20
+ assert _is_test_file(Path("/proj/src/test/utils.py"))
21
+
22
+ def test_not_test_file():
23
+ assert not _is_test_file(Path("/proj/src/utils.py"))
24
+
25
+ def test_not_test_file_with_test_in_name():
26
+ # "testament.py" should not be excluded
27
+ assert not _is_test_file(Path("/proj/testament.py"))
28
+
29
+
30
+ # ── get_module_name ───────────────────────────────────────────────────────────
31
+
32
+ def test_module_name_simple(tmp_path):
33
+ f = tmp_path / "utils.py"
34
+ f.touch()
35
+ assert get_module_name(f, tmp_path) == "utils"
36
+
37
+ def test_module_name_nested(tmp_path):
38
+ (tmp_path / "services").mkdir()
39
+ f = tmp_path / "services" / "order.py"
40
+ f.touch()
41
+ assert get_module_name(f, tmp_path) == "services.order"
42
+
43
+ def test_module_name_init(tmp_path):
44
+ (tmp_path / "services").mkdir()
45
+ f = tmp_path / "services" / "__init__.py"
46
+ f.touch()
47
+ assert get_module_name(f, tmp_path) == "services"
48
+
49
+
50
+ # ── analyze_project ───────────────────────────────────────────────────────────
51
+
52
+ def test_basic_edges(tmp_path):
53
+ (tmp_path / "foo.py").write_text("def a(): pass\ndef b():\n a()")
54
+ edges, node_info = analyze_project(tmp_path)
55
+ assert ("foo.b", "foo.a") in edges
56
+ assert "foo.a" in node_info
57
+ assert "foo.b" in node_info
58
+
59
+
60
+ def test_excludes_test_files(tmp_path):
61
+ (tmp_path / "foo.py").write_text("def a(): pass")
62
+ test_dir = tmp_path / "tests"
63
+ test_dir.mkdir()
64
+ (test_dir / "test_foo.py").write_text(
65
+ "from foo import a\ndef test_it():\n a()"
66
+ )
67
+ edges, node_info = analyze_project(tmp_path)
68
+ # test functions must not appear as nodes or callers
69
+ assert not any("test_it" in qn for qn in node_info)
70
+ assert not any("test_it" in e[0] for e in edges)
71
+
72
+
73
+ def test_cross_module_edges(tmp_path):
74
+ (tmp_path / "a.py").write_text("def helper(): pass")
75
+ (tmp_path / "b.py").write_text(
76
+ "from a import helper\ndef caller():\n helper()"
77
+ )
78
+ edges, _ = analyze_project(tmp_path)
79
+ assert ("b.caller", "a.helper") in edges
80
+
81
+
82
+ def test_syntax_error_file_skipped(tmp_path):
83
+ (tmp_path / "good.py").write_text("def ok(): pass")
84
+ (tmp_path / "bad.py").write_text("def broken(: pass") # syntax error
85
+ # Should not raise; the bad file is silently skipped
86
+ edges, node_info = analyze_project(tmp_path)
87
+ assert "good.ok" in node_info
88
+
89
+
90
+ def test_no_duplicate_edges(tmp_path):
91
+ # Two calls from the same caller to the same callee → one edge with count 2
92
+ (tmp_path / "m.py").write_text(
93
+ "def a(): pass\ndef b():\n a()\n a()"
94
+ )
95
+ edges, _ = analyze_project(tmp_path)
96
+ matching = [e for e in edges if e == ("m.b", "m.a")]
97
+ assert len(matching) == 2 # raw edges — duplicates expected; build_graph deduplicates
@@ -0,0 +1,134 @@
1
+ """Tests for the FastAPI server endpoints."""
2
+ import io
3
+ import zipfile
4
+ import pytest
5
+ from httpx import AsyncClient, ASGITransport
6
+
7
+ from vizzpy.server import app, _find_project_root
8
+ from pathlib import Path
9
+
10
+
11
+ # ── helpers ───────────────────────────────────────────────────────────────────
12
+
13
+ def _make_zip(files: dict[str, str]) -> bytes:
14
+ """Build an in-memory zip from {relative_path: content} pairs."""
15
+ buf = io.BytesIO()
16
+ with zipfile.ZipFile(buf, "w") as zf:
17
+ for name, content in files.items():
18
+ zf.writestr(name, content)
19
+ return buf.getvalue()
20
+
21
+
22
+ @pytest.fixture
23
+ async def client():
24
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
25
+ yield c
26
+
27
+
28
+ # ── GET / ─────────────────────────────────────────────────────────────────────
29
+
30
+ @pytest.mark.anyio
31
+ async def test_index_returns_html(client):
32
+ r = await client.get("/")
33
+ assert r.status_code == 200
34
+ assert "text/html" in r.headers["content-type"]
35
+
36
+
37
+ # ── POST /api/analyze ─────────────────────────────────────────────────────────
38
+
39
+ @pytest.mark.anyio
40
+ async def test_analyze_valid_zip(client):
41
+ zip_bytes = _make_zip({
42
+ "proj/foo.py": "def a(): pass\ndef b():\n a()",
43
+ })
44
+ r = await client.post(
45
+ "/api/analyze",
46
+ files={"file": ("proj.zip", zip_bytes, "application/zip")},
47
+ )
48
+ assert r.status_code == 200
49
+ data = r.json()
50
+ assert "nodes" in data and "edges" in data and "modules" in data
51
+ node_ids = {n["id"] for n in data["nodes"]}
52
+ assert "foo.a" in node_ids
53
+
54
+
55
+ @pytest.mark.anyio
56
+ async def test_analyze_rejects_non_zip(client):
57
+ r = await client.post(
58
+ "/api/analyze",
59
+ files={"file": ("code.py", b"def f(): pass", "text/plain")},
60
+ )
61
+ assert r.status_code == 400
62
+ assert "zip" in r.json()["detail"].lower()
63
+
64
+
65
+ @pytest.mark.anyio
66
+ async def test_analyze_rejects_bad_zip(client):
67
+ r = await client.post(
68
+ "/api/analyze",
69
+ files={"file": ("bad.zip", b"not a zip", "application/zip")},
70
+ )
71
+ assert r.status_code == 400
72
+
73
+
74
+ @pytest.mark.anyio
75
+ async def test_analyze_zip_single_top_level_dir(client):
76
+ # Zip wraps everything in a top-level folder — _find_project_root should unwrap it
77
+ zip_bytes = _make_zip({
78
+ "myproject/utils.py": "def helper(): pass\ndef run():\n helper()",
79
+ })
80
+ r = await client.post(
81
+ "/api/analyze",
82
+ files={"file": ("myproject.zip", zip_bytes, "application/zip")},
83
+ )
84
+ assert r.status_code == 200
85
+ node_ids = {n["id"] for n in r.json()["nodes"]}
86
+ assert "utils.helper" in node_ids # module name from inside myproject/, not myproject.utils
87
+
88
+
89
+ # ── POST /api/analyze-folder ──────────────────────────────────────────────────
90
+
91
+ @pytest.mark.anyio
92
+ async def test_analyze_folder_basic(client):
93
+ files = [
94
+ ("files", ("proj/foo.py", b"def a(): pass\ndef b():\n a()", "text/x-python")),
95
+ ]
96
+ r = await client.post("/api/analyze-folder", files=files)
97
+ assert r.status_code == 200
98
+ data = r.json()
99
+ node_ids = {n["id"] for n in data["nodes"]}
100
+ assert "foo.a" in node_ids
101
+
102
+
103
+ @pytest.mark.anyio
104
+ async def test_analyze_folder_multiple_files(client):
105
+ files = [
106
+ ("files", ("pkg/a.py", b"def helper(): pass", "text/x-python")),
107
+ ("files", ("pkg/b.py", b"from a import helper\ndef caller():\n helper()", "text/x-python")),
108
+ ]
109
+ r = await client.post("/api/analyze-folder", files=files)
110
+ assert r.status_code == 200
111
+ data = r.json()
112
+ assert any(e["source"] == "b.caller" and e["target"] == "a.helper" for e in data["edges"])
113
+
114
+
115
+ # ── _find_project_root ────────────────────────────────────────────────────────
116
+
117
+ def test_find_project_root_unwraps_single_dir(tmp_path):
118
+ inner = tmp_path / "myproject"
119
+ inner.mkdir()
120
+ (inner / "foo.py").touch()
121
+ assert _find_project_root(tmp_path) == inner
122
+
123
+
124
+ def test_find_project_root_keeps_flat(tmp_path):
125
+ (tmp_path / "foo.py").touch()
126
+ (tmp_path / "bar.py").touch()
127
+ assert _find_project_root(tmp_path) == tmp_path
128
+
129
+
130
+ def test_find_project_root_ignores_dot_files(tmp_path):
131
+ (tmp_path / ".DS_Store").touch()
132
+ inner = tmp_path / "src"
133
+ inner.mkdir()
134
+ assert _find_project_root(tmp_path) == inner