python-package-folder 5.4.0__tar.gz → 7.0.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-5.4.0 → python_package_folder-7.0.0}/PKG-INFO +1 -1
  2. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/pyproject.toml +1 -1
  3. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/analyzer.py +37 -49
  4. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/manager.py +21 -1
  5. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/subfolder_build.py +202 -69
  6. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_build_with_external_deps.py +14 -6
  7. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_preserve_directory_structure.py +4 -2
  8. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_spreadsheet_creation_imports.py +3 -1
  9. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_subfolder_build.py +297 -5
  10. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.copier-answers.yml +0 -0
  11. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  12. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.cursor/plans/replace_node.js_semantic-release_with_custom_python_implementation_64e05e1a.plan.md +0 -0
  13. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.cursor/rules/general.mdc +0 -0
  14. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.cursor/rules/python.mdc +0 -0
  15. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.github/workflows/ci.yml +0 -0
  16. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.github/workflows/publish.yml +0 -0
  17. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.gitignore +0 -0
  18. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/.vscode/settings.json +0 -0
  19. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/LICENSE +0 -0
  20. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/MANIFEST.in +0 -0
  21. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/Makefile +0 -0
  22. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/README.md +0 -0
  23. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/coverage.svg +0 -0
  24. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/development.md +0 -0
  25. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/docs/DEVELOPMENT.md +0 -0
  26. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/docs/INSTALLATION.md +0 -0
  27. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/docs/PUBLISHING.md +0 -0
  28. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/docs/REFERENCE.md +0 -0
  29. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/docs/USAGE.md +0 -0
  30. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/docs/VERSION_RESOLUTION.md +0 -0
  31. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/installation.md +0 -0
  32. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/publishing.md +0 -0
  33. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/__init__.py +0 -0
  34. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/__main__.py +0 -0
  35. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/finder.py +0 -0
  36. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/publisher.py +0 -0
  37. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/py.typed +0 -0
  38. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/python_package_folder.py +0 -0
  39. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/types.py +0 -0
  40. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/utils.py +0 -0
  41. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/version.py +0 -0
  42. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/src/python_package_folder/version_calculator.py +0 -0
  43. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/conftest.py +0 -0
  44. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/folder_structure/some_globals.py +0 -0
  45. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  46. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  47. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  48. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  49. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  50. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  51. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_exclude_patterns.py +0 -0
  52. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_linting.py +0 -0
  53. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_publisher.py +0 -0
  54. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_shared_subdirectory_imports.py +0 -0
  55. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_third_party_dependencies.py +0 -0
  56. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_utils.py +0 -0
  57. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_version_calculator.py +0 -0
  58. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/test_version_manager.py +0 -0
  59. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/tests/tests.py +0 -0
  60. {python_package_folder-5.4.0 → python_package_folder-7.0.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 5.4.0
3
+ Version: 7.0.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 = "5.4.0"
46
+ version = "7.0.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
 
@@ -330,56 +338,36 @@ class ImportAnalyzer:
330
338
 
331
339
  # Check all subdirectories in parent (not just common ones)
332
340
  # This handles cases like src/data/spreadsheet_creation/spreadsheet_formatting_dataclasses.py
333
- if parent.is_dir():
341
+ # Use recursive search to find modules in nested directories
342
+ if parent.is_dir() and parent.is_relative_to(self.project_root):
343
+ # Recursively search for the module file in subdirectories
344
+ # Limit search to project_root and its subdirectories to avoid searching too broadly
345
+ module_basename = module_name.split(".")[-1]
334
346
  try:
335
- for subdir in parent.iterdir():
336
- if not subdir.is_dir():
347
+ # Search recursively for the module file
348
+ for potential_file in parent.rglob(f"{module_basename}.py"):
349
+ # Only search within project_root to avoid going too far
350
+ if not potential_file.is_relative_to(self.project_root):
337
351
  continue
338
- # Skip common excluded patterns
339
- if subdir.name.startswith("_SS") or subdir.name.startswith("__SS"):
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):
340
354
  continue
341
- # Check if module file exists directly in subdirectory
342
- potential_subdir_file = subdir / f"{module_name.split('.')[-1]}.py"
343
- if potential_subdir_file.exists():
344
- return potential_subdir_file
345
- # Check if module directory exists in subdirectory
346
- potential_subdir_module = subdir / module_name.replace(".", "/")
347
- if (
348
- potential_subdir_module.is_dir()
349
- and (potential_subdir_module / "__init__.py").exists()
355
+ # Skip excluded patterns
356
+ if any(
357
+ part.startswith("_SS")
358
+ or part.startswith("__SS")
359
+ or part.startswith("_sandbox")
360
+ or part.startswith("__sandbox")
361
+ for part in potential_file.parts
350
362
  ):
351
- return potential_subdir_module / "__init__.py"
352
- if potential_subdir_module.with_suffix(".py").is_file():
353
- return potential_subdir_module.with_suffix(".py")
354
- # Check nested subdirectories (e.g., data/spreadsheet_creation)
355
- # Recursively check subdirectories up to 2 levels deep
356
- try:
357
- for nested_subdir in subdir.iterdir():
358
- if not nested_subdir.is_dir():
359
- continue
360
- # Check if module file exists in nested subdirectory
361
- potential_nested_file = (
362
- nested_subdir / f"{module_name.split('.')[-1]}.py"
363
- )
364
- if potential_nested_file.exists():
365
- return potential_nested_file
366
- # Check if module directory exists in nested subdirectory
367
- potential_nested_module = nested_subdir / module_name.replace(
368
- ".", "/"
369
- )
370
- if (
371
- potential_nested_module.is_dir()
372
- and (potential_nested_module / "__init__.py").exists()
373
- ):
374
- return potential_nested_module / "__init__.py"
375
- if potential_nested_module.with_suffix(".py").is_file():
376
- return potential_nested_module.with_suffix(".py")
377
- except (OSError, PermissionError):
378
- # Skip nested directories we can't read
379
363
  continue
364
+ # Skip if it's in the src_dir (we're looking for external dependencies)
365
+ if potential_file.is_relative_to(src_dir):
366
+ continue
367
+ return potential_file
380
368
  except (OSError, PermissionError):
381
- # Skip directories we can't read
382
- continue
369
+ # Skip if we can't read the directory
370
+ pass
383
371
 
384
372
  # Check common subdirectories in parent (e.g., _shared, shared, common)
385
373
  # This handles cases like src/_shared/better_enum.py
@@ -268,10 +268,30 @@ class BuildManager:
268
268
  # This is acceptable for tests or dependency-only operations
269
269
  if temp_pyproject is None:
270
270
  self.subfolder_config = None
271
+ else:
272
+ # If temporary package directory was created, use it for all operations
273
+ # This ensures dependencies are copied to the correct location and
274
+ # imports are fixed in the files that will actually be packaged
275
+ if (
276
+ self.subfolder_config
277
+ and self.subfolder_config._temp_package_dir
278
+ and self.subfolder_config._temp_package_dir.exists()
279
+ ):
280
+ # Update src_dir to point to temp package directory
281
+ self.src_dir = self.subfolder_config._temp_package_dir
282
+ # Recreate finder with updated src_dir so it calculates target paths correctly
283
+ self.finder = ExternalDependencyFinder(
284
+ self.project_root,
285
+ self.src_dir,
286
+ exclude_patterns=self.exclude_patterns,
287
+ )
288
+ print(
289
+ f"Using temporary package directory for build: {self.src_dir}"
290
+ )
271
291
 
272
292
  analyzer = ImportAnalyzer(self.project_root)
273
293
 
274
- # Find all Python files in src/
294
+ # Find all Python files in src/ (which may now be the temp package directory)
275
295
  python_files = analyzer.find_all_python_files(self.src_dir)
276
296
 
277
297
  # Find external dependencies using the configured finder
@@ -78,6 +78,7 @@ class SubfolderBuildConfig:
78
78
  self._used_subfolder_pyproject = False
79
79
  self._excluded_files: list[tuple[Path, Path]] = [] # List of (original_path, temp_path) tuples
80
80
  self._exclude_temp_dir: Path | None = None
81
+ self._temp_package_dir: Path | None = None
81
82
 
82
83
  def _derive_package_name(self) -> str:
83
84
  """
@@ -121,6 +122,70 @@ class SubfolderBuildConfig:
121
122
  # Fallback to just subfolder name
122
123
  return subfolder_name
123
124
 
125
+ def _create_temp_package_directory(self) -> None:
126
+ """
127
+ Create a temporary package directory with the correct import name.
128
+
129
+ This ensures the installed package has the correct directory structure.
130
+ The package name (with hyphens) is converted to the import name (with underscores).
131
+ For example: 'ml-drawing-assistant-data' -> 'ml_drawing_assistant_data'
132
+
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
+ """
136
+ if not self.package_name:
137
+ return
138
+
139
+ # Convert package name (with hyphens) to import name (with underscores)
140
+ # PyPI package names use hyphens, but Python import names use underscores
141
+ import_name = self.package_name.replace("-", "_")
142
+
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
+
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
173
+
174
+ try:
175
+ shutil.copytree(self.src_dir, import_name_dir)
176
+ self._temp_package_dir = import_name_dir
177
+ print(
178
+ f"Created temporary package directory: {import_name_dir} "
179
+ f"(import name: {import_name})"
180
+ )
181
+ except Exception as e:
182
+ print(
183
+ f"Warning: Could not create temporary package directory: {e}",
184
+ file=sys.stderr,
185
+ )
186
+ # Fall back to using src_dir directly
187
+ self._temp_package_dir = None
188
+
124
189
  def _get_package_structure(self) -> tuple[str, list[str]]:
125
190
  """
126
191
  Determine the package structure for hatchling.
@@ -130,21 +195,24 @@ class SubfolderBuildConfig:
130
195
  - packages_path: The path to the directory containing packages
131
196
  - package_dirs: List of package directories to include
132
197
  """
133
- # Check if src_dir itself is a package (has __init__.py)
134
- has_init = (self.src_dir / "__init__.py").exists()
198
+ # Use temporary package directory if it exists, otherwise use src_dir
199
+ package_dir = self._temp_package_dir if self._temp_package_dir and self._temp_package_dir.exists() else self.src_dir
200
+
201
+ # Check if package_dir itself is a package (has __init__.py)
202
+ has_init = (package_dir / "__init__.py").exists()
135
203
 
136
- # Check for Python files directly in src_dir
137
- py_files = list(self.src_dir.glob("*.py"))
204
+ # Check for Python files directly in package_dir
205
+ py_files = list(package_dir.glob("*.py"))
138
206
  has_py_files = bool(py_files)
139
207
 
140
- # Calculate relative path
208
+ # Calculate relative path from project root
141
209
  try:
142
- rel_path = self.src_dir.relative_to(self.project_root)
210
+ rel_path = package_dir.relative_to(self.project_root)
143
211
  packages_path = str(rel_path).replace("\\", "/")
144
212
  except ValueError:
145
213
  packages_path = None
146
214
 
147
- # If src_dir has Python files but no __init__.py, we need to make it a package
215
+ # If package_dir has Python files but no __init__.py, we need to make it a package
148
216
  # or include it as a module directory
149
217
  if has_py_files and not has_init:
150
218
  # For flat structures, we include the directory itself
@@ -298,80 +366,133 @@ class SubfolderBuildConfig:
298
366
  if not self.version:
299
367
  raise ValueError("Version is required for subfolder builds")
300
368
 
301
- # Ensure src_dir is a package (has __init__.py) for hatchling
302
- init_file = self.src_dir / "__init__.py"
303
- if not init_file.exists():
304
- # Create a temporary __init__.py to make it a package
305
- init_file.write_text("# Temporary __init__.py for build\n", encoding="utf-8")
306
- self._temp_init_created = True
307
- else:
308
- self._temp_init_created = False
309
-
310
- # Check if pyproject.toml exists in subfolder
311
- subfolder_pyproject = self.src_dir / "pyproject.toml"
312
- if subfolder_pyproject.exists():
313
- # Use the subfolder's pyproject.toml
314
- print(f"Using existing pyproject.toml from subfolder: {subfolder_pyproject}")
315
- self._used_subfolder_pyproject = True
316
-
317
- # Store reference to original project root pyproject.toml
318
- original_pyproject = self.project_root / "pyproject.toml"
319
- self.original_pyproject_path = original_pyproject
320
-
321
- # Create temporary pyproject.toml file
322
- temp_pyproject_path = self.project_root / "pyproject.toml.temp"
323
-
324
- # Read and adjust the subfolder pyproject.toml
325
- subfolder_content = subfolder_pyproject.read_text(encoding="utf-8")
326
- # Adjust packages path to be relative to project root
327
- adjusted_content = self._adjust_subfolder_pyproject_packages_path(subfolder_content)
328
-
329
- # Read exclude patterns from root pyproject.toml and inject them
330
- exclude_patterns = read_exclude_patterns(original_pyproject)
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
331
374
  print(
332
- f"INFO: Read exclude patterns from {original_pyproject}: {exclude_patterns}",
375
+ f"Warning: Source directory does not exist or is not a directory: {self.src_dir}",
333
376
  file=sys.stderr,
334
377
  )
335
- if exclude_patterns:
336
- adjusted_content = self._inject_exclude_patterns(adjusted_content, exclude_patterns)
337
-
338
- # Write adjusted content to temporary file
339
- temp_pyproject_path.write_text(adjusted_content, encoding="utf-8")
340
- self.temp_pyproject = temp_pyproject_path
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
341
405
 
342
- # Print the temporary pyproject.toml content for debugging
343
- print("\n" + "=" * 80)
344
- print("Temporary pyproject.toml content (from subfolder pyproject.toml):")
345
- print("=" * 80)
346
- print(adjusted_content)
347
- print("=" * 80 + "\n")
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
348
415
 
349
- # If original pyproject.toml exists, temporarily move it
350
- if original_pyproject.exists():
351
- backup_path = self.project_root / "pyproject.toml.original"
352
- # Remove backup if it already exists (from previous failed test or run)
353
- if backup_path.exists():
354
- backup_path.unlink()
355
- original_pyproject.rename(backup_path)
356
- self.original_pyproject_backup = backup_path
357
-
358
- # Move temp file to pyproject.toml for the build
359
- temp_pyproject_path.rename(original_pyproject)
360
- self.temp_pyproject = original_pyproject
361
-
362
- # Handle README file
363
- self._handle_readme()
416
+ # Store reference to original project root pyproject.toml
417
+ original_pyproject = self.project_root / "pyproject.toml"
418
+ self.original_pyproject_path = original_pyproject
364
419
 
365
- # Exclude files matching exclude patterns
366
- if exclude_patterns:
367
- self._exclude_files_by_patterns(exclude_patterns)
420
+ # Create temporary pyproject.toml file
421
+ temp_pyproject_path = self.project_root / "pyproject.toml.temp"
368
422
 
369
- return original_pyproject
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
370
474
 
371
475
  # No pyproject.toml in subfolder, create one from parent
372
476
  self._used_subfolder_pyproject = False
373
477
  print("No pyproject.toml found in subfolder, creating temporary one from parent")
374
478
 
479
+ # Ensure src_dir is a package (has __init__.py) before creating temp directory
480
+ # This way the __init__.py will be copied to the temp directory
481
+ init_file = self.src_dir / "__init__.py"
482
+ if not init_file.exists():
483
+ # Create a temporary __init__.py to make it a package
484
+ init_file.write_text("# Temporary __init__.py for build\n", encoding="utf-8")
485
+ self._temp_init_created = True
486
+ else:
487
+ self._temp_init_created = False
488
+
489
+ # Create temporary package directory with correct import name
490
+ # This will copy the __init__.py we just created (if any)
491
+ self._create_temp_package_directory()
492
+
493
+ # Determine which directory to use (temp package dir or src_dir)
494
+ package_dir = self._temp_package_dir if self._temp_package_dir and self._temp_package_dir.exists() else self.src_dir
495
+
375
496
  # Read the original pyproject.toml
376
497
  original_pyproject = self.project_root / "pyproject.toml"
377
498
  if not original_pyproject.exists():
@@ -1206,6 +1327,18 @@ class SubfolderBuildConfig:
1206
1327
  self.original_pyproject_path = None
1207
1328
  self._used_subfolder_pyproject = False
1208
1329
 
1330
+ # Remove temporary package directory if it exists
1331
+ if self._temp_package_dir and self._temp_package_dir.exists():
1332
+ try:
1333
+ shutil.rmtree(self._temp_package_dir)
1334
+ print(f"Removed temporary package directory: {self._temp_package_dir}")
1335
+ except Exception as e:
1336
+ print(
1337
+ f"Warning: Could not remove temporary package directory {self._temp_package_dir}: {e}",
1338
+ file=sys.stderr,
1339
+ )
1340
+ self._temp_package_dir = None
1341
+
1209
1342
  def __enter__(self) -> Self:
1210
1343
  """Context manager entry."""
1211
1344
  return self
@@ -440,11 +440,15 @@ def shared_function():
440
440
 
441
441
  # Verify dependencies were copied
442
442
  assert len(external_deps) >= 2
443
- assert (subfolder / "_shared").exists()
444
- assert (subfolder / "_globals.py").exists()
443
+ # Dependencies are copied to the temp package directory (if it exists) or original subfolder
444
+ package_dir = manager.src_dir # This will be temp package dir if it was created
445
+ assert (package_dir / "_shared").exists()
446
+ assert (package_dir / "_globals.py").exists()
445
447
 
446
448
  # Verify imports were converted to relative
447
- modified_content = test_file.read_text()
449
+ # Read from package_dir which may be temp package directory
450
+ test_file_in_package = package_dir / test_file.name
451
+ modified_content = test_file_in_package.read_text()
448
452
  assert "from ._shared.image_utils import save_PIL_image" in modified_content
449
453
  assert "from ._shared.file_utils import get_filepaths_config" in modified_content
450
454
  assert "from ._globals import is_testing" in modified_content
@@ -455,12 +459,15 @@ def shared_function():
455
459
  assert "from pathlib import Path" in modified_content
456
460
 
457
461
  # Verify import statement conversion
458
- modified_content2 = test_file2.read_text()
462
+ test_file2_in_package = package_dir / test_file2.name
463
+ modified_content2 = test_file2_in_package.read_text()
459
464
  assert "from . import _globals" in modified_content2
460
465
 
461
466
  # 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()
467
+ # Check in the package directory (which may be temp package dir)
468
+ shared_utils_path = package_dir / "_shared" / "shared_utils.py"
469
+ if shared_utils_path.exists():
470
+ shared_utils_content = shared_utils_path.read_text()
464
471
  # The relative import should be converted to absolute
465
472
  assert (
466
473
  "from .file_utils import" not in shared_utils_content
@@ -470,6 +477,7 @@ def shared_function():
470
477
  # Cleanup should restore original imports
471
478
  manager.cleanup()
472
479
 
480
+ # After cleanup, check original files (not temp package dir)
473
481
  restored_content = test_file.read_text()
474
482
  assert restored_content == original_content
475
483
 
@@ -161,14 +161,16 @@ packages = ["src/test_package"]
161
161
  assert len(data_deps) > 0, "data dependencies should be found"
162
162
 
163
163
  # Verify models structure was copied with full path
164
- models_path = src_dir / "models" / "Information_extraction" / "_shared_ie" / "ie_enums.py"
164
+ # Use manager.src_dir which may point to temp package directory
165
+ package_dir = manager.src_dir
166
+ models_path = package_dir / "models" / "Information_extraction" / "_shared_ie" / "ie_enums.py"
165
167
  assert models_path.exists(), (
166
168
  "models/Information_extraction/_shared_ie/ie_enums.py should be copied with full structure"
167
169
  )
168
170
 
169
171
  # Verify data structure was copied with full path
170
172
  data_path = (
171
- src_dir / "data" / "spreadsheet_creation" / "spreadsheet_formatting_dataclasses.py"
173
+ package_dir / "data" / "spreadsheet_creation" / "spreadsheet_formatting_dataclasses.py"
172
174
  )
173
175
  assert data_path.exists(), (
174
176
  "data/spreadsheet_creation/spreadsheet_formatting_dataclasses.py should be copied with full structure"
@@ -139,7 +139,9 @@ packages = ["src/test_package"]
139
139
 
140
140
  # Verify spreadsheet_creation directory was copied with full structure preserved
141
141
  # Import is "data.spreadsheet_creation.spreadsheet_formatting_dataclasses", so structure should be preserved
142
- copied_dir = src_dir / "data" / "spreadsheet_creation"
142
+ # Use manager.src_dir which may point to temp package directory
143
+ package_dir = manager.src_dir
144
+ copied_dir = package_dir / "data" / "spreadsheet_creation"
143
145
  assert copied_dir.exists(), (
144
146
  f"spreadsheet_creation directory should be copied with structure at {copied_dir}"
145
147
  )
@@ -2,11 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import subprocess
6
+ import zipfile
5
7
  from pathlib import Path
6
8
 
7
9
  import pytest
8
10
 
9
- from python_package_folder import SubfolderBuildConfig
11
+ from python_package_folder import BuildManager, SubfolderBuildConfig
10
12
 
11
13
 
12
14
  @pytest.fixture
@@ -683,8 +685,8 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
683
685
  assert "[tool.hatch.version]" not in content
684
686
  assert "[tool.uv-dynamic-versioning]" not in content
685
687
 
686
- # Verify packages path is set correctly
687
- assert 'packages = ["subfolder"]' in content or '"subfolder"' in content
688
+ # Verify packages path is set correctly (should use import name, not temp directory name)
689
+ assert '"my_custom_package"' in content or "'my_custom_package'" in content
688
690
 
689
691
  # Verify backup was created
690
692
  assert (project_root / "pyproject.toml.original").exists()
@@ -734,8 +736,9 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
734
736
  # Verify only-include is present
735
737
  assert "only-include = [" in content
736
738
 
737
- # Verify the subfolder is included
738
- assert '"subfolder"' in content
739
+ # Verify the import name is used in packages configuration (not the original subfolder)
740
+ # The temp directory is renamed to the import name, so packages should use that
741
+ assert '"test_package"' in content or "'test_package'" in content
739
742
 
740
743
  # Verify necessary files are included
741
744
  assert '"pyproject.toml"' in content
@@ -950,3 +953,292 @@ description = "Subfolder package"
950
953
  assert 'build-backend = "hatchling.build"' in content
951
954
 
952
955
  config.restore()
956
+
957
+
958
+ class TestTemporaryPackageDirectory:
959
+ """Tests for temporary package directory creation and cleanup."""
960
+
961
+ def test_temp_package_directory_created_with_correct_name(
962
+ self, test_project_with_pyproject: Path
963
+ ) -> None:
964
+ """Test that temporary package directory is created with correct import name."""
965
+ project_root = test_project_with_pyproject
966
+ subfolder = project_root / "subfolder"
967
+ (subfolder / "module.py").write_text("def func(): pass")
968
+
969
+ config = SubfolderBuildConfig(
970
+ project_root=project_root,
971
+ src_dir=subfolder,
972
+ version="1.0.0",
973
+ )
974
+
975
+ # Package name should be "test-package-subfolder" (with hyphens)
976
+ assert config.package_name == "test-package-subfolder"
977
+
978
+ # Create temp pyproject (which creates temp package directory)
979
+ config.create_temp_pyproject()
980
+
981
+ # Temp package directory should exist with import name (underscores, no temp prefix)
982
+ import_name = "test_package_subfolder" # Import name from "test-package-subfolder"
983
+ temp_package_dir = project_root / import_name
984
+ assert temp_package_dir.exists()
985
+ assert config._temp_package_dir == temp_package_dir
986
+
987
+ # Temp package directory should contain the subfolder contents
988
+ assert (temp_package_dir / "module.py").exists()
989
+
990
+ # Cleanup
991
+ config.restore()
992
+
993
+ def test_temp_package_directory_uses_import_name(
994
+ self, test_project_with_pyproject: Path
995
+ ) -> None:
996
+ """Test that temp package directory name converts hyphens to underscores."""
997
+ project_root = test_project_with_pyproject
998
+ subfolder = project_root / "subfolder"
999
+ (subfolder / "module.py").write_text("def func(): pass")
1000
+
1001
+ config = SubfolderBuildConfig(
1002
+ project_root=project_root,
1003
+ src_dir=subfolder,
1004
+ version="1.0.0",
1005
+ package_name="my-custom-package", # Package name with hyphens
1006
+ )
1007
+
1008
+ config.create_temp_pyproject()
1009
+
1010
+ # Temp directory should use underscores (import name, no temp prefix)
1011
+ import_name = "my_custom_package" # Import name from "my-custom-package"
1012
+ temp_package_dir = project_root / import_name
1013
+ assert temp_package_dir.exists()
1014
+ assert config._temp_package_dir == temp_package_dir
1015
+
1016
+ config.restore()
1017
+
1018
+ def test_temp_package_directory_cleaned_up(self, test_project_with_pyproject: Path) -> None:
1019
+ """Test that temporary package directory is cleaned up on restore."""
1020
+ project_root = test_project_with_pyproject
1021
+ subfolder = project_root / "subfolder"
1022
+ (subfolder / "module.py").write_text("def func(): pass")
1023
+
1024
+ config = SubfolderBuildConfig(
1025
+ project_root=project_root,
1026
+ src_dir=subfolder,
1027
+ version="1.0.0",
1028
+ )
1029
+
1030
+ config.create_temp_pyproject()
1031
+
1032
+ # Verify temp directory exists
1033
+ temp_package_dir = config._temp_package_dir
1034
+ assert temp_package_dir is not None
1035
+ assert temp_package_dir.exists()
1036
+
1037
+ # Restore should clean it up
1038
+ config.restore()
1039
+
1040
+ # Temp directory should be removed
1041
+ assert not temp_package_dir.exists()
1042
+ assert config._temp_package_dir is None
1043
+
1044
+ def test_packages_configuration_uses_temp_directory(
1045
+ self, test_project_with_pyproject: Path
1046
+ ) -> None:
1047
+ """Test that packages configuration uses temp directory path."""
1048
+ project_root = test_project_with_pyproject
1049
+ subfolder = project_root / "subfolder"
1050
+ (subfolder / "module.py").write_text("def func(): pass")
1051
+
1052
+ config = SubfolderBuildConfig(
1053
+ project_root=project_root,
1054
+ src_dir=subfolder,
1055
+ version="1.0.0",
1056
+ )
1057
+
1058
+ pyproject_path = config.create_temp_pyproject()
1059
+ assert pyproject_path is not None
1060
+
1061
+ content = pyproject_path.read_text()
1062
+
1063
+ # Packages configuration should use import name (temp directory is renamed to import name)
1064
+ # Import name is "test_package_subfolder" (from "test-package-subfolder")
1065
+ assert '"test_package_subfolder"' in content or "'test_package_subfolder'" in content
1066
+
1067
+ config.restore()
1068
+
1069
+ def test_temp_package_directory_preserves_structure(
1070
+ self, test_project_with_pyproject: Path
1071
+ ) -> None:
1072
+ """Test that temp package directory preserves the original directory structure."""
1073
+ project_root = test_project_with_pyproject
1074
+ subfolder = project_root / "subfolder"
1075
+ (subfolder / "module.py").write_text("def func(): pass")
1076
+ (subfolder / "submodule").mkdir()
1077
+ (subfolder / "submodule" / "__init__.py").write_text("")
1078
+ (subfolder / "submodule" / "helper.py").write_text("def helper(): pass")
1079
+
1080
+ config = SubfolderBuildConfig(
1081
+ project_root=project_root,
1082
+ src_dir=subfolder,
1083
+ version="1.0.0",
1084
+ )
1085
+
1086
+ config.create_temp_pyproject()
1087
+
1088
+ temp_package_dir = config._temp_package_dir
1089
+ assert temp_package_dir is not None
1090
+
1091
+ # Verify structure is preserved
1092
+ assert (temp_package_dir / "module.py").exists()
1093
+ assert (temp_package_dir / "submodule" / "__init__.py").exists()
1094
+ assert (temp_package_dir / "submodule" / "helper.py").exists()
1095
+
1096
+ config.restore()
1097
+
1098
+ def test_temp_package_directory_handles_existing_directory(
1099
+ self, test_project_with_pyproject: Path
1100
+ ) -> None:
1101
+ """Test that temp package directory creation handles existing directory."""
1102
+ project_root = test_project_with_pyproject
1103
+ subfolder = project_root / "subfolder"
1104
+ (subfolder / "module.py").write_text("def func(): pass")
1105
+
1106
+ # Create a directory that would conflict (using import name directly)
1107
+ import_name = "test_package_subfolder" # Import name from "test-package-subfolder"
1108
+ existing_temp_dir = project_root / import_name
1109
+ existing_temp_dir.mkdir()
1110
+ (existing_temp_dir / "old_file.py").write_text("# Old file")
1111
+
1112
+ config = SubfolderBuildConfig(
1113
+ project_root=project_root,
1114
+ src_dir=subfolder,
1115
+ version="1.0.0",
1116
+ )
1117
+
1118
+ # Should remove existing directory and create new one
1119
+ config.create_temp_pyproject()
1120
+
1121
+ temp_package_dir = config._temp_package_dir
1122
+ assert temp_package_dir is not None
1123
+ assert temp_package_dir.exists()
1124
+ # Should have new file, not old file
1125
+ assert (temp_package_dir / "module.py").exists()
1126
+ assert not (temp_package_dir / "old_file.py").exists()
1127
+
1128
+ config.restore()
1129
+
1130
+
1131
+ class TestWheelPackaging:
1132
+ """Tests to verify that wheels are correctly packaged with the right directory structure."""
1133
+
1134
+ def test_wheel_contains_package_directory_with_correct_name(self, tmp_path: Path) -> None:
1135
+ """Test that a built wheel contains the package directory with the correct import name."""
1136
+ project_root = tmp_path / "test_project"
1137
+ project_root.mkdir()
1138
+
1139
+ # Create pyproject.toml
1140
+ pyproject_content = """[project]
1141
+ name = "test-package"
1142
+ version = "0.1.0"
1143
+
1144
+ [build-system]
1145
+ requires = ["hatchling"]
1146
+ build-backend = "hatchling.build"
1147
+ """
1148
+ (project_root / "pyproject.toml").write_text(pyproject_content)
1149
+
1150
+ # Create subfolder with package name that has hyphens
1151
+ subfolder = project_root / "src" / "data"
1152
+ subfolder.mkdir(parents=True)
1153
+
1154
+ # Create some Python files
1155
+ (subfolder / "__init__.py").write_text("# Package init")
1156
+ (subfolder / "module.py").write_text("def hello(): return 'world'")
1157
+ (subfolder / "utils.py").write_text("def util(): return 'helper'")
1158
+
1159
+ # Package name with hyphens (like ml-drawing-assistant-data)
1160
+ package_name = "ml-drawing-assistant-data"
1161
+ import_name = "ml_drawing_assistant_data" # Expected import name
1162
+ version = "1.0.0"
1163
+
1164
+ # Build the wheel
1165
+ manager = BuildManager(project_root=project_root, src_dir=subfolder)
1166
+
1167
+ def build_wheel() -> None:
1168
+ """Build the wheel using uv build."""
1169
+ subprocess.run(
1170
+ ["uv", "build", "--wheel"],
1171
+ cwd=project_root,
1172
+ check=True,
1173
+ capture_output=True,
1174
+ )
1175
+
1176
+ try:
1177
+ # run_build will call prepare_build internally, so we don't need to call it explicitly
1178
+ manager.run_build(build_wheel, version=version, package_name=package_name)
1179
+ finally:
1180
+ manager.cleanup()
1181
+
1182
+ # Find the built wheel
1183
+ dist_dir = project_root / "dist"
1184
+ assert dist_dir.exists(), "dist directory should exist after build"
1185
+
1186
+ wheel_files = list(dist_dir.glob("*.whl"))
1187
+ assert len(wheel_files) > 0, "At least one wheel should be built"
1188
+
1189
+ wheel_file = wheel_files[0]
1190
+
1191
+ # Extract and inspect the wheel
1192
+ with zipfile.ZipFile(wheel_file, "r") as wheel:
1193
+ # Get all file names in the wheel
1194
+ file_names = wheel.namelist()
1195
+
1196
+ # Debug: Print all files to understand what's in the wheel
1197
+ print(f"\nWheel contents ({len(file_names)} files):")
1198
+ for f in sorted(file_names)[:20]:
1199
+ print(f" {f}")
1200
+ if len(file_names) > 20:
1201
+ print(f" ... and {len(file_names) - 20} more files")
1202
+
1203
+ # Verify the package directory exists with the correct import name
1204
+ # The package should be installed as ml_drawing_assistant_data/, not .temp_package_ml_drawing_assistant_data/
1205
+ package_dir_prefix = f"{import_name}/"
1206
+ package_files = [f for f in file_names if f.startswith(package_dir_prefix)]
1207
+
1208
+ # Also check for temp directory name (should NOT be present)
1209
+ temp_dir_prefix = ".temp_package_"
1210
+ temp_dir_files = [f for f in file_names if temp_dir_prefix in f and ".dist-info" not in f]
1211
+
1212
+ assert len(package_files) > 0, (
1213
+ f"Wheel should contain files in {import_name}/ directory. "
1214
+ f"Found {len(file_names)} total files. "
1215
+ f"Files with '/' in name: {[f for f in file_names if '/' in f and '.dist-info' not in f][:10]}"
1216
+ )
1217
+
1218
+ # Verify the expected files are present
1219
+ assert f"{import_name}/__init__.py" in file_names, (
1220
+ f"Wheel should contain {import_name}/__init__.py"
1221
+ )
1222
+ assert f"{import_name}/module.py" in file_names, (
1223
+ f"Wheel should contain {import_name}/module.py"
1224
+ )
1225
+ assert f"{import_name}/utils.py" in file_names, (
1226
+ f"Wheel should contain {import_name}/utils.py"
1227
+ )
1228
+
1229
+ # Verify the .dist-info folder exists
1230
+ dist_info_files = [f for f in file_names if ".dist-info" in f]
1231
+ assert len(dist_info_files) > 0, "Wheel should contain .dist-info files"
1232
+
1233
+ # Verify the temp directory name is NOT in the wheel
1234
+ assert len(temp_dir_files) == 0, (
1235
+ f"Wheel should not contain temp directory files. Found: {temp_dir_files[:5]}"
1236
+ )
1237
+
1238
+ # Verify the original subfolder name is NOT in the wheel (if different from import name)
1239
+ if "data/" in file_names and import_name != "data":
1240
+ # Only check if data/ would be different from the import name
1241
+ data_files = [f for f in file_names if f.startswith("data/") and ".dist-info" not in f]
1242
+ assert len(data_files) == 0, (
1243
+ f"Wheel should not contain 'data/' directory, should use '{import_name}/' instead"
1244
+ )