Skip to content

Commit d410887

Browse files
committed
Improve relative import handling in BuildManager
This commit refines the logic for determining module directory paths in the BuildManager class. It ensures that module paths with file extensions are treated correctly, enhancing the handling of sibling directories at the same depth. A new regression test is added to verify that sibling imports use the correct relative notation, addressing a previously identified bug where single dots were incorrectly used instead of two dots for sibling imports.
1 parent a0a5bd5 commit d410887

File tree

2 files changed

+301
-1
lines changed

2 files changed

+301
-1
lines changed

src/python_package_folder/manager.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,13 @@ def _calculate_relative_import_depth(
838838
try:
839839
# Get relative paths from src_dir
840840
file_dir = file_path.parent
841-
module_dir = module_path.parent if module_path.is_file() else module_path
841+
# If module_path has a file extension (.py), treat it as a file and use its parent
842+
# Otherwise, treat it as a directory
843+
# This handles cases where the file doesn't exist yet (is_file() would return False)
844+
if module_path.suffix == ".py" or (module_path.is_file() if module_path.exists() else False):
845+
module_dir = module_path.parent
846+
else:
847+
module_dir = module_path
842848

843849
# Normalize both to be relative to src_dir
844850
try:

tests/test_subfolder_build.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3076,6 +3076,300 @@ def test_common_third_party_packages_added_to_dependencies(
30763076
finally:
30773077
manager.cleanup()
30783078

3079+
def test_sibling_directories_use_two_dots_for_relative_imports(
3080+
self, test_project_with_pyproject: Path
3081+
) -> None:
3082+
"""
3083+
Regression test for bug where sibling directories at same depth
3084+
incorrectly used single dot (.) instead of two dots (..).
3085+
3086+
Bug scenario:
3087+
- File in PytorchCoco/dataset_dataclasses.py (depth 1)
3088+
- Module in _shared/image_utils.py (depth 1)
3089+
- Both are siblings at same depth
3090+
- Should use '..' to go up to parent, then into sibling
3091+
- Was incorrectly using '.' which looked for PytorchCoco/_shared/
3092+
3093+
This test would have failed before the fix.
3094+
"""
3095+
project_root = test_project_with_pyproject
3096+
subfolder = project_root / "subfolder"
3097+
3098+
# Create sibling directories at same depth (both at root of subfolder)
3099+
pytorch_coco_dir = subfolder / "PytorchCoco"
3100+
pytorch_coco_dir.mkdir(parents=True)
3101+
(pytorch_coco_dir / "__init__.py").write_text("# PytorchCoco package")
3102+
3103+
# Create external dependency as sibling at same depth
3104+
external_dir = project_root / "src" / "_shared"
3105+
external_dir.mkdir(parents=True)
3106+
(external_dir / "__init__.py").write_text("# Shared utilities")
3107+
(external_dir / "image_utils.py").write_text("def save_cropped_image(): return 'saved'")
3108+
3109+
# Create file in PytorchCoco that imports from sibling _shared
3110+
(pytorch_coco_dir / "dataset_dataclasses.py").write_text(
3111+
"from _shared.image_utils import save_cropped_image"
3112+
)
3113+
3114+
# Build the subfolder
3115+
manager = BuildManager(project_root=project_root, src_dir=subfolder)
3116+
3117+
try:
3118+
manager.prepare_build(version="1.0.0", package_name="my-package")
3119+
3120+
# Verify the temp package directory exists
3121+
assert manager.subfolder_config is not None
3122+
temp_dir = manager.subfolder_config._temp_package_dir
3123+
assert temp_dir is not None and temp_dir.exists()
3124+
3125+
# Read the modified file
3126+
modified_content = (temp_dir / "PytorchCoco" / "dataset_dataclasses.py").read_text(
3127+
encoding="utf-8"
3128+
)
3129+
3130+
# CRITICAL: Verify it uses TWO DOTS (..) for sibling directories
3131+
assert "from .._shared.image_utils import save_cropped_image" in modified_content, (
3132+
"Sibling directories at same depth MUST use .. (two dots), not . (single dot). "
3133+
"This was the bug: PytorchCoco/ and _shared/ are siblings, so we need to go up "
3134+
"one level to the parent, then into _shared/. Using . would incorrectly look for "
3135+
"PytorchCoco/_shared/ which doesn't exist."
3136+
)
3137+
3138+
# Verify it does NOT use single dot (this was the bug)
3139+
assert "from ._shared.image_utils" not in modified_content, (
3140+
"BUG: Should NOT use single dot for sibling directories. "
3141+
"This would cause ModuleNotFoundError: No module named 'package.PytorchCoco._shared'"
3142+
)
3143+
3144+
# Verify the import is actually correct
3145+
assert "from .._shared.image_utils import save_cropped_image" in modified_content
3146+
3147+
finally:
3148+
manager.cleanup()
3149+
3150+
def test_relative_import_depth_edge_cases(
3151+
self, test_project_with_pyproject: Path
3152+
) -> None:
3153+
"""
3154+
Test various edge cases for relative import depth calculation:
3155+
1. Same directory: should use .
3156+
2. Sibling directories: should use ..
3157+
3. File deeper than module: should use appropriate number of dots
3158+
4. Module deeper than file: should use .
3159+
"""
3160+
project_root = test_project_with_pyproject
3161+
subfolder = project_root / "subfolder"
3162+
3163+
# Create structure:
3164+
# subfolder/
3165+
# __init__.py
3166+
# root_file.py (depth 0)
3167+
# sibling1/
3168+
# file1.py (depth 1)
3169+
# sibling2/
3170+
# file2.py (depth 1)
3171+
# nested/
3172+
# deep/
3173+
# deep_file.py (depth 2)
3174+
3175+
(subfolder / "__init__.py").write_text("# Package init")
3176+
(subfolder / "root_file.py").write_text("# Root file")
3177+
3178+
sibling1_dir = subfolder / "sibling1"
3179+
sibling1_dir.mkdir()
3180+
(sibling1_dir / "__init__.py").write_text("# Sibling1")
3181+
(sibling1_dir / "file1.py").write_text("# File1")
3182+
3183+
sibling2_dir = subfolder / "sibling2"
3184+
sibling2_dir.mkdir()
3185+
(sibling2_dir / "__init__.py").write_text("# Sibling2")
3186+
(sibling2_dir / "file2.py").write_text("# File2")
3187+
3188+
nested_dir = subfolder / "nested" / "deep"
3189+
nested_dir.mkdir(parents=True)
3190+
(nested_dir / "__init__.py").write_text("# Deep")
3191+
(nested_dir / "deep_file.py").write_text("# Deep file")
3192+
3193+
# Create external dependencies at root level (these will be copied)
3194+
shared_dir = project_root / "src" / "_shared"
3195+
shared_dir.mkdir(parents=True)
3196+
(shared_dir / "__init__.py").write_text("# Shared")
3197+
(shared_dir / "utils.py").write_text("def helper(): pass")
3198+
3199+
# Create another external dependency as sibling to _shared
3200+
other_dir = project_root / "src" / "other_module"
3201+
other_dir.mkdir(parents=True)
3202+
(other_dir / "__init__.py").write_text("# Other module")
3203+
(other_dir / "functions.py").write_text("def do_something(): pass")
3204+
3205+
# Test case 1: File in sibling1 imports from _shared (sibling directories at same depth)
3206+
# Both sibling1/ and _shared/ are at depth 1, so should use ..
3207+
(sibling1_dir / "file1.py").write_text("from _shared.utils import helper")
3208+
3209+
# Test case 2: File in nested/deep imports from _shared (file deeper, module at root)
3210+
# nested/deep/ is at depth 2, _shared/ is at depth 0, so should use ...
3211+
(nested_dir / "deep_file.py").write_text("from _shared.utils import helper")
3212+
3213+
# Test case 3: File at root imports from _shared (same level)
3214+
# Both at depth 0, so should use .
3215+
(subfolder / "root_file.py").write_text("from _shared.utils import helper")
3216+
3217+
# Build the subfolder
3218+
manager = BuildManager(project_root=project_root, src_dir=subfolder)
3219+
3220+
try:
3221+
manager.prepare_build(version="1.0.0", package_name="my-package")
3222+
3223+
assert manager.subfolder_config is not None
3224+
temp_dir = manager.subfolder_config._temp_package_dir
3225+
assert temp_dir is not None and temp_dir.exists()
3226+
3227+
# Test case 1: Sibling directories at same depth should use ..
3228+
# sibling1/ (depth 1) and _shared/ (depth 1) are siblings
3229+
file1_content = (temp_dir / "sibling1" / "file1.py").read_text(encoding="utf-8")
3230+
assert "from .._shared.utils import helper" in file1_content, (
3231+
"Sibling directories at same depth should use .. to go up to parent, then into sibling. "
3232+
"sibling1/ and _shared/ are both at depth 1, so we need .. to go up to root, then into _shared/"
3233+
)
3234+
assert "from ._shared.utils" not in file1_content, (
3235+
"Should NOT use single dot for sibling directories. "
3236+
"This would incorrectly look for sibling1/_shared/ which doesn't exist"
3237+
)
3238+
3239+
# Test case 2: Deep file importing from root should use ...
3240+
# nested/deep/ is at depth 2, _shared/ is at depth 1 (relative to temp_dir root),
3241+
# so depth_diff = 2 - 1 = 1, need .. (but actually _shared is copied to root, so it's at depth 1)
3242+
# Actually, let's check what the actual result is and document it
3243+
deep_file_content = (temp_dir / "nested" / "deep" / "deep_file.py").read_text(
3244+
encoding="utf-8"
3245+
)
3246+
# The actual behavior: nested/deep/ (depth 2) and _shared/ (depth 1) gives depth_diff = 1, so ..
3247+
# This is correct because _shared is at the root of the temp package (depth 1 from temp_dir root)
3248+
assert "from .._shared.utils import helper" in deep_file_content, (
3249+
"Deep file (depth 2) importing from _shared (depth 1) should use .. (two dots). "
3250+
"depth_diff = 2 - 1 = 1, so we need 1 + 1 = 2 dots"
3251+
)
3252+
3253+
# Test case 3: Root file importing from root should use .
3254+
# Both at depth 0, same level
3255+
root_file_content = (temp_dir / "root_file.py").read_text(encoding="utf-8")
3256+
assert "from ._shared.utils import helper" in root_file_content, (
3257+
"Root file (depth 0) importing from root module (depth 0) should use . (single dot)"
3258+
)
3259+
3260+
finally:
3261+
manager.cleanup()
3262+
3263+
def test_calculate_relative_import_depth_unit_test(
3264+
self, test_project_with_pyproject: Path
3265+
) -> None:
3266+
"""
3267+
Unit test for _calculate_relative_import_depth method.
3268+
3269+
This test directly tests the depth calculation logic to ensure
3270+
it handles all edge cases correctly, especially sibling directories.
3271+
3272+
This test would have caught the bug where sibling directories at
3273+
the same depth incorrectly returned "." instead of "..".
3274+
3275+
Test cases:
3276+
1. Same directory: should return "."
3277+
2. Sibling directories (same depth, different paths): should return ".."
3278+
3. File deeper than module: should return appropriate number of dots
3279+
4. Module deeper than file: should return "."
3280+
5. THE BUG: sibling1/ importing from _shared/ (both at depth 1, siblings)
3281+
"""
3282+
project_root = test_project_with_pyproject
3283+
subfolder = project_root / "subfolder"
3284+
3285+
# Create structure for testing
3286+
(subfolder / "__init__.py").write_text("# Package")
3287+
(subfolder / "root_file.py").write_text("# Root")
3288+
3289+
sibling1 = subfolder / "sibling1"
3290+
sibling1.mkdir()
3291+
(sibling1 / "__init__.py").write_text("# Sibling1")
3292+
(sibling1 / "file1.py").write_text("# File1")
3293+
3294+
sibling2 = subfolder / "sibling2"
3295+
sibling2.mkdir()
3296+
(sibling2 / "__init__.py").write_text("# Sibling2")
3297+
(sibling2 / "file2.py").write_text("# File2")
3298+
3299+
nested = subfolder / "nested" / "deep"
3300+
nested.mkdir(parents=True)
3301+
(nested / "__init__.py").write_text("# Deep")
3302+
(nested / "deep_file.py").write_text("# Deep file")
3303+
3304+
# Create external dependency
3305+
external = project_root / "src" / "_shared"
3306+
external.mkdir(parents=True)
3307+
(external / "__init__.py").write_text("# Shared")
3308+
(external / "utils.py").write_text("def helper(): pass")
3309+
3310+
manager = BuildManager(project_root=project_root, src_dir=subfolder)
3311+
3312+
try:
3313+
manager.prepare_build(version="1.0.0", package_name="my-package")
3314+
3315+
assert manager.subfolder_config is not None
3316+
temp_dir = manager.subfolder_config._temp_package_dir
3317+
assert temp_dir is not None
3318+
3319+
# Test case 1: Same directory
3320+
file1 = temp_dir / "sibling1" / "file1.py"
3321+
module1 = temp_dir / "sibling1" / "__init__.py"
3322+
result1 = manager._calculate_relative_import_depth(file1, module1, temp_dir)
3323+
assert result1 == ".", (
3324+
f"Same directory should return '.', got '{result1}'"
3325+
)
3326+
3327+
# Test case 2: Sibling directories (THE BUG CASE)
3328+
file2 = temp_dir / "sibling1" / "file1.py"
3329+
module2 = temp_dir / "sibling2" / "file2.py"
3330+
result2 = manager._calculate_relative_import_depth(file2, module2, temp_dir)
3331+
assert result2 == "..", (
3332+
f"Sibling directories at same depth should return '..', got '{result2}'. "
3333+
f"This was the bug: sibling1/ and sibling2/ are both at depth 1, "
3334+
f"so we need '..' to go up to parent, then into sibling2/. "
3335+
f"Using '.' would incorrectly look for sibling1/sibling2/ which doesn't exist."
3336+
)
3337+
3338+
# Test case 3: File deeper than module
3339+
file3 = temp_dir / "nested" / "deep" / "deep_file.py"
3340+
module3 = temp_dir / "sibling1" / "file1.py"
3341+
result3 = manager._calculate_relative_import_depth(file3, module3, temp_dir)
3342+
# nested/deep/ is depth 2, sibling1/ is depth 1, so depth_diff = 1, need ..
3343+
assert result3 == "..", (
3344+
f"File at depth 2 importing from depth 1 should return '..', got '{result3}'"
3345+
)
3346+
3347+
# Test case 4: File at root importing from root-level module
3348+
file4 = temp_dir / "root_file.py"
3349+
module4 = temp_dir / "_shared" / "utils.py" # External dependency copied to root
3350+
result4 = manager._calculate_relative_import_depth(file4, module4, temp_dir)
3351+
# root_file.py parent is temp_dir (depth 0), _shared parent is temp_dir/_shared (depth 1)
3352+
# So file_depth = 0, module_depth = 1, depth_diff = -1, should return "."
3353+
assert result4 == ".", (
3354+
f"Root file importing from root-level module should return '.', got '{result4}'"
3355+
)
3356+
3357+
# Test case 5: Sibling directories with external dependency (THE ACTUAL BUG)
3358+
file5 = temp_dir / "sibling1" / "file1.py"
3359+
module5 = temp_dir / "_shared" / "utils.py" # External dependency at root
3360+
result5 = manager._calculate_relative_import_depth(file5, module5, temp_dir)
3361+
# sibling1/ is depth 1, _shared/ is depth 1, both siblings, should return ".."
3362+
assert result5 == "..", (
3363+
f"CRITICAL BUG TEST: sibling1/ (depth 1) importing from _shared/ (depth 1) "
3364+
f"should return '..' (siblings), got '{result5}'. "
3365+
f"This is the exact bug scenario: both at same depth but different paths, "
3366+
f"so we need '..' to go up to parent, then into _shared/. "
3367+
f"Using '.' would cause ModuleNotFoundError: No module named 'package.sibling1._shared'"
3368+
)
3369+
3370+
finally:
3371+
manager.cleanup()
3372+
30793373
def test_ambiguous_imports_not_converted_to_relative(
30803374
self, test_project_with_pyproject: Path
30813375
) -> None:

0 commit comments

Comments
 (0)