python-package-folder 2.0.4__tar.gz → 2.0.6__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 (46) hide show
  1. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/PKG-INFO +1 -1
  2. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/coverage.svg +2 -2
  3. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/analyzer.py +20 -0
  4. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/manager.py +76 -25
  5. python_package_folder-2.0.6/tests/test_shared_subdirectory_imports.py +107 -0
  6. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/.copier-answers.yml +0 -0
  7. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/.cursor/rules/general.mdc +0 -0
  8. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/.cursor/rules/python.mdc +0 -0
  9. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/.github/workflows/ci.yml +0 -0
  10. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/.github/workflows/publish.yml +0 -0
  11. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/.gitignore +0 -0
  12. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/.vscode/settings.json +0 -0
  13. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/LICENSE +0 -0
  14. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/Makefile +0 -0
  15. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/README.md +0 -0
  16. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/development.md +0 -0
  17. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/installation.md +0 -0
  18. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/publishing.md +0 -0
  19. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/pyproject.toml +0 -0
  20. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/__init__.py +0 -0
  21. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/__main__.py +0 -0
  22. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/finder.py +0 -0
  23. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/publisher.py +0 -0
  24. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/py.typed +0 -0
  25. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/python_package_folder.py +0 -0
  26. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/subfolder_build.py +0 -0
  27. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/types.py +0 -0
  28. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/utils.py +0 -0
  29. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/src/python_package_folder/version.py +0 -0
  30. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/conftest.py +0 -0
  31. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/folder_structure/some_globals.py +0 -0
  32. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  33. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  34. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  35. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  36. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  37. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  38. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/test_build_with_external_deps.py +0 -0
  39. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/test_linting.py +0 -0
  40. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/test_publisher.py +0 -0
  41. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/test_subfolder_build.py +0 -0
  42. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/test_third_party_dependencies.py +0 -0
  43. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/test_utils.py +0 -0
  44. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/test_version_manager.py +0 -0
  45. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/tests/tests.py +0 -0
  46. {python_package_folder-2.0.4 → python_package_folder-2.0.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 2.0.4
3
+ Version: 2.0.6
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">69%</text>
18
- <text x="81" y="14">69%</text>
17
+ <text x="81" y="15" fill="#010101" fill-opacity=".3">68%</text>
18
+ <text x="81" y="14">68%</text>
19
19
  </g>
20
20
  </svg>
@@ -328,6 +328,26 @@ class ImportAnalyzer:
328
328
  if potential_file.exists():
329
329
  return potential_file
330
330
 
331
+ # Check common subdirectories in parent (e.g., _shared, shared, common)
332
+ # This handles cases like src/_shared/better_enum.py
333
+ common_subdirs = ["_shared", "shared", "common", "_common"]
334
+ for subdir_name in common_subdirs:
335
+ subdir = parent / subdir_name
336
+ if subdir.exists() and subdir.is_dir():
337
+ # Check if module file exists in subdirectory
338
+ potential_subdir_file = subdir / f"{module_name.split('.')[-1]}.py"
339
+ if potential_subdir_file.exists():
340
+ return potential_subdir_file
341
+ # Check if module directory exists in subdirectory
342
+ potential_subdir_module = subdir / module_name.replace(".", "/")
343
+ if (
344
+ potential_subdir_module.is_dir()
345
+ and (potential_subdir_module / "__init__.py").exists()
346
+ ):
347
+ return potential_subdir_module / "__init__.py"
348
+ if potential_subdir_module.with_suffix(".py").is_file():
349
+ return potential_subdir_module.with_suffix(".py")
350
+
331
351
  return None
332
352
 
333
353
  def is_third_party(self, module_name: str) -> bool:
@@ -86,6 +86,8 @@ class BuildManager:
86
86
  self.project_root, self.src_dir, exclude_patterns=exclude_patterns
87
87
  )
88
88
  self.subfolder_config: SubfolderBuildConfig | None = None
89
+ # Cache for package name lookups (expensive operation)
90
+ self._packages_distributions_cache: dict[str, list[str]] | None = None
89
91
 
90
92
  # Check if it's a valid Python package directory
91
93
  if not any(self.src_dir.glob("*.py")) and not (self.src_dir / "__init__.py").exists():
@@ -271,7 +273,9 @@ class BuildManager:
271
273
  # For subfolder builds, extract third-party dependencies and add to pyproject.toml
272
274
  if self._is_subfolder_build() and self.subfolder_config:
273
275
  # Re-analyze all Python files (including copied dependencies) to find third-party imports
276
+ print("Analyzing Python files for third-party dependencies...")
274
277
  all_python_files = analyzer.find_all_python_files(self.src_dir)
278
+ print(f"Found {len(all_python_files)} Python files to analyze")
275
279
  third_party_deps = self._extract_third_party_dependencies(all_python_files, analyzer)
276
280
  if third_party_deps:
277
281
  print(
@@ -413,8 +417,12 @@ class BuildManager:
413
417
  import importlib.metadata as importlib_metadata
414
418
 
415
419
  # Use packages_distributions() if available (Python 3.10+)
420
+ # Cache the result since it's expensive to call
416
421
  if hasattr(importlib_metadata, "packages_distributions"):
417
- packages_map = importlib_metadata.packages_distributions()
422
+ if self._packages_distributions_cache is None:
423
+ # Cache the packages_distributions() result
424
+ self._packages_distributions_cache = importlib_metadata.packages_distributions()
425
+ packages_map = self._packages_distributions_cache
418
426
  # packages_map is a dict mapping module names to list of distribution names
419
427
  if root_module in packages_map:
420
428
  # Return the first distribution name (usually there's only one)
@@ -422,26 +430,45 @@ class BuildManager:
422
430
  if dist_names:
423
431
  return dist_names[0]
424
432
 
425
- # Fallback: search all distributions
433
+ # Fallback: search all distributions (this can be slow, so limit search)
434
+ # Only check top-level package matches to speed up search
435
+ dist_count = 0
436
+ max_distributions_to_check = 1000 # Limit to prevent excessive searching
426
437
  for dist in importlib_metadata.distributions():
438
+ dist_count += 1
439
+ if dist_count > max_distributions_to_check:
440
+ # Too many distributions, give up to avoid hanging
441
+ break
427
442
  try:
428
- # Check if this distribution provides the module
429
- # by looking at its files
430
- files = dist.files or []
431
- for file in files:
432
- file_str = str(file)
433
- # Check if file is the module itself or in a package directory
434
- if (
435
- file.suffix == ".py"
436
- and (file.stem == root_module or file.stem == "__init__")
437
- ) or (
438
- "/" in file_str
439
- and (
440
- file_str.startswith(f"{root_module}/")
441
- or file_str.startswith(f"{root_module.replace('_', '-')}/")
442
- )
443
- ):
444
- return dist.metadata["Name"]
443
+ # Check distribution name first (fast check)
444
+ dist_name = dist.metadata.get("Name", "")
445
+ # If distribution name matches or contains the module name, check files
446
+ if dist_name.lower().replace(
447
+ "-", "_"
448
+ ) == root_module.lower() or root_module.lower() in dist_name.lower().replace(
449
+ "-", "_"
450
+ ):
451
+ # Check if this distribution provides the module by looking at its files
452
+ files = dist.files or []
453
+ # Limit file checking to first 100 files per distribution
454
+ file_count = 0
455
+ for file in files:
456
+ file_count += 1
457
+ if file_count > 100:
458
+ break
459
+ file_str = str(file)
460
+ # Check if file is the module itself or in a package directory
461
+ if (
462
+ file.suffix == ".py"
463
+ and (file.stem == root_module or file.stem == "__init__")
464
+ ) or (
465
+ "/" in file_str
466
+ and (
467
+ file_str.startswith(f"{root_module}/")
468
+ or file_str.startswith(f"{root_module.replace('_', '-')}/")
469
+ )
470
+ ):
471
+ return dist.metadata["Name"]
445
472
  except Exception:
446
473
  continue
447
474
 
@@ -494,8 +521,14 @@ class BuildManager:
494
521
  List of unique third-party package names (e.g., ["pypdf", "requests", "pymupdf"])
495
522
  """
496
523
  third_party_packages: set[str] = set()
524
+ # Cache package name lookups to avoid repeated expensive searches
525
+ package_name_cache: dict[str, str | None] = {}
526
+
527
+ total_files = len(python_files)
528
+ for idx, file_path in enumerate(python_files):
529
+ if idx > 0 and idx % 50 == 0:
530
+ print(f" Analyzing file {idx}/{total_files}...", end="\r", flush=True)
497
531
 
498
- for file_path in python_files:
499
532
  imports = analyzer.extract_imports(file_path)
500
533
  for imp in imports:
501
534
  analyzer.classify_import(imp, self.src_dir)
@@ -508,10 +541,22 @@ class BuildManager:
508
541
  if root_module in stdlib_modules:
509
542
  continue
510
543
 
544
+ # Skip if it's local or external (already copied, don't add as dependency)
545
+ if imp.classification in ("local", "external"):
546
+ continue
547
+
511
548
  # If classified as third_party, try to get actual package name
512
549
  if imp.classification == "third_party":
513
- # Try to get the actual package name from metadata
514
- actual_package = self._get_package_name_from_import(imp.module_name)
550
+ # Double-check: if it resolves to a file in src_dir, it's actually local
551
+ # (might have been copied and now resolves locally)
552
+ if imp.resolved_path and imp.resolved_path.is_relative_to(self.src_dir):
553
+ continue # Skip - it's a local file, not a third-party package
554
+ # Check cache first
555
+ if root_module not in package_name_cache:
556
+ package_name_cache[root_module] = self._get_package_name_from_import(
557
+ imp.module_name
558
+ )
559
+ actual_package = package_name_cache[root_module]
515
560
  if actual_package:
516
561
  third_party_packages.add(actual_package)
517
562
  else:
@@ -522,15 +567,21 @@ class BuildManager:
522
567
  elif imp.classification == "ambiguous" or imp.classification is None:
523
568
  # Check if it's not a local or external module
524
569
  if not imp.resolved_path:
525
- # Try to get the actual package name from metadata
526
- # (might work if package is installed but module path is unusual)
527
- actual_package = self._get_package_name_from_import(imp.module_name)
570
+ # Check cache first
571
+ if root_module not in package_name_cache:
572
+ package_name_cache[root_module] = self._get_package_name_from_import(
573
+ imp.module_name
574
+ )
575
+ actual_package = package_name_cache[root_module]
528
576
  if actual_package:
529
577
  third_party_packages.add(actual_package)
530
578
  else:
531
579
  # Fallback: use import name (will be normalized later)
532
580
  third_party_packages.add(root_module)
533
581
 
582
+ if total_files > 50:
583
+ print() # New line after progress indicator
584
+
534
585
  return sorted(list(third_party_packages))
535
586
 
536
587
  def _report_ambiguous_imports(self, python_files: list[Path]) -> None:
@@ -0,0 +1,107 @@
1
+ """Tests for imports from _shared subdirectories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from python_package_folder import BuildManager, ImportAnalyzer, ImportInfo
10
+
11
+
12
+ @pytest.fixture
13
+ def test_project_with_shared(tmp_path: Path) -> Path:
14
+ """Create a test project with _shared subdirectory."""
15
+ project_root = tmp_path / "test_project"
16
+ project_root.mkdir()
17
+
18
+ # Create src directory
19
+ src_dir = project_root / "src"
20
+ src_dir.mkdir()
21
+
22
+ # Create _shared directory with better_enum.py
23
+ shared_dir = src_dir / "_shared"
24
+ shared_dir.mkdir()
25
+ (shared_dir / "better_enum.py").write_text(
26
+ """class Enum:
27
+ pass
28
+ """
29
+ )
30
+
31
+ # Create subfolder_to_build
32
+ subfolder = src_dir / "integration" / "empty_drawing_detection"
33
+ subfolder.mkdir(parents=True)
34
+ (subfolder / "module.py").write_text(
35
+ """from better_enum import Enum
36
+
37
+ def use_enum():
38
+ return Enum
39
+ """
40
+ )
41
+
42
+ return project_root
43
+
44
+
45
+ class TestSharedSubdirectoryImports:
46
+ """Tests for imports from _shared subdirectories."""
47
+
48
+ def test_resolve_better_enum_from_shared(self, test_project_with_shared: Path) -> None:
49
+ """Test that better_enum is resolved from src/_shared/better_enum.py."""
50
+ project_root = test_project_with_shared
51
+ src_dir = project_root / "src" / "integration" / "empty_drawing_detection"
52
+
53
+ analyzer = ImportAnalyzer(project_root)
54
+
55
+ # Create import info for better_enum
56
+ import_info = ImportInfo(
57
+ module_name="better_enum",
58
+ import_type="from",
59
+ line_number=1,
60
+ file_path=src_dir / "module.py",
61
+ )
62
+
63
+ # Classify the import
64
+ analyzer.classify_import(import_info, src_dir)
65
+
66
+ # Should be classified as external (not third_party)
67
+ assert import_info.classification == "external"
68
+ assert import_info.resolved_path is not None
69
+ assert import_info.resolved_path.name == "better_enum.py"
70
+ # Should resolve to src/_shared/better_enum.py
71
+ assert "_shared" in str(import_info.resolved_path)
72
+ assert import_info.resolved_path.exists()
73
+
74
+ def test_better_enum_copied_not_added_as_dependency(
75
+ self, test_project_with_shared: Path
76
+ ) -> None:
77
+ """Test that better_enum is copied as external dependency, not added as third-party."""
78
+ project_root = test_project_with_shared
79
+ src_dir = project_root / "src" / "integration" / "empty_drawing_detection"
80
+
81
+ manager = BuildManager(project_root, src_dir)
82
+
83
+ # Prepare build
84
+ external_deps = manager.prepare_build(version="1.0.0", package_name="test-package")
85
+
86
+ # Should find better_enum as an external dependency to copy
87
+ better_enum_deps = [dep for dep in external_deps if "better_enum" in dep.import_name]
88
+ assert len(better_enum_deps) > 0, "better_enum should be found as external dependency"
89
+
90
+ # Verify _shared directory was copied (which contains better_enum.py)
91
+ copied_shared_dir = src_dir / "_shared"
92
+ assert copied_shared_dir.exists(), "_shared directory should be copied to subfolder"
93
+ copied_file = copied_shared_dir / "better_enum.py"
94
+ assert copied_file.exists(), "better_enum.py should be in copied _shared directory"
95
+
96
+ # Check that subfolder_config exists (for subfolder builds)
97
+ if manager.subfolder_config:
98
+ # Read the temporary pyproject.toml
99
+ pyproject_path = project_root / "pyproject.toml"
100
+ if pyproject_path.exists():
101
+ content = pyproject_path.read_text()
102
+ # Should NOT have better-enum or better_enum in dependencies
103
+ # (it should be copied, not added as dependency)
104
+ assert '"better-enum"' not in content
105
+ assert '"better_enum"' not in content
106
+
107
+ manager.cleanup()