python-package-folder 6.0.0__tar.gz → 7.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.
Files changed (60) hide show
  1. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/PKG-INFO +1 -1
  2. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/pyproject.toml +1 -1
  3. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/analyzer.py +16 -5
  4. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/manager.py +50 -0
  5. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/subfolder_build.py +144 -77
  6. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_subfolder_build.py +249 -15
  7. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.copier-answers.yml +0 -0
  8. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  9. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.cursor/plans/replace_node.js_semantic-release_with_custom_python_implementation_64e05e1a.plan.md +0 -0
  10. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.cursor/rules/general.mdc +0 -0
  11. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.cursor/rules/python.mdc +0 -0
  12. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.github/workflows/ci.yml +0 -0
  13. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.github/workflows/publish.yml +0 -0
  14. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.gitignore +0 -0
  15. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/.vscode/settings.json +0 -0
  16. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/LICENSE +0 -0
  17. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/MANIFEST.in +0 -0
  18. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/Makefile +0 -0
  19. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/README.md +0 -0
  20. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/coverage.svg +0 -0
  21. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/development.md +0 -0
  22. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/docs/DEVELOPMENT.md +0 -0
  23. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/docs/INSTALLATION.md +0 -0
  24. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/docs/PUBLISHING.md +0 -0
  25. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/docs/REFERENCE.md +0 -0
  26. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/docs/USAGE.md +0 -0
  27. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/docs/VERSION_RESOLUTION.md +0 -0
  28. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/installation.md +0 -0
  29. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/publishing.md +0 -0
  30. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/__init__.py +0 -0
  31. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/__main__.py +0 -0
  32. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/finder.py +0 -0
  33. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/publisher.py +0 -0
  34. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/py.typed +0 -0
  35. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/python_package_folder.py +0 -0
  36. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/types.py +0 -0
  37. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/utils.py +0 -0
  38. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/version.py +0 -0
  39. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/src/python_package_folder/version_calculator.py +0 -0
  40. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/conftest.py +0 -0
  41. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/folder_structure/some_globals.py +0 -0
  42. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  43. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  44. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  45. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  46. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  47. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  48. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_build_with_external_deps.py +0 -0
  49. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_exclude_patterns.py +0 -0
  50. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_linting.py +0 -0
  51. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_preserve_directory_structure.py +0 -0
  52. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_publisher.py +0 -0
  53. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_shared_subdirectory_imports.py +0 -0
  54. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_spreadsheet_creation_imports.py +0 -0
  55. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_third_party_dependencies.py +0 -0
  56. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_utils.py +0 -0
  57. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_version_calculator.py +0 -0
  58. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/test_version_manager.py +0 -0
  59. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/tests/tests.py +0 -0
  60. {python_package_folder-6.0.0 → python_package_folder-7.1.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 6.0.0
3
+ Version: 7.1.0
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>
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  # ---- Dev dependencies ----
45
45
 
46
- version = "6.0.0"
46
+ version = "7.1.0"
47
47
  [dependency-groups]
48
48
  dev = [
49
49
  "pytest>=8.3.5",
@@ -227,9 +227,22 @@ class ImportAnalyzer:
227
227
  import_info.classification = "stdlib"
228
228
  return
229
229
 
230
+ # Check if it's a third-party package (in site-packages) FIRST
231
+ # This must be checked before resolve_local_import to avoid incorrectly
232
+ # classifying site-packages modules as "external" when they're found
233
+ # by the recursive search
234
+ if self.is_third_party(module_name):
235
+ import_info.classification = "third_party"
236
+ return
237
+
230
238
  # Try to resolve as a local import
231
239
  resolved = self.resolve_local_import(import_info, src_dir)
232
240
  if resolved is not None:
241
+ # Double-check: if resolved path is in site-packages, it's actually third-party
242
+ # (this can happen if the recursive search finds it before importlib does)
243
+ if "site-packages" in str(resolved) or "dist-packages" in str(resolved):
244
+ import_info.classification = "third_party"
245
+ return
233
246
  if resolved.is_relative_to(src_dir):
234
247
  import_info.classification = "local"
235
248
  else:
@@ -237,11 +250,6 @@ class ImportAnalyzer:
237
250
  import_info.resolved_path = resolved
238
251
  return
239
252
 
240
- # Check if it's a third-party package (in site-packages)
241
- if self.is_third_party(module_name):
242
- import_info.classification = "third_party"
243
- return
244
-
245
253
  # Mark as ambiguous if we can't determine
246
254
  import_info.classification = "ambiguous"
247
255
 
@@ -341,6 +349,9 @@ class ImportAnalyzer:
341
349
  # Only search within project_root to avoid going too far
342
350
  if not potential_file.is_relative_to(self.project_root):
343
351
  continue
352
+ # Skip site-packages and dist-packages (these are third-party, not external)
353
+ if "site-packages" in str(potential_file) or "dist-packages" in str(potential_file):
354
+ continue
344
355
  # Skip excluded patterns
345
356
  if any(
346
357
  part.startswith("_SS")
@@ -1142,6 +1142,26 @@ class BuildManager:
1142
1142
  else:
1143
1143
  print("No external dependencies found\n")
1144
1144
 
1145
+ # Verify temporary package directory exists if using subfolder build
1146
+ if self.subfolder_config and self.subfolder_config._temp_package_dir:
1147
+ temp_dir = self.subfolder_config._temp_package_dir
1148
+ if not temp_dir.exists():
1149
+ raise RuntimeError(
1150
+ f"Temporary package directory does not exist: {temp_dir}. "
1151
+ "This should have been created during prepare_build()."
1152
+ )
1153
+ # Verify it contains Python files
1154
+ py_files = list(temp_dir.glob("*.py"))
1155
+ if not py_files:
1156
+ raise RuntimeError(
1157
+ f"Temporary package directory exists but contains no Python files: {temp_dir}"
1158
+ )
1159
+ # Verify __init__.py exists
1160
+ if not (temp_dir / "__init__.py").exists():
1161
+ raise RuntimeError(
1162
+ f"Temporary package directory missing __init__.py: {temp_dir}"
1163
+ )
1164
+
1145
1165
  print("Running build...")
1146
1166
  # Build command should run from project root to find pyproject.toml
1147
1167
  import os
@@ -1152,6 +1172,36 @@ class BuildManager:
1152
1172
  build_command()
1153
1173
  finally:
1154
1174
  os.chdir(original_cwd)
1175
+
1176
+ # Verify wheel contents after build (for subfolder builds)
1177
+ if self.subfolder_config and self.subfolder_config.package_name:
1178
+ import_name = self.subfolder_config.package_name.replace("-", "_")
1179
+ dist_dir = self.project_root / "dist"
1180
+ if dist_dir.exists():
1181
+ wheel_files = list(dist_dir.glob("*.whl"))
1182
+ if wheel_files:
1183
+ import zipfile
1184
+ wheel_file = wheel_files[0]
1185
+ try:
1186
+ with zipfile.ZipFile(wheel_file, "r") as wheel:
1187
+ file_names = wheel.namelist()
1188
+ package_files = [f for f in file_names if f.startswith(f"{import_name}/")]
1189
+ if not package_files:
1190
+ print(
1191
+ f"\nWARNING: Built wheel does not contain package directory '{import_name}/'. "
1192
+ f"Only found: {[f for f in file_names if '.dist-info' not in f][:10]}",
1193
+ file=sys.stderr,
1194
+ )
1195
+ else:
1196
+ print(
1197
+ f"\nVerified wheel contains package directory '{import_name}/' "
1198
+ f"with {len(package_files)} files"
1199
+ )
1200
+ except Exception as e:
1201
+ print(
1202
+ f"\nWARNING: Could not verify wheel contents: {e}",
1203
+ file=sys.stderr,
1204
+ )
1155
1205
 
1156
1206
  finally:
1157
1207
  print("\nCleaning up copied files...")
@@ -130,8 +130,8 @@ class SubfolderBuildConfig:
130
130
  The package name (with hyphens) is converted to the import name (with underscores).
131
131
  For example: 'ml-drawing-assistant-data' -> 'ml_drawing_assistant_data'
132
132
 
133
- The temporary directory is created in the project root and contains a copy
134
- of the source directory contents.
133
+ The temporary directory is created in the project root with the import name directly.
134
+ This way, hatchling will install it with the correct name without needing force-include.
135
135
  """
136
136
  if not self.package_name:
137
137
  return
@@ -140,20 +140,42 @@ class SubfolderBuildConfig:
140
140
  # PyPI package names use hyphens, but Python import names use underscores
141
141
  import_name = self.package_name.replace("-", "_")
142
142
 
143
- # Create temporary directory name
144
- temp_dir_name = f".temp_package_{import_name}"
145
- temp_package_dir = self.project_root / temp_dir_name
143
+ # Create temporary directory with the import name directly
144
+ # This way, hatchling will install it with the correct name
145
+ import_name_dir = self.project_root / import_name
146
146
 
147
- # Remove if it already exists (from a previous failed build)
148
- if temp_package_dir.exists():
149
- shutil.rmtree(temp_package_dir)
147
+ # Check if the directory already exists and is the correct one
148
+ if import_name_dir.exists() and import_name_dir == self._temp_package_dir:
149
+ # Directory already exists and is the correct one, no need to recreate
150
+ return
151
+
152
+ # Remove if it already exists (from a previous build)
153
+ if import_name_dir.exists():
154
+ shutil.rmtree(import_name_dir)
155
+
156
+ # Copy the entire source directory contents directly to the import name directory
157
+ # Check if src_dir exists and is a directory before copying
158
+ if not self.src_dir.exists():
159
+ print(
160
+ f"Warning: Source directory does not exist: {self.src_dir}",
161
+ file=sys.stderr,
162
+ )
163
+ self._temp_package_dir = None
164
+ return
165
+
166
+ if not self.src_dir.is_dir():
167
+ print(
168
+ f"Warning: Source path is not a directory: {self.src_dir}",
169
+ file=sys.stderr,
170
+ )
171
+ self._temp_package_dir = None
172
+ return
150
173
 
151
- # Copy the entire source directory contents to the temporary directory
152
174
  try:
153
- shutil.copytree(self.src_dir, temp_package_dir)
154
- self._temp_package_dir = temp_package_dir
175
+ shutil.copytree(self.src_dir, import_name_dir)
176
+ self._temp_package_dir = import_name_dir
155
177
  print(
156
- f"Created temporary package directory: {temp_package_dir} "
178
+ f"Created temporary package directory: {import_name_dir} "
157
179
  f"(import name: {import_name})"
158
180
  )
159
181
  except Exception as e:
@@ -344,6 +366,116 @@ class SubfolderBuildConfig:
344
366
  if not self.version:
345
367
  raise ValueError("Version is required for subfolder builds")
346
368
 
369
+ # Check if pyproject.toml exists in subfolder FIRST
370
+ # This allows us to handle subfolder pyproject.toml even when parent doesn't exist
371
+ # But first ensure src_dir exists
372
+ if not self.src_dir.exists() or not self.src_dir.is_dir():
373
+ # If src_dir doesn't exist, we can't proceed
374
+ print(
375
+ f"Warning: Source directory does not exist or is not a directory: {self.src_dir}",
376
+ file=sys.stderr,
377
+ )
378
+ return None
379
+
380
+ subfolder_pyproject = self.src_dir / "pyproject.toml"
381
+ if subfolder_pyproject.exists() and subfolder_pyproject.is_file():
382
+ # Read the subfolder pyproject.toml content IMMEDIATELY after checking it exists
383
+ # This prevents any issues if the file is affected by subsequent operations
384
+ try:
385
+ subfolder_content = subfolder_pyproject.read_text(encoding="utf-8")
386
+ except (FileNotFoundError, OSError) as e:
387
+ # File was deleted or inaccessible between check and read
388
+ print(
389
+ f"Warning: Could not read subfolder pyproject.toml at {subfolder_pyproject}: {e}. "
390
+ "Falling back to creating from parent.",
391
+ file=sys.stderr,
392
+ )
393
+ subfolder_content = None
394
+
395
+ if subfolder_content is not None:
396
+ # Ensure src_dir is a package (has __init__.py) before creating temp directory
397
+ # This way the __init__.py will be copied to the temp directory
398
+ init_file = self.src_dir / "__init__.py"
399
+ if not init_file.exists():
400
+ # Create a temporary __init__.py to make it a package
401
+ init_file.write_text("# Temporary __init__.py for build\n", encoding="utf-8")
402
+ self._temp_init_created = True
403
+ else:
404
+ self._temp_init_created = False
405
+
406
+ # Create temporary package directory with correct import name
407
+ # This will copy the __init__.py we just created (if any)
408
+ self._create_temp_package_directory()
409
+
410
+ # Determine which directory to use (temp package dir or src_dir)
411
+ package_dir = self._temp_package_dir if self._temp_package_dir and self._temp_package_dir.exists() else self.src_dir
412
+ # Use the subfolder's pyproject.toml
413
+ print(f"Using existing pyproject.toml from subfolder: {subfolder_pyproject}")
414
+ self._used_subfolder_pyproject = True
415
+
416
+ # Store reference to original project root pyproject.toml
417
+ original_pyproject = self.project_root / "pyproject.toml"
418
+ self.original_pyproject_path = original_pyproject
419
+
420
+ # Create temporary pyproject.toml file
421
+ temp_pyproject_path = self.project_root / "pyproject.toml.temp"
422
+
423
+ # Adjust packages path to be relative to project root
424
+ adjusted_content = self._adjust_subfolder_pyproject_packages_path(subfolder_content)
425
+
426
+ # Read exclude patterns from root pyproject.toml and inject them (if it exists)
427
+ exclude_patterns = []
428
+ if original_pyproject.exists():
429
+ exclude_patterns = read_exclude_patterns(original_pyproject)
430
+ print(
431
+ f"INFO: Read exclude patterns from {original_pyproject}: {exclude_patterns}",
432
+ file=sys.stderr,
433
+ )
434
+ else:
435
+ print(
436
+ f"INFO: No parent pyproject.toml found at {original_pyproject}, skipping exclude patterns",
437
+ file=sys.stderr,
438
+ )
439
+ if exclude_patterns:
440
+ adjusted_content = self._inject_exclude_patterns(adjusted_content, exclude_patterns)
441
+
442
+ # Write adjusted content to temporary file
443
+ temp_pyproject_path.write_text(adjusted_content, encoding="utf-8")
444
+ self.temp_pyproject = temp_pyproject_path
445
+
446
+ # Print the temporary pyproject.toml content for debugging
447
+ print("\n" + "=" * 80)
448
+ print("Temporary pyproject.toml content (from subfolder pyproject.toml):")
449
+ print("=" * 80)
450
+ print(adjusted_content)
451
+ print("=" * 80 + "\n")
452
+
453
+ # If original pyproject.toml exists, temporarily move it
454
+ if original_pyproject.exists():
455
+ backup_path = self.project_root / "pyproject.toml.original"
456
+ # Remove backup if it already exists (from previous failed test or run)
457
+ if backup_path.exists():
458
+ backup_path.unlink()
459
+ original_pyproject.rename(backup_path)
460
+ self.original_pyproject_backup = backup_path
461
+
462
+ # Move temp file to pyproject.toml for the build
463
+ temp_pyproject_path.rename(original_pyproject)
464
+ self.temp_pyproject = original_pyproject
465
+
466
+ # Handle README file
467
+ self._handle_readme()
468
+
469
+ # Exclude files matching exclude patterns
470
+ if exclude_patterns:
471
+ self._exclude_files_by_patterns(exclude_patterns)
472
+
473
+ return original_pyproject
474
+
475
+ # No pyproject.toml in subfolder, create one from parent
476
+ self._used_subfolder_pyproject = False
477
+ print("No pyproject.toml found in subfolder, creating temporary one from parent")
478
+
347
479
  # Ensure src_dir is a package (has __init__.py) before creating temp directory
348
480
  # This way the __init__.py will be copied to the temp directory
349
481
  init_file = self.src_dir / "__init__.py"
@@ -361,71 +493,6 @@ class SubfolderBuildConfig:
361
493
  # Determine which directory to use (temp package dir or src_dir)
362
494
  package_dir = self._temp_package_dir if self._temp_package_dir and self._temp_package_dir.exists() else self.src_dir
363
495
 
364
- # Check if pyproject.toml exists in subfolder
365
- subfolder_pyproject = self.src_dir / "pyproject.toml"
366
- if subfolder_pyproject.exists():
367
- # Use the subfolder's pyproject.toml
368
- print(f"Using existing pyproject.toml from subfolder: {subfolder_pyproject}")
369
- self._used_subfolder_pyproject = True
370
-
371
- # Store reference to original project root pyproject.toml
372
- original_pyproject = self.project_root / "pyproject.toml"
373
- self.original_pyproject_path = original_pyproject
374
-
375
- # Create temporary pyproject.toml file
376
- temp_pyproject_path = self.project_root / "pyproject.toml.temp"
377
-
378
- # Read and adjust the subfolder pyproject.toml
379
- subfolder_content = subfolder_pyproject.read_text(encoding="utf-8")
380
- # Adjust packages path to be relative to project root
381
- adjusted_content = self._adjust_subfolder_pyproject_packages_path(subfolder_content)
382
-
383
- # Read exclude patterns from root pyproject.toml and inject them
384
- exclude_patterns = read_exclude_patterns(original_pyproject)
385
- print(
386
- f"INFO: Read exclude patterns from {original_pyproject}: {exclude_patterns}",
387
- file=sys.stderr,
388
- )
389
- if exclude_patterns:
390
- adjusted_content = self._inject_exclude_patterns(adjusted_content, exclude_patterns)
391
-
392
- # Write adjusted content to temporary file
393
- temp_pyproject_path.write_text(adjusted_content, encoding="utf-8")
394
- self.temp_pyproject = temp_pyproject_path
395
-
396
- # Print the temporary pyproject.toml content for debugging
397
- print("\n" + "=" * 80)
398
- print("Temporary pyproject.toml content (from subfolder pyproject.toml):")
399
- print("=" * 80)
400
- print(adjusted_content)
401
- print("=" * 80 + "\n")
402
-
403
- # If original pyproject.toml exists, temporarily move it
404
- if original_pyproject.exists():
405
- backup_path = self.project_root / "pyproject.toml.original"
406
- # Remove backup if it already exists (from previous failed test or run)
407
- if backup_path.exists():
408
- backup_path.unlink()
409
- original_pyproject.rename(backup_path)
410
- self.original_pyproject_backup = backup_path
411
-
412
- # Move temp file to pyproject.toml for the build
413
- temp_pyproject_path.rename(original_pyproject)
414
- self.temp_pyproject = original_pyproject
415
-
416
- # Handle README file
417
- self._handle_readme()
418
-
419
- # Exclude files matching exclude patterns
420
- if exclude_patterns:
421
- self._exclude_files_by_patterns(exclude_patterns)
422
-
423
- return original_pyproject
424
-
425
- # No pyproject.toml in subfolder, create one from parent
426
- self._used_subfolder_pyproject = False
427
- print("No pyproject.toml found in subfolder, creating temporary one from parent")
428
-
429
496
  # Read the original pyproject.toml
430
497
  original_pyproject = self.project_root / "pyproject.toml"
431
498
  if not original_pyproject.exists():
@@ -2,11 +2,16 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import venv
9
+ import zipfile
5
10
  from pathlib import Path
6
11
 
7
12
  import pytest
8
13
 
9
- from python_package_folder import SubfolderBuildConfig
14
+ from python_package_folder import BuildManager, SubfolderBuildConfig
10
15
 
11
16
 
12
17
  @pytest.fixture
@@ -683,8 +688,8 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
683
688
  assert "[tool.hatch.version]" not in content
684
689
  assert "[tool.uv-dynamic-versioning]" not in content
685
690
 
686
- # Verify packages path is set correctly (should use temp package directory)
687
- assert '.temp_package_my_custom_package' in content
691
+ # Verify packages path is set correctly (should use import name, not temp directory name)
692
+ assert '"my_custom_package"' in content or "'my_custom_package'" in content
688
693
 
689
694
  # Verify backup was created
690
695
  assert (project_root / "pyproject.toml.original").exists()
@@ -734,8 +739,9 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
734
739
  # Verify only-include is present
735
740
  assert "only-include = [" in content
736
741
 
737
- # Verify the temp package directory is included (not the original subfolder)
738
- assert '.temp_package_test_package' in content
742
+ # Verify the import name is used in packages configuration (not the original subfolder)
743
+ # The temp directory is renamed to the import name, so packages should use that
744
+ assert '"test_package"' in content or "'test_package'" in content
739
745
 
740
746
  # Verify necessary files are included
741
747
  assert '"pyproject.toml"' in content
@@ -975,8 +981,9 @@ class TestTemporaryPackageDirectory:
975
981
  # Create temp pyproject (which creates temp package directory)
976
982
  config.create_temp_pyproject()
977
983
 
978
- # Temp package directory should exist with import name (underscores)
979
- temp_package_dir = project_root / ".temp_package_test_package_subfolder"
984
+ # Temp package directory should exist with import name (underscores, no temp prefix)
985
+ import_name = "test_package_subfolder" # Import name from "test-package-subfolder"
986
+ temp_package_dir = project_root / import_name
980
987
  assert temp_package_dir.exists()
981
988
  assert config._temp_package_dir == temp_package_dir
982
989
 
@@ -1003,8 +1010,9 @@ class TestTemporaryPackageDirectory:
1003
1010
 
1004
1011
  config.create_temp_pyproject()
1005
1012
 
1006
- # Temp directory should use underscores (import name)
1007
- temp_package_dir = project_root / ".temp_package_my_custom_package"
1013
+ # Temp directory should use underscores (import name, no temp prefix)
1014
+ import_name = "my_custom_package" # Import name from "my-custom-package"
1015
+ temp_package_dir = project_root / import_name
1008
1016
  assert temp_package_dir.exists()
1009
1017
  assert config._temp_package_dir == temp_package_dir
1010
1018
 
@@ -1055,9 +1063,9 @@ class TestTemporaryPackageDirectory:
1055
1063
 
1056
1064
  content = pyproject_path.read_text()
1057
1065
 
1058
- # Packages configuration should use temp directory path
1059
- # Temp directory name is ".temp_package_test_package_subfolder"
1060
- assert ".temp_package_test_package_subfolder" in content
1066
+ # Packages configuration should use import name (temp directory is renamed to import name)
1067
+ # Import name is "test_package_subfolder" (from "test-package-subfolder")
1068
+ assert '"test_package_subfolder"' in content or "'test_package_subfolder'" in content
1061
1069
 
1062
1070
  config.restore()
1063
1071
 
@@ -1098,8 +1106,9 @@ class TestTemporaryPackageDirectory:
1098
1106
  subfolder = project_root / "subfolder"
1099
1107
  (subfolder / "module.py").write_text("def func(): pass")
1100
1108
 
1101
- # Create a directory that would conflict
1102
- existing_temp_dir = project_root / ".temp_package_test_package_subfolder"
1109
+ # Create a directory that would conflict (using import name directly)
1110
+ import_name = "test_package_subfolder" # Import name from "test-package-subfolder"
1111
+ existing_temp_dir = project_root / import_name
1103
1112
  existing_temp_dir.mkdir()
1104
1113
  (existing_temp_dir / "old_file.py").write_text("# Old file")
1105
1114
 
@@ -1119,4 +1128,229 @@ class TestTemporaryPackageDirectory:
1119
1128
  assert (temp_package_dir / "module.py").exists()
1120
1129
  assert not (temp_package_dir / "old_file.py").exists()
1121
1130
 
1122
- config.restore()
1131
+ config.restore()
1132
+
1133
+
1134
+ class TestWheelPackaging:
1135
+ """Tests to verify that wheels are correctly packaged with the right directory structure."""
1136
+
1137
+ def test_wheel_contains_package_directory_with_correct_name(self, tmp_path: Path) -> None:
1138
+ """Test that a built wheel contains the package directory with the correct import name."""
1139
+ project_root = tmp_path / "test_project"
1140
+ project_root.mkdir()
1141
+
1142
+ # Create pyproject.toml
1143
+ pyproject_content = """[project]
1144
+ name = "test-package"
1145
+ version = "0.1.0"
1146
+
1147
+ [build-system]
1148
+ requires = ["hatchling"]
1149
+ build-backend = "hatchling.build"
1150
+ """
1151
+ (project_root / "pyproject.toml").write_text(pyproject_content)
1152
+
1153
+ # Create subfolder with package name that has hyphens
1154
+ subfolder = project_root / "src" / "data"
1155
+ subfolder.mkdir(parents=True)
1156
+
1157
+ # Create some Python files
1158
+ (subfolder / "__init__.py").write_text("# Package init")
1159
+ (subfolder / "module.py").write_text("def hello(): return 'world'")
1160
+ (subfolder / "utils.py").write_text("def util(): return 'helper'")
1161
+
1162
+ # Package name with hyphens (like ml-drawing-assistant-data)
1163
+ package_name = "ml-drawing-assistant-data"
1164
+ import_name = "ml_drawing_assistant_data" # Expected import name
1165
+ version = "1.0.0"
1166
+
1167
+ # Build the wheel
1168
+ manager = BuildManager(project_root=project_root, src_dir=subfolder)
1169
+
1170
+ def build_wheel() -> None:
1171
+ """Build the wheel using uv build."""
1172
+ subprocess.run(
1173
+ ["uv", "build", "--wheel"],
1174
+ cwd=project_root,
1175
+ check=True,
1176
+ capture_output=True,
1177
+ )
1178
+
1179
+ try:
1180
+ # run_build will call prepare_build internally, so we don't need to call it explicitly
1181
+ manager.run_build(build_wheel, version=version, package_name=package_name)
1182
+ finally:
1183
+ manager.cleanup()
1184
+
1185
+ # Find the built wheel
1186
+ dist_dir = project_root / "dist"
1187
+ assert dist_dir.exists(), "dist directory should exist after build"
1188
+
1189
+ wheel_files = list(dist_dir.glob("*.whl"))
1190
+ assert len(wheel_files) > 0, "At least one wheel should be built"
1191
+
1192
+ wheel_file = wheel_files[0]
1193
+
1194
+ # Extract and inspect the wheel
1195
+ with zipfile.ZipFile(wheel_file, "r") as wheel:
1196
+ # Get all file names in the wheel
1197
+ file_names = wheel.namelist()
1198
+
1199
+ # Debug: Print all files to understand what's in the wheel
1200
+ print(f"\nWheel contents ({len(file_names)} files):")
1201
+ for f in sorted(file_names)[:20]:
1202
+ print(f" {f}")
1203
+ if len(file_names) > 20:
1204
+ print(f" ... and {len(file_names) - 20} more files")
1205
+
1206
+ # Verify the package directory exists with the correct import name
1207
+ # The package should be installed as ml_drawing_assistant_data/, not .temp_package_ml_drawing_assistant_data/
1208
+ package_dir_prefix = f"{import_name}/"
1209
+ package_files = [f for f in file_names if f.startswith(package_dir_prefix)]
1210
+
1211
+ # Also check for temp directory name (should NOT be present)
1212
+ temp_dir_prefix = ".temp_package_"
1213
+ temp_dir_files = [f for f in file_names if temp_dir_prefix in f and ".dist-info" not in f]
1214
+
1215
+ assert len(package_files) > 0, (
1216
+ f"Wheel should contain files in {import_name}/ directory. "
1217
+ f"Found {len(file_names)} total files. "
1218
+ f"Files with '/' in name: {[f for f in file_names if '/' in f and '.dist-info' not in f][:10]}"
1219
+ )
1220
+
1221
+ # Verify the expected files are present
1222
+ assert f"{import_name}/__init__.py" in file_names, (
1223
+ f"Wheel should contain {import_name}/__init__.py"
1224
+ )
1225
+ assert f"{import_name}/module.py" in file_names, (
1226
+ f"Wheel should contain {import_name}/module.py"
1227
+ )
1228
+ assert f"{import_name}/utils.py" in file_names, (
1229
+ f"Wheel should contain {import_name}/utils.py"
1230
+ )
1231
+
1232
+ # Verify the .dist-info folder exists
1233
+ dist_info_files = [f for f in file_names if ".dist-info" in f]
1234
+ assert len(dist_info_files) > 0, "Wheel should contain .dist-info files"
1235
+
1236
+ # Verify the temp directory name is NOT in the wheel
1237
+ assert len(temp_dir_files) == 0, (
1238
+ f"Wheel should not contain temp directory files. Found: {temp_dir_files[:5]}"
1239
+ )
1240
+
1241
+ # Verify the original subfolder name is NOT in the wheel (if different from import name)
1242
+ if "data/" in file_names and import_name != "data":
1243
+ # Only check if data/ would be different from the import name
1244
+ data_files = [f for f in file_names if f.startswith("data/") and ".dist-info" not in f]
1245
+ assert len(data_files) == 0, (
1246
+ f"Wheel should not contain 'data/' directory, should use '{import_name}/' instead"
1247
+ )
1248
+
1249
+ def test_wheel_installs_with_correct_package_directory(self, tmp_path: Path) -> None:
1250
+ """Test that a built wheel can be installed and the package directory exists."""
1251
+ project_root = tmp_path / "test_project"
1252
+ project_root.mkdir()
1253
+
1254
+ # Create pyproject.toml
1255
+ pyproject_content = """[project]
1256
+ name = "test-package"
1257
+ version = "0.1.0"
1258
+
1259
+ [build-system]
1260
+ requires = ["hatchling"]
1261
+ build-backend = "hatchling.build"
1262
+ """
1263
+ (project_root / "pyproject.toml").write_text(pyproject_content)
1264
+
1265
+ # Create subfolder with package name that has hyphens
1266
+ subfolder = project_root / "src" / "data"
1267
+ subfolder.mkdir(parents=True)
1268
+
1269
+ # Create some Python files
1270
+ (subfolder / "__init__.py").write_text("# Package init")
1271
+ (subfolder / "module.py").write_text("def hello(): return 'world'")
1272
+ (subfolder / "utils.py").write_text("def util(): return 'helper'")
1273
+
1274
+ # Package name with hyphens (like ml-drawing-assistant-data)
1275
+ package_name = "ml-drawing-assistant-data"
1276
+ import_name = "ml_drawing_assistant_data" # Expected import name
1277
+ version = "1.0.0"
1278
+
1279
+ # Build the wheel
1280
+ manager = BuildManager(project_root=project_root, src_dir=subfolder)
1281
+
1282
+ def build_wheel() -> None:
1283
+ """Build the wheel using uv build."""
1284
+ subprocess.run(
1285
+ ["uv", "build", "--wheel"],
1286
+ cwd=project_root,
1287
+ check=True,
1288
+ capture_output=True,
1289
+ )
1290
+
1291
+ try:
1292
+ manager.run_build(build_wheel, version=version, package_name=package_name)
1293
+ finally:
1294
+ manager.cleanup()
1295
+
1296
+ # Find the built wheel
1297
+ dist_dir = project_root / "dist"
1298
+ assert dist_dir.exists(), "dist directory should exist after build"
1299
+
1300
+ wheel_files = list(dist_dir.glob("*.whl"))
1301
+ assert len(wheel_files) > 0, "At least one wheel should be built"
1302
+
1303
+ wheel_file = wheel_files[0]
1304
+
1305
+ # Create a temporary virtual environment and install the wheel
1306
+ venv_dir = tmp_path / "test_venv"
1307
+ venv.create(venv_dir, with_pip=True)
1308
+
1309
+ # Determine the Python executable in the venv
1310
+ if sys.platform == "win32":
1311
+ python_exe = venv_dir / "Scripts" / "python.exe"
1312
+ pip_exe = venv_dir / "Scripts" / "pip.exe"
1313
+ else:
1314
+ python_exe = venv_dir / "bin" / "python"
1315
+ pip_exe = venv_dir / "bin" / "pip"
1316
+
1317
+ # Install the wheel
1318
+ install_result = subprocess.run(
1319
+ [str(pip_exe), "install", str(wheel_file)],
1320
+ capture_output=True,
1321
+ text=True,
1322
+ check=True,
1323
+ )
1324
+
1325
+ # Find the site-packages directory
1326
+ if sys.platform == "win32":
1327
+ site_packages = venv_dir / "Lib" / "site-packages"
1328
+ else:
1329
+ # Get Python version
1330
+ version_result = subprocess.run(
1331
+ [str(python_exe), "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"],
1332
+ capture_output=True,
1333
+ text=True,
1334
+ check=True,
1335
+ )
1336
+ py_version = version_result.stdout.strip()
1337
+ site_packages = venv_dir / "lib" / f"python{py_version}" / "site-packages"
1338
+
1339
+ assert site_packages.exists(), f"site-packages directory should exist at {site_packages}"
1340
+
1341
+ # Verify the package directory exists (not just dist-info)
1342
+ package_dir = site_packages / import_name
1343
+ assert package_dir.exists(), (
1344
+ f"Package directory {import_name}/ should exist in site-packages after installation. "
1345
+ f"Found in site-packages: {list(site_packages.iterdir())[:20]}"
1346
+ )
1347
+ assert package_dir.is_dir(), f"{import_name} should be a directory, not a file"
1348
+
1349
+ # Verify the expected files are present
1350
+ assert (package_dir / "__init__.py").exists(), f"{import_name}/__init__.py should exist"
1351
+ assert (package_dir / "module.py").exists(), f"{import_name}/module.py should exist"
1352
+ assert (package_dir / "utils.py").exists(), f"{import_name}/utils.py should exist"
1353
+
1354
+ # Verify dist-info also exists
1355
+ dist_info_dir = site_packages / f"{import_name}-{version}.dist-info"
1356
+ assert dist_info_dir.exists(), f"dist-info directory should exist: {dist_info_dir}"