python-package-folder 3.1.1__tar.gz → 3.1.3__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.
Files changed (48) hide show
  1. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/PKG-INFO +1 -1
  2. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/coverage.svg +2 -2
  3. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/manager.py +274 -0
  4. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_build_with_external_deps.py +118 -0
  5. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/.copier-answers.yml +0 -0
  6. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/.cursor/rules/general.mdc +0 -0
  7. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/.cursor/rules/python.mdc +0 -0
  8. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/.github/workflows/ci.yml +0 -0
  9. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/.github/workflows/publish.yml +0 -0
  10. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/.gitignore +0 -0
  11. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/.vscode/settings.json +0 -0
  12. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/LICENSE +0 -0
  13. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/Makefile +0 -0
  14. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/README.md +0 -0
  15. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/development.md +0 -0
  16. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/installation.md +0 -0
  17. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/publishing.md +0 -0
  18. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/pyproject.toml +0 -0
  19. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/__init__.py +0 -0
  20. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/__main__.py +0 -0
  21. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/analyzer.py +0 -0
  22. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/finder.py +0 -0
  23. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/publisher.py +0 -0
  24. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/py.typed +0 -0
  25. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/python_package_folder.py +0 -0
  26. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/subfolder_build.py +0 -0
  27. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/types.py +0 -0
  28. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/utils.py +0 -0
  29. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/src/python_package_folder/version.py +0 -0
  30. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/conftest.py +0 -0
  31. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/folder_structure/some_globals.py +0 -0
  32. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  33. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  34. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  35. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  36. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  37. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  38. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_linting.py +0 -0
  39. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_preserve_directory_structure.py +0 -0
  40. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_publisher.py +0 -0
  41. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_shared_subdirectory_imports.py +0 -0
  42. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_spreadsheet_creation_imports.py +0 -0
  43. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_subfolder_build.py +0 -0
  44. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_third_party_dependencies.py +0 -0
  45. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_utils.py +0 -0
  46. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/test_version_manager.py +0 -0
  47. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/tests/tests.py +0 -0
  48. {python_package_folder-3.1.1 → python_package_folder-3.1.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 3.1.1
3
+ Version: 3.1.3
4
4
  Summary: Python package to automatically package and build a folder, fetching all relevant dependencies.
5
5
  Project-URL: Repository, https://github.com/alelom/python-package-folder
6
6
  Author-email: Alessio Lombardi <work@alelom.com>
@@ -14,7 +14,7 @@
14
14
  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
15
15
  <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
16
16
  <text x="31.5" y="14">coverage</text>
17
- <text x="81" y="15" fill="#010101" fill-opacity=".3">68%</text>
18
- <text x="81" y="14">68%</text>
17
+ <text x="81" y="15" fill="#010101" fill-opacity=".3">69%</text>
18
+ <text x="81" y="14">69%</text>
19
19
  </g>
20
20
  </svg>
@@ -88,6 +88,8 @@ class BuildManager:
88
88
  self.subfolder_config: SubfolderBuildConfig | None = None
89
89
  # Cache for package name lookups (expensive operation)
90
90
  self._packages_distributions_cache: dict[str, list[str]] | None = None
91
+ # Track files with modified imports and their original content
92
+ self._modified_import_files: dict[Path, str] = {}
91
93
 
92
94
  # Check if it's a valid Python package directory
93
95
  if not any(self.src_dir.glob("*.py")) and not (self.src_dir / "__init__.py").exists():
@@ -270,6 +272,13 @@ class BuildManager:
270
272
  for dep in external_deps:
271
273
  self._copy_dependency(dep)
272
274
 
275
+ # For subfolder builds, fix imports
276
+ if self._is_subfolder_build() and external_deps:
277
+ # Fix relative imports in copied dependency files (convert to absolute)
278
+ self._fix_relative_imports_in_copied_files(external_deps)
279
+ # Convert absolute imports of copied dependencies and local files to relative imports
280
+ self._convert_imports_to_relative(python_files, external_deps)
281
+
273
282
  # For subfolder builds, extract third-party dependencies and add to pyproject.toml
274
283
  if self._is_subfolder_build() and self.subfolder_config:
275
284
  # Re-analyze all Python files (including copied dependencies) to find third-party imports
@@ -629,6 +638,258 @@ class BuildManager:
629
638
 
630
639
  return sorted(list(third_party_packages))
631
640
 
641
+ def _fix_relative_imports_in_copied_files(
642
+ self, external_deps: list[ExternalDependency]
643
+ ) -> None:
644
+ """
645
+ Fix relative imports in copied dependency files.
646
+
647
+ When files are copied into the subfolder, their relative imports (like
648
+ `from ._shared.shared_dataclasses import ...`) break because the file
649
+ structure has changed. Convert these to absolute imports based on the
650
+ target location.
651
+
652
+ Args:
653
+ external_deps: List of external dependencies that were copied
654
+ """
655
+ import ast
656
+ import re
657
+
658
+ # Find all Python files in copied dependencies
659
+ copied_files: list[Path] = []
660
+ for dep in external_deps:
661
+ if dep.target_path.is_file() and dep.target_path.suffix == ".py":
662
+ copied_files.append(dep.target_path)
663
+ elif dep.target_path.is_dir():
664
+ copied_files.extend(dep.target_path.rglob("*.py"))
665
+
666
+ for file_path in copied_files:
667
+ try:
668
+ content = file_path.read_text(encoding="utf-8")
669
+ original_content = content
670
+ lines = content.split("\n")
671
+ modified = False
672
+
673
+ try:
674
+ tree = ast.parse(content, filename=str(file_path))
675
+ except SyntaxError:
676
+ continue
677
+
678
+ lines_to_modify: dict[int, str] = {}
679
+
680
+ for node in ast.walk(tree):
681
+ if isinstance(node, ast.ImportFrom):
682
+ if node.module is None:
683
+ continue
684
+
685
+ # Check if this is a relative import (level > 0)
686
+ if node.level == 0:
687
+ continue
688
+
689
+ line_num = node.lineno - 1
690
+ if line_num < 0 or line_num >= len(lines):
691
+ continue
692
+
693
+ original_line = lines[line_num]
694
+
695
+ # Convert relative import to absolute based on target location
696
+ # When a file is copied to the package root, relative imports need to be absolute
697
+ # For example: from ._shared.shared_dataclasses -> from _shared.shared_dataclasses
698
+ if node.module:
699
+ # Remove the leading dots and convert to absolute
700
+ # If it was `from ._shared.shared_dataclasses`, it becomes `from _shared.shared_dataclasses`
701
+ absolute_module = node.module
702
+ new_line = re.sub(
703
+ rf"^(\s*)from\s+\.+{re.escape(node.module)}\s+import",
704
+ rf"\1from {absolute_module} import",
705
+ original_line,
706
+ )
707
+ else:
708
+ # from . import X -> from . import X (keep as relative, but at package root level)
709
+ # Actually, if we're at package root, this should work as-is
710
+ # But if the file was in a subdirectory, we need to adjust
711
+ # For now, keep it as relative import
712
+ continue
713
+
714
+ if new_line != original_line:
715
+ lines_to_modify[line_num] = new_line
716
+ modified = True
717
+
718
+ if modified:
719
+ for line_num, new_line in lines_to_modify.items():
720
+ lines[line_num] = new_line
721
+
722
+ new_content = "\n".join(lines)
723
+ if file_path not in self._modified_import_files:
724
+ self._modified_import_files[file_path] = original_content
725
+
726
+ file_path.write_text(new_content, encoding="utf-8")
727
+ print(f"Fixed relative imports in copied file: {file_path}")
728
+
729
+ except Exception as e:
730
+ print(
731
+ f"Warning: Could not fix imports in copied file {file_path}: {e}",
732
+ file=sys.stderr,
733
+ )
734
+
735
+ def _convert_imports_to_relative(
736
+ self, python_files: list[Path], external_deps: list[ExternalDependency]
737
+ ) -> None:
738
+ """
739
+ Convert absolute imports to relative imports for subfolder builds.
740
+
741
+ For subfolder builds, when external dependencies are copied into the subfolder,
742
+ imports need to be converted from absolute to relative so they work correctly
743
+ when the package is installed. This includes:
744
+ 1. Imports of copied dependencies (e.g., `from _shared.image_utils` -> `from ._shared.image_utils`)
745
+ 2. Imports of local files within the subfolder (e.g., `from detect_empty_drawings_utils` -> `from .detect_empty_drawings_utils`)
746
+
747
+ Args:
748
+ python_files: List of Python files in the source directory
749
+ external_deps: List of external dependencies that were copied
750
+ """
751
+ import ast
752
+ import re
753
+
754
+ # Build a set of import names that were copied
755
+ copied_import_names: set[str] = set()
756
+ for dep in external_deps:
757
+ root_module = dep.import_name.split(".")[0]
758
+ copied_import_names.add(root_module)
759
+ copied_import_names.add(dep.import_name)
760
+
761
+ # Build a set of local file names in the subfolder (excluding copied dependencies)
762
+ local_file_names: set[str] = set()
763
+ for file_path in python_files:
764
+ # Skip files that are part of copied dependencies
765
+ is_copied_file = any(file_path.is_relative_to(dep.target_path) for dep in external_deps)
766
+ if is_copied_file:
767
+ continue
768
+ if not file_path.is_relative_to(self.src_dir):
769
+ continue
770
+ # Get the module name (filename without .py extension)
771
+ if file_path.suffix == ".py":
772
+ module_name = file_path.stem
773
+ if module_name != "__init__":
774
+ local_file_names.add(module_name)
775
+
776
+ # Only modify files that are in the original subfolder (not the copied dependencies)
777
+ for file_path in python_files:
778
+ # Skip files that are part of copied dependencies
779
+ is_copied_file = any(file_path.is_relative_to(dep.target_path) for dep in external_deps)
780
+ if is_copied_file:
781
+ continue
782
+
783
+ # Skip if file is not in src_dir (shouldn't happen, but safety check)
784
+ if not file_path.is_relative_to(self.src_dir):
785
+ continue
786
+
787
+ try:
788
+ content = file_path.read_text(encoding="utf-8")
789
+ original_content = content
790
+ lines = content.split("\n")
791
+ modified = False
792
+
793
+ # Parse the file with AST to find imports accurately
794
+ try:
795
+ tree = ast.parse(content, filename=str(file_path))
796
+ except SyntaxError:
797
+ # Skip files with syntax errors
798
+ continue
799
+
800
+ # Track which lines need to be modified
801
+ lines_to_modify: dict[int, str] = {}
802
+
803
+ for node in ast.walk(tree):
804
+ if isinstance(node, ast.ImportFrom):
805
+ if node.module is None:
806
+ continue
807
+
808
+ # Check if this import matches a copied dependency or a local file
809
+ root_module = node.module.split(".")[0]
810
+ is_copied_dependency = (
811
+ root_module in copied_import_names or node.module in copied_import_names
812
+ )
813
+ is_local_file = root_module in local_file_names
814
+
815
+ if not is_copied_dependency and not is_local_file:
816
+ continue
817
+
818
+ # Get the line content
819
+ line_num = node.lineno - 1 # Convert to 0-based index
820
+ if line_num < 0 or line_num >= len(lines):
821
+ continue
822
+
823
+ original_line = lines[line_num]
824
+
825
+ # Skip if already a relative import
826
+ if original_line.strip().startswith("from ."):
827
+ continue
828
+
829
+ # Convert absolute import to relative import
830
+ # from _shared.image_utils import ... -> from ._shared.image_utils import ...
831
+ new_line = re.sub(
832
+ rf"^(\s*)from\s+{re.escape(node.module)}\s+import",
833
+ rf"\1from .{node.module} import",
834
+ original_line,
835
+ )
836
+
837
+ if new_line != original_line:
838
+ lines_to_modify[line_num] = new_line
839
+ modified = True
840
+
841
+ elif isinstance(node, ast.Import):
842
+ # Handle "import X" statements
843
+ for alias in node.names:
844
+ root_module = alias.name.split(".")[0]
845
+ is_copied_dependency = root_module in copied_import_names
846
+ is_local_file = root_module in local_file_names
847
+
848
+ if not is_copied_dependency and not is_local_file:
849
+ continue
850
+
851
+ line_num = node.lineno - 1
852
+ if line_num < 0 or line_num >= len(lines):
853
+ continue
854
+
855
+ original_line = lines[line_num]
856
+
857
+ # Skip if already a relative import
858
+ if original_line.strip().startswith("import ."):
859
+ continue
860
+
861
+ # Convert "import _shared" to "from . import _shared"
862
+ # This is more complex, so we'll use a regex replacement
863
+ new_line = re.sub(
864
+ rf"^(\s*)import\s+{re.escape(alias.name)}\b",
865
+ rf"\1from . import {alias.name}",
866
+ original_line,
867
+ )
868
+
869
+ if new_line != original_line:
870
+ lines_to_modify[line_num] = new_line
871
+ modified = True
872
+
873
+ # Apply modifications
874
+ if modified:
875
+ for line_num, new_line in lines_to_modify.items():
876
+ lines[line_num] = new_line
877
+
878
+ new_content = "\n".join(lines)
879
+ # Store original content for restoration
880
+ if file_path not in self._modified_import_files:
881
+ self._modified_import_files[file_path] = original_content
882
+
883
+ # Write modified content
884
+ file_path.write_text(new_content, encoding="utf-8")
885
+ print(f"Converted imports to relative in: {file_path}")
886
+
887
+ except Exception as e:
888
+ print(
889
+ f"Warning: Could not modify imports in {file_path}: {e}",
890
+ file=sys.stderr,
891
+ )
892
+
632
893
  def _report_ambiguous_imports(self, python_files: list[Path]) -> None:
633
894
  """
634
895
  Report any ambiguous imports that couldn't be resolved.
@@ -708,6 +969,19 @@ class BuildManager:
708
969
  self.copied_files.clear()
709
970
  self.copied_dirs.clear()
710
971
 
972
+ # Restore files with modified imports
973
+ for file_path, original_content in self._modified_import_files.items():
974
+ if file_path.exists():
975
+ try:
976
+ file_path.write_text(original_content, encoding="utf-8")
977
+ print(f"Restored original imports in: {file_path}")
978
+ except Exception as e:
979
+ print(
980
+ f"Warning: Could not restore imports in {file_path}: {e}",
981
+ file=sys.stderr,
982
+ )
983
+ self._modified_import_files.clear()
984
+
711
985
  # Remove all .egg-info directories in src_dir and project_root
712
986
  self._cleanup_egg_info_dirs()
713
987
 
@@ -363,6 +363,124 @@ class TestBuildManager:
363
363
  assert len(manager.copied_files) == 0
364
364
  assert len(manager.copied_dirs) == 0
365
365
 
366
+ def test_convert_copied_dependency_imports_to_relative(self, tmp_path: Path) -> None:
367
+ """Test that absolute imports of copied dependencies are converted to relative imports for subfolder builds."""
368
+ project_root = tmp_path / "test_project"
369
+ project_root.mkdir()
370
+
371
+ # Create pyproject.toml
372
+ (project_root / "pyproject.toml").write_text(
373
+ """[project]
374
+ name = "test-package"
375
+ version = "0.1.0"
376
+ """
377
+ )
378
+
379
+ # Create external dependency _shared
380
+ shared_dir = project_root / "_shared"
381
+ shared_dir.mkdir()
382
+ (shared_dir / "__init__.py").write_text("")
383
+ (shared_dir / "image_utils.py").write_text("def save_PIL_image(): pass")
384
+ (shared_dir / "file_utils.py").write_text("def get_filepaths_config(): pass")
385
+
386
+ # Create external dependency _globals
387
+ (project_root / "_globals.py").write_text("is_testing = False")
388
+
389
+ # Create subfolder to build
390
+ subfolder = project_root / "src" / "integration" / "empty_drawing_detection"
391
+ subfolder.mkdir(parents=True)
392
+ (subfolder / "__init__.py").write_text("")
393
+
394
+ # Create a local file that will be imported
395
+ local_file = subfolder / "local_utils.py"
396
+ local_file.write_text("def local_function(): return 42")
397
+
398
+ # Create a file in subfolder that imports copied dependencies with absolute imports
399
+ test_file = subfolder / "detect_empty_drawings.py"
400
+ original_content = """from pathlib import Path
401
+ from _shared.image_utils import save_PIL_image
402
+ from _shared.file_utils import get_filepaths_config
403
+ from _globals import is_testing
404
+ from local_utils import local_function
405
+ from config import get_config
406
+
407
+ def analyze_folder():
408
+ save_PIL_image()
409
+ get_filepaths_config()
410
+ return is_testing
411
+ """
412
+ test_file.write_text(original_content)
413
+
414
+ # Create another file with different import style
415
+ test_file2 = subfolder / "config.py"
416
+ original_content2 = """import _globals
417
+
418
+ def get_config():
419
+ return _globals.is_testing
420
+ """
421
+ test_file2.write_text(original_content2)
422
+
423
+ # Create a copied dependency file with relative imports (simulating detect_empty_drawings_utils.py)
424
+ # This will be created when _shared is copied
425
+ # First, let's create a file in _shared that will be copied
426
+ shared_utils = shared_dir / "shared_utils.py"
427
+ shared_utils.write_text(
428
+ """from .file_utils import get_filepaths_config
429
+
430
+ def shared_function():
431
+ return get_filepaths_config()
432
+ """
433
+ )
434
+
435
+ manager = BuildManager(project_root, subfolder)
436
+
437
+ try:
438
+ # Prepare build - this should copy dependencies and convert imports
439
+ external_deps = manager.prepare_build(version="1.0.0", package_name="test-package")
440
+
441
+ # Verify dependencies were copied
442
+ assert len(external_deps) >= 2
443
+ assert (subfolder / "_shared").exists()
444
+ assert (subfolder / "_globals.py").exists()
445
+
446
+ # Verify imports were converted to relative
447
+ modified_content = test_file.read_text()
448
+ assert "from ._shared.image_utils import save_PIL_image" in modified_content
449
+ assert "from ._shared.file_utils import get_filepaths_config" in modified_content
450
+ assert "from ._globals import is_testing" in modified_content
451
+ # Verify local file imports were converted to relative
452
+ assert "from .local_utils import local_function" in modified_content
453
+ assert "from .config import get_config" in modified_content
454
+ # Verify stdlib import was not changed
455
+ assert "from pathlib import Path" in modified_content
456
+
457
+ # Verify import statement conversion
458
+ modified_content2 = test_file2.read_text()
459
+ assert "from . import _globals" in modified_content2
460
+
461
+ # Verify relative imports in copied files were fixed
462
+ if (subfolder / "_shared" / "shared_utils.py").exists():
463
+ shared_utils_content = (subfolder / "_shared" / "shared_utils.py").read_text()
464
+ # The relative import should be converted to absolute
465
+ assert (
466
+ "from .file_utils import" not in shared_utils_content
467
+ or "from file_utils import" in shared_utils_content
468
+ )
469
+
470
+ # Cleanup should restore original imports
471
+ manager.cleanup()
472
+
473
+ restored_content = test_file.read_text()
474
+ assert restored_content == original_content
475
+
476
+ restored_content2 = test_file2.read_text()
477
+ assert restored_content2 == original_content2
478
+
479
+ finally:
480
+ # Ensure cleanup even if test fails
481
+ if manager._modified_import_files:
482
+ manager.cleanup()
483
+
366
484
 
367
485
  class TestRealFolderStructure:
368
486
  """Tests using the real folder_structure from tests directory."""