python-package-folder 5.3.0__tar.gz → 6.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.3.0 → python_package_folder-6.0.0}/PKG-INFO +1 -1
  2. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/coverage.svg +2 -2
  3. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/pyproject.toml +1 -1
  4. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/analyzer.py +22 -45
  5. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/manager.py +58 -10
  6. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/python_package_folder.py +83 -10
  7. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/subfolder_build.py +111 -13
  8. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_build_with_external_deps.py +14 -6
  9. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_preserve_directory_structure.py +4 -2
  10. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_spreadsheet_creation_imports.py +3 -1
  11. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_subfolder_build.py +198 -11
  12. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.copier-answers.yml +0 -0
  13. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  14. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.cursor/plans/replace_node.js_semantic-release_with_custom_python_implementation_64e05e1a.plan.md +0 -0
  15. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.cursor/rules/general.mdc +0 -0
  16. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.cursor/rules/python.mdc +0 -0
  17. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.github/workflows/ci.yml +0 -0
  18. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.github/workflows/publish.yml +0 -0
  19. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.gitignore +0 -0
  20. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/.vscode/settings.json +0 -0
  21. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/LICENSE +0 -0
  22. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/MANIFEST.in +0 -0
  23. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/Makefile +0 -0
  24. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/README.md +0 -0
  25. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/development.md +0 -0
  26. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/docs/DEVELOPMENT.md +0 -0
  27. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/docs/INSTALLATION.md +0 -0
  28. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/docs/PUBLISHING.md +0 -0
  29. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/docs/REFERENCE.md +0 -0
  30. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/docs/USAGE.md +0 -0
  31. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/docs/VERSION_RESOLUTION.md +0 -0
  32. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/installation.md +0 -0
  33. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/publishing.md +0 -0
  34. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/__init__.py +0 -0
  35. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/__main__.py +0 -0
  36. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/finder.py +0 -0
  37. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/publisher.py +0 -0
  38. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/py.typed +0 -0
  39. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/types.py +0 -0
  40. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/utils.py +0 -0
  41. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/version.py +0 -0
  42. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/src/python_package_folder/version_calculator.py +0 -0
  43. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/conftest.py +0 -0
  44. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/folder_structure/some_globals.py +0 -0
  45. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  46. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  47. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  48. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  49. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  50. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  51. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_exclude_patterns.py +0 -0
  52. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_linting.py +0 -0
  53. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_publisher.py +0 -0
  54. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_shared_subdirectory_imports.py +0 -0
  55. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_third_party_dependencies.py +0 -0
  56. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_utils.py +0 -0
  57. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_version_calculator.py +0 -0
  58. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/test_version_manager.py +0 -0
  59. {python_package_folder-5.3.0 → python_package_folder-6.0.0}/tests/tests.py +0 -0
  60. {python_package_folder-5.3.0 → python_package_folder-6.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.3.0
3
+ Version: 6.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>
@@ -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">66%</text>
18
- <text x="81" y="14">66%</text>
17
+ <text x="81" y="15" fill="#010101" fill-opacity=".3">65%</text>
18
+ <text x="81" y="14">65%</text>
19
19
  </g>
20
20
  </svg>
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  # ---- Dev dependencies ----
45
45
 
46
- version = "5.3.0"
46
+ version = "6.0.0"
47
47
  [dependency-groups]
48
48
  dev = [
49
49
  "pytest>=8.3.5",
@@ -330,56 +330,33 @@ class ImportAnalyzer:
330
330
 
331
331
  # Check all subdirectories in parent (not just common ones)
332
332
  # This handles cases like src/data/spreadsheet_creation/spreadsheet_formatting_dataclasses.py
333
- if parent.is_dir():
333
+ # Use recursive search to find modules in nested directories
334
+ if parent.is_dir() and parent.is_relative_to(self.project_root):
335
+ # Recursively search for the module file in subdirectories
336
+ # Limit search to project_root and its subdirectories to avoid searching too broadly
337
+ module_basename = module_name.split(".")[-1]
334
338
  try:
335
- for subdir in parent.iterdir():
336
- if not subdir.is_dir():
339
+ # Search recursively for the module file
340
+ for potential_file in parent.rglob(f"{module_basename}.py"):
341
+ # Only search within project_root to avoid going too far
342
+ if not potential_file.is_relative_to(self.project_root):
337
343
  continue
338
- # Skip common excluded patterns
339
- if subdir.name.startswith("_SS") or subdir.name.startswith("__SS"):
340
- 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()
344
+ # Skip excluded patterns
345
+ if any(
346
+ part.startswith("_SS")
347
+ or part.startswith("__SS")
348
+ or part.startswith("_sandbox")
349
+ or part.startswith("__sandbox")
350
+ for part in potential_file.parts
350
351
  ):
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
352
  continue
353
+ # Skip if it's in the src_dir (we're looking for external dependencies)
354
+ if potential_file.is_relative_to(src_dir):
355
+ continue
356
+ return potential_file
380
357
  except (OSError, PermissionError):
381
- # Skip directories we can't read
382
- continue
358
+ # Skip if we can't read the directory
359
+ pass
383
360
 
384
361
  # Check common subdirectories in parent (e.g., _shared, shared, common)
385
362
  # This handles cases like src/_shared/better_enum.py
@@ -239,10 +239,19 @@ class BuildManager:
239
239
  )
240
240
 
241
241
  if not package_name:
242
- # Derive package name from subfolder
243
- package_name = (
242
+ # Derive package name from subfolder: {root_project_name}-{subfolder_name}
243
+ root_project_name = self._get_project_name()
244
+ subfolder_name = (
244
245
  self.src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
245
246
  )
247
+
248
+ if root_project_name:
249
+ # Normalize root project name (replace underscores/hyphens consistently)
250
+ root_name_normalized = root_project_name.replace("_", "-").lower()
251
+ package_name = f"{root_name_normalized}-{subfolder_name}"
252
+ else:
253
+ # Fallback to just subfolder name if root project name not found
254
+ package_name = subfolder_name
246
255
 
247
256
  print(
248
257
  f"Detected subfolder build. Setting up package '{package_name}' version '{version}'..."
@@ -259,10 +268,30 @@ class BuildManager:
259
268
  # This is acceptable for tests or dependency-only operations
260
269
  if temp_pyproject is None:
261
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
+ )
262
291
 
263
292
  analyzer = ImportAnalyzer(self.project_root)
264
293
 
265
- # Find all Python files in src/
294
+ # Find all Python files in src/ (which may now be the temp package directory)
266
295
  python_files = analyzer.find_all_python_files(self.src_dir)
267
296
 
268
297
  # Find external dependencies using the configured finder
@@ -1191,13 +1220,23 @@ class BuildManager:
1191
1220
  captured_package_name = None
1192
1221
  if self._is_subfolder_build():
1193
1222
  # We need to get the package name before run_build cleans up subfolder_config
1194
- if not package_name:
1195
- # Derive from src_dir name (same logic as in prepare_build)
1196
- captured_package_name = (
1223
+ if package_name:
1224
+ # Use provided package name (from --package-name arg)
1225
+ captured_package_name = package_name
1226
+ else:
1227
+ # Derive from root project name + src_dir name (same logic as in prepare_build)
1228
+ root_project_name = self._get_project_name()
1229
+ subfolder_name = (
1197
1230
  self.src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
1198
1231
  )
1199
- else:
1200
- captured_package_name = package_name
1232
+
1233
+ if root_project_name:
1234
+ # Normalize root project name (replace underscores/hyphens consistently)
1235
+ root_name_normalized = root_project_name.replace("_", "-").lower()
1236
+ captured_package_name = f"{root_name_normalized}-{subfolder_name}"
1237
+ else:
1238
+ # Fallback to just subfolder name if root project name not found
1239
+ captured_package_name = subfolder_name
1201
1240
 
1202
1241
  self.run_build(
1203
1242
  build_command,
@@ -1223,10 +1262,19 @@ class BuildManager:
1223
1262
  elif package_name:
1224
1263
  publish_package_name = package_name
1225
1264
  else:
1226
- # Last resort: derive from src_dir name
1227
- publish_package_name = (
1265
+ # Last resort: derive from root project name + src_dir name
1266
+ root_project_name = self._get_project_name()
1267
+ subfolder_name = (
1228
1268
  self.src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
1229
1269
  )
1270
+
1271
+ if root_project_name:
1272
+ # Normalize root project name (replace underscores/hyphens consistently)
1273
+ root_name_normalized = root_project_name.replace("_", "-").lower()
1274
+ publish_package_name = f"{root_name_normalized}-{subfolder_name}"
1275
+ else:
1276
+ # Fallback to just subfolder name if root project name not found
1277
+ publish_package_name = subfolder_name
1230
1278
 
1231
1279
  # Log the package name being used for publishing
1232
1280
  import logging
@@ -30,6 +30,45 @@ logging.basicConfig(
30
30
 
31
31
  def is_github_actions() -> bool:
32
32
  """Check if running in GitHub Actions."""
33
+
34
+
35
+ def _get_root_project_name(project_root: Path) -> str | None:
36
+ """
37
+ Get the root project name from pyproject.toml.
38
+
39
+ Args:
40
+ project_root: Root directory of the project
41
+
42
+ Returns:
43
+ Project name from pyproject.toml, or None if not found
44
+ """
45
+ pyproject_path = project_root / "pyproject.toml"
46
+ if not pyproject_path.exists():
47
+ return None
48
+
49
+ try:
50
+ import tomllib
51
+ except ImportError:
52
+ try:
53
+ import tomli as tomllib
54
+ except ImportError:
55
+ tomllib = None
56
+
57
+ try:
58
+ if tomllib:
59
+ with open(pyproject_path, "rb") as f:
60
+ data = tomllib.load(f)
61
+ return data.get("project", {}).get("name")
62
+ else:
63
+ # Fallback: simple string parsing
64
+ content = pyproject_path.read_text(encoding="utf-8")
65
+ for line in content.split("\n"):
66
+ if line.strip().startswith("name ="):
67
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
68
+ except Exception:
69
+ pass
70
+
71
+ return None
33
72
  return os.getenv("GITHUB_ACTIONS") == "true"
34
73
 
35
74
 
@@ -177,6 +216,27 @@ def main() -> int:
177
216
 
178
217
  # Resolve version via conventional commits if not provided and needed
179
218
  resolved_version = args.version
219
+
220
+ # Derive package name for subfolder builds (used for both version resolution and publishing)
221
+ derived_package_name = None
222
+ if is_subfolder:
223
+ if args.package_name:
224
+ derived_package_name = args.package_name
225
+ else:
226
+ # Derive package name: {root_project_name}-{subfolder_name}
227
+ root_project_name = _get_root_project_name(project_root)
228
+ subfolder_name = src_dir.name.replace("_", "-").replace(
229
+ " ", "-"
230
+ ).lower().strip("-")
231
+
232
+ if root_project_name:
233
+ # Normalize root project name (replace underscores/hyphens consistently)
234
+ root_name_normalized = root_project_name.replace("_", "-").lower()
235
+ derived_package_name = f"{root_name_normalized}-{subfolder_name}"
236
+ else:
237
+ # Fallback to just subfolder name if root project name not found
238
+ derived_package_name = subfolder_name
239
+
180
240
  if not resolved_version and not args.analyze_only:
181
241
  # Version is needed for subfolder builds or when publishing main package
182
242
  if is_subfolder or args.publish:
@@ -204,21 +264,18 @@ def main() -> int:
204
264
  if is_subfolder:
205
265
  # Workflow 1: subfolder build
206
266
  # src_dir is guaranteed to be relative to project_root due to is_subfolder check
207
- package_name = args.package_name or src_dir.name.replace("_", "-").replace(
208
- " ", "-"
209
- ).lower().strip("-")
210
267
  subfolder_rel_path = src_dir.relative_to(project_root)
211
268
 
212
269
  # Log the package name being used for version query
213
270
  logger = logging.getLogger(__name__)
214
271
  logger.info(
215
- f"Querying registry for package name: '{package_name}' "
216
- f"(derived from src_dir: '{src_dir.name}', args.package_name: {args.package_name})"
272
+ f"Querying registry for package name: '{derived_package_name}' "
273
+ f"(derived from src_dir: '{src_dir.name}', root_project: {_get_root_project_name(project_root)}, args.package_name: {args.package_name})"
217
274
  )
218
275
 
219
276
  resolved_version, error_details = resolve_version(
220
277
  project_root,
221
- package_name=package_name,
278
+ package_name=derived_package_name,
222
279
  subfolder_path=subfolder_rel_path,
223
280
  repository=repository,
224
281
  repository_url=repository_url,
@@ -279,7 +336,7 @@ def main() -> int:
279
336
  skip_existing=args.skip_existing,
280
337
  version=args.version,
281
338
  restore_versioning=not args.no_restore_versioning,
282
- package_name=args.package_name,
339
+ package_name=derived_package_name if is_subfolder else args.package_name,
283
340
  dependency_group=args.dependency_group,
284
341
  )
285
342
  else:
@@ -289,9 +346,25 @@ def main() -> int:
289
346
  if is_subfolder:
290
347
  from .subfolder_build import SubfolderBuildConfig
291
348
 
292
- package_name = args.package_name or src_dir.name.replace("_", "-").replace(
293
- " ", "-"
294
- ).lower().strip("-")
349
+ # Use derived_package_name if available, otherwise derive it again
350
+ if derived_package_name is not None:
351
+ package_name = derived_package_name
352
+ elif args.package_name:
353
+ package_name = args.package_name
354
+ else:
355
+ # Derive package name: {root_project_name}-{subfolder_name}
356
+ root_project_name = _get_root_project_name(project_root)
357
+ subfolder_name = src_dir.name.replace("_", "-").replace(
358
+ " ", "-"
359
+ ).lower().strip("-")
360
+
361
+ if root_project_name:
362
+ # Normalize root project name (replace underscores/hyphens consistently)
363
+ root_name_normalized = root_project_name.replace("_", "-").lower()
364
+ package_name = f"{root_name_normalized}-{subfolder_name}"
365
+ else:
366
+ # Fallback to just subfolder name if root project name not found
367
+ package_name = subfolder_name
295
368
  subfolder_config = SubfolderBuildConfig(
296
369
  project_root=project_root,
297
370
  src_dir=src_dir,
@@ -78,16 +78,91 @@ 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
- """Derive package name from source directory name."""
84
+ """
85
+ Derive package name from root project name and source directory name.
86
+
87
+ Format: {root_project_name}-{subfolder_name}
88
+ Falls back to just subfolder_name if root project name not found.
89
+ """
90
+ # Get root project name from pyproject.toml
91
+ root_project_name = None
92
+ pyproject_path = self.project_root / "pyproject.toml"
93
+ if pyproject_path.exists():
94
+ try:
95
+ if tomllib:
96
+ with open(pyproject_path, "rb") as f:
97
+ data = tomllib.load(f)
98
+ root_project_name = data.get("project", {}).get("name")
99
+ else:
100
+ # Fallback: simple string parsing
101
+ content = pyproject_path.read_text(encoding="utf-8")
102
+ for line in content.split("\n"):
103
+ if line.strip().startswith("name ="):
104
+ root_project_name = line.split("=", 1)[1].strip().strip('"').strip("'")
105
+ break
106
+ except Exception:
107
+ pass
108
+
84
109
  # Use the directory name, replacing invalid characters
85
- name = self.src_dir.name
110
+ subfolder_name = self.src_dir.name
86
111
  # Replace invalid characters with hyphens
87
- name = name.replace("_", "-").replace(" ", "-").lower()
112
+ subfolder_name = subfolder_name.replace("_", "-").replace(" ", "-").lower()
88
113
  # Remove any leading/trailing hyphens
89
- name = name.strip("-")
90
- return name
114
+ subfolder_name = subfolder_name.strip("-")
115
+
116
+ # Combine with root project name if available
117
+ if root_project_name:
118
+ # Normalize root project name (replace underscores/hyphens consistently)
119
+ root_name_normalized = root_project_name.replace("_", "-").lower()
120
+ return f"{root_name_normalized}-{subfolder_name}"
121
+ else:
122
+ # Fallback to just subfolder name
123
+ return subfolder_name
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 and contains a copy
134
+ of the source directory contents.
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 name
144
+ temp_dir_name = f".temp_package_{import_name}"
145
+ temp_package_dir = self.project_root / temp_dir_name
146
+
147
+ # Remove if it already exists (from a previous failed build)
148
+ if temp_package_dir.exists():
149
+ shutil.rmtree(temp_package_dir)
150
+
151
+ # Copy the entire source directory contents to the temporary directory
152
+ try:
153
+ shutil.copytree(self.src_dir, temp_package_dir)
154
+ self._temp_package_dir = temp_package_dir
155
+ print(
156
+ f"Created temporary package directory: {temp_package_dir} "
157
+ f"(import name: {import_name})"
158
+ )
159
+ except Exception as e:
160
+ print(
161
+ f"Warning: Could not create temporary package directory: {e}",
162
+ file=sys.stderr,
163
+ )
164
+ # Fall back to using src_dir directly
165
+ self._temp_package_dir = None
91
166
 
92
167
  def _get_package_structure(self) -> tuple[str, list[str]]:
93
168
  """
@@ -98,21 +173,24 @@ class SubfolderBuildConfig:
98
173
  - packages_path: The path to the directory containing packages
99
174
  - package_dirs: List of package directories to include
100
175
  """
101
- # Check if src_dir itself is a package (has __init__.py)
102
- has_init = (self.src_dir / "__init__.py").exists()
176
+ # Use temporary package directory if it exists, otherwise use src_dir
177
+ package_dir = self._temp_package_dir if self._temp_package_dir and self._temp_package_dir.exists() else self.src_dir
178
+
179
+ # Check if package_dir itself is a package (has __init__.py)
180
+ has_init = (package_dir / "__init__.py").exists()
103
181
 
104
- # Check for Python files directly in src_dir
105
- py_files = list(self.src_dir.glob("*.py"))
182
+ # Check for Python files directly in package_dir
183
+ py_files = list(package_dir.glob("*.py"))
106
184
  has_py_files = bool(py_files)
107
185
 
108
- # Calculate relative path
186
+ # Calculate relative path from project root
109
187
  try:
110
- rel_path = self.src_dir.relative_to(self.project_root)
188
+ rel_path = package_dir.relative_to(self.project_root)
111
189
  packages_path = str(rel_path).replace("\\", "/")
112
190
  except ValueError:
113
191
  packages_path = None
114
192
 
115
- # If src_dir has Python files but no __init__.py, we need to make it a package
193
+ # If package_dir has Python files but no __init__.py, we need to make it a package
116
194
  # or include it as a module directory
117
195
  if has_py_files and not has_init:
118
196
  # For flat structures, we include the directory itself
@@ -266,7 +344,8 @@ class SubfolderBuildConfig:
266
344
  if not self.version:
267
345
  raise ValueError("Version is required for subfolder builds")
268
346
 
269
- # Ensure src_dir is a package (has __init__.py) for hatchling
347
+ # Ensure src_dir is a package (has __init__.py) before creating temp directory
348
+ # This way the __init__.py will be copied to the temp directory
270
349
  init_file = self.src_dir / "__init__.py"
271
350
  if not init_file.exists():
272
351
  # Create a temporary __init__.py to make it a package
@@ -275,6 +354,13 @@ class SubfolderBuildConfig:
275
354
  else:
276
355
  self._temp_init_created = False
277
356
 
357
+ # Create temporary package directory with correct import name
358
+ # This will copy the __init__.py we just created (if any)
359
+ self._create_temp_package_directory()
360
+
361
+ # Determine which directory to use (temp package dir or src_dir)
362
+ package_dir = self._temp_package_dir if self._temp_package_dir and self._temp_package_dir.exists() else self.src_dir
363
+
278
364
  # Check if pyproject.toml exists in subfolder
279
365
  subfolder_pyproject = self.src_dir / "pyproject.toml"
280
366
  if subfolder_pyproject.exists():
@@ -1174,6 +1260,18 @@ class SubfolderBuildConfig:
1174
1260
  self.original_pyproject_path = None
1175
1261
  self._used_subfolder_pyproject = False
1176
1262
 
1263
+ # Remove temporary package directory if it exists
1264
+ if self._temp_package_dir and self._temp_package_dir.exists():
1265
+ try:
1266
+ shutil.rmtree(self._temp_package_dir)
1267
+ print(f"Removed temporary package directory: {self._temp_package_dir}")
1268
+ except Exception as e:
1269
+ print(
1270
+ f"Warning: Could not remove temporary package directory {self._temp_package_dir}: {e}",
1271
+ file=sys.stderr,
1272
+ )
1273
+ self._temp_package_dir = None
1274
+
1177
1275
  def __enter__(self) -> Self:
1178
1276
  """Context manager entry."""
1179
1277
  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
  )
@@ -63,7 +63,7 @@ class TestSubfolderBuildConfig:
63
63
  version="1.0.0",
64
64
  )
65
65
 
66
- assert config.package_name == "subfolder"
66
+ assert config.package_name == "test-package-subfolder"
67
67
  assert config.version == "1.0.0"
68
68
  assert config.dependency_group is None
69
69
 
@@ -104,7 +104,7 @@ class TestSubfolderBuildConfig:
104
104
  content = pyproject_path.read_text()
105
105
 
106
106
  # Check package name and version are set
107
- assert 'name = "subfolder"' in content
107
+ assert 'name = "test-package-subfolder"' in content
108
108
  assert 'version = "2.0.0"' in content
109
109
 
110
110
  # Check dynamic versioning is removed
@@ -226,7 +226,7 @@ class TestSubfolderBuildConfig:
226
226
  ) as config:
227
227
  config.create_temp_pyproject()
228
228
  content = (test_project_with_pyproject / "pyproject.toml").read_text()
229
- assert 'name = "subfolder"' in content
229
+ assert 'name = "test-package-subfolder"' in content
230
230
 
231
231
  # Check restore happened automatically
232
232
  restored_content = (test_project_with_pyproject / "pyproject.toml").read_text()
@@ -262,7 +262,7 @@ class TestSubfolderBuildConfig:
262
262
  config.create_temp_pyproject()
263
263
 
264
264
  def test_package_name_derivation(self, test_project_with_pyproject: Path) -> None:
265
- """Test package name derivation from directory name."""
265
+ """Test package name derivation from root project name and directory name."""
266
266
  # Test with underscores
267
267
  subfolder = test_project_with_pyproject / "subfolder_to_build"
268
268
  subfolder.mkdir()
@@ -271,7 +271,7 @@ class TestSubfolderBuildConfig:
271
271
  src_dir=subfolder,
272
272
  version="1.0.0",
273
273
  )
274
- assert config.package_name == "subfolder-to-build"
274
+ assert config.package_name == "test-package-subfolder-to-build"
275
275
 
276
276
  # Test with spaces
277
277
  subfolder2 = test_project_with_pyproject / "subfolder with spaces"
@@ -281,7 +281,24 @@ class TestSubfolderBuildConfig:
281
281
  src_dir=subfolder2,
282
282
  version="1.0.0",
283
283
  )
284
- assert config2.package_name == "subfolder-with-spaces"
284
+ assert config2.package_name == "test-package-subfolder-with-spaces"
285
+
286
+ def test_package_name_derivation_no_root_project(self, tmp_path: Path) -> None:
287
+ """Test package name derivation when root project name is not found (fallback)."""
288
+ # Create a project without pyproject.toml
289
+ project_root = tmp_path / "test_project_no_pyproject"
290
+ project_root.mkdir()
291
+
292
+ subfolder = project_root / "subfolder"
293
+ subfolder.mkdir()
294
+
295
+ config = SubfolderBuildConfig(
296
+ project_root=project_root,
297
+ src_dir=subfolder,
298
+ version="1.0.0",
299
+ )
300
+ # Should fallback to just subfolder name when root project name not found
301
+ assert config.package_name == "subfolder"
285
302
 
286
303
 
287
304
  def test_readme_handling_with_existing_readme(test_project_with_pyproject: Path):
@@ -666,8 +683,8 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
666
683
  assert "[tool.hatch.version]" not in content
667
684
  assert "[tool.uv-dynamic-versioning]" not in content
668
685
 
669
- # Verify packages path is set correctly
670
- assert 'packages = ["subfolder"]' in content or '"subfolder"' in content
686
+ # Verify packages path is set correctly (should use temp package directory)
687
+ assert '.temp_package_my_custom_package' in content
671
688
 
672
689
  # Verify backup was created
673
690
  assert (project_root / "pyproject.toml.original").exists()
@@ -717,8 +734,8 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
717
734
  # Verify only-include is present
718
735
  assert "only-include = [" in content
719
736
 
720
- # Verify the subfolder is included
721
- assert '"subfolder"' in content
737
+ # Verify the temp package directory is included (not the original subfolder)
738
+ assert '.temp_package_test_package' in content
722
739
 
723
740
  # Verify necessary files are included
724
741
  assert '"pyproject.toml"' in content
@@ -856,7 +873,7 @@ description = "Subfolder package"
856
873
  # Verify it was modified
857
874
  modified_content = (project_root / "pyproject.toml").read_text()
858
875
  assert modified_content != original_content
859
- assert 'name = "subfolder"' in modified_content
876
+ assert 'name = "test-package-subfolder"' in modified_content
860
877
 
861
878
  # Restore
862
879
  config.restore()
@@ -933,3 +950,173 @@ description = "Subfolder package"
933
950
  assert 'build-backend = "hatchling.build"' in content
934
951
 
935
952
  config.restore()
953
+
954
+
955
+ class TestTemporaryPackageDirectory:
956
+ """Tests for temporary package directory creation and cleanup."""
957
+
958
+ def test_temp_package_directory_created_with_correct_name(
959
+ self, test_project_with_pyproject: Path
960
+ ) -> None:
961
+ """Test that temporary package directory is created with correct import name."""
962
+ project_root = test_project_with_pyproject
963
+ subfolder = project_root / "subfolder"
964
+ (subfolder / "module.py").write_text("def func(): pass")
965
+
966
+ config = SubfolderBuildConfig(
967
+ project_root=project_root,
968
+ src_dir=subfolder,
969
+ version="1.0.0",
970
+ )
971
+
972
+ # Package name should be "test-package-subfolder" (with hyphens)
973
+ assert config.package_name == "test-package-subfolder"
974
+
975
+ # Create temp pyproject (which creates temp package directory)
976
+ config.create_temp_pyproject()
977
+
978
+ # Temp package directory should exist with import name (underscores)
979
+ temp_package_dir = project_root / ".temp_package_test_package_subfolder"
980
+ assert temp_package_dir.exists()
981
+ assert config._temp_package_dir == temp_package_dir
982
+
983
+ # Temp package directory should contain the subfolder contents
984
+ assert (temp_package_dir / "module.py").exists()
985
+
986
+ # Cleanup
987
+ config.restore()
988
+
989
+ def test_temp_package_directory_uses_import_name(
990
+ self, test_project_with_pyproject: Path
991
+ ) -> None:
992
+ """Test that temp package directory name converts hyphens to underscores."""
993
+ project_root = test_project_with_pyproject
994
+ subfolder = project_root / "subfolder"
995
+ (subfolder / "module.py").write_text("def func(): pass")
996
+
997
+ config = SubfolderBuildConfig(
998
+ project_root=project_root,
999
+ src_dir=subfolder,
1000
+ version="1.0.0",
1001
+ package_name="my-custom-package", # Package name with hyphens
1002
+ )
1003
+
1004
+ config.create_temp_pyproject()
1005
+
1006
+ # Temp directory should use underscores (import name)
1007
+ temp_package_dir = project_root / ".temp_package_my_custom_package"
1008
+ assert temp_package_dir.exists()
1009
+ assert config._temp_package_dir == temp_package_dir
1010
+
1011
+ config.restore()
1012
+
1013
+ def test_temp_package_directory_cleaned_up(self, test_project_with_pyproject: Path) -> None:
1014
+ """Test that temporary package directory is cleaned up on restore."""
1015
+ project_root = test_project_with_pyproject
1016
+ subfolder = project_root / "subfolder"
1017
+ (subfolder / "module.py").write_text("def func(): pass")
1018
+
1019
+ config = SubfolderBuildConfig(
1020
+ project_root=project_root,
1021
+ src_dir=subfolder,
1022
+ version="1.0.0",
1023
+ )
1024
+
1025
+ config.create_temp_pyproject()
1026
+
1027
+ # Verify temp directory exists
1028
+ temp_package_dir = config._temp_package_dir
1029
+ assert temp_package_dir is not None
1030
+ assert temp_package_dir.exists()
1031
+
1032
+ # Restore should clean it up
1033
+ config.restore()
1034
+
1035
+ # Temp directory should be removed
1036
+ assert not temp_package_dir.exists()
1037
+ assert config._temp_package_dir is None
1038
+
1039
+ def test_packages_configuration_uses_temp_directory(
1040
+ self, test_project_with_pyproject: Path
1041
+ ) -> None:
1042
+ """Test that packages configuration uses temp directory path."""
1043
+ project_root = test_project_with_pyproject
1044
+ subfolder = project_root / "subfolder"
1045
+ (subfolder / "module.py").write_text("def func(): pass")
1046
+
1047
+ config = SubfolderBuildConfig(
1048
+ project_root=project_root,
1049
+ src_dir=subfolder,
1050
+ version="1.0.0",
1051
+ )
1052
+
1053
+ pyproject_path = config.create_temp_pyproject()
1054
+ assert pyproject_path is not None
1055
+
1056
+ content = pyproject_path.read_text()
1057
+
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
1061
+
1062
+ config.restore()
1063
+
1064
+ def test_temp_package_directory_preserves_structure(
1065
+ self, test_project_with_pyproject: Path
1066
+ ) -> None:
1067
+ """Test that temp package directory preserves the original directory structure."""
1068
+ project_root = test_project_with_pyproject
1069
+ subfolder = project_root / "subfolder"
1070
+ (subfolder / "module.py").write_text("def func(): pass")
1071
+ (subfolder / "submodule").mkdir()
1072
+ (subfolder / "submodule" / "__init__.py").write_text("")
1073
+ (subfolder / "submodule" / "helper.py").write_text("def helper(): pass")
1074
+
1075
+ config = SubfolderBuildConfig(
1076
+ project_root=project_root,
1077
+ src_dir=subfolder,
1078
+ version="1.0.0",
1079
+ )
1080
+
1081
+ config.create_temp_pyproject()
1082
+
1083
+ temp_package_dir = config._temp_package_dir
1084
+ assert temp_package_dir is not None
1085
+
1086
+ # Verify structure is preserved
1087
+ assert (temp_package_dir / "module.py").exists()
1088
+ assert (temp_package_dir / "submodule" / "__init__.py").exists()
1089
+ assert (temp_package_dir / "submodule" / "helper.py").exists()
1090
+
1091
+ config.restore()
1092
+
1093
+ def test_temp_package_directory_handles_existing_directory(
1094
+ self, test_project_with_pyproject: Path
1095
+ ) -> None:
1096
+ """Test that temp package directory creation handles existing directory."""
1097
+ project_root = test_project_with_pyproject
1098
+ subfolder = project_root / "subfolder"
1099
+ (subfolder / "module.py").write_text("def func(): pass")
1100
+
1101
+ # Create a directory that would conflict
1102
+ existing_temp_dir = project_root / ".temp_package_test_package_subfolder"
1103
+ existing_temp_dir.mkdir()
1104
+ (existing_temp_dir / "old_file.py").write_text("# Old file")
1105
+
1106
+ config = SubfolderBuildConfig(
1107
+ project_root=project_root,
1108
+ src_dir=subfolder,
1109
+ version="1.0.0",
1110
+ )
1111
+
1112
+ # Should remove existing directory and create new one
1113
+ config.create_temp_pyproject()
1114
+
1115
+ temp_package_dir = config._temp_package_dir
1116
+ assert temp_package_dir is not None
1117
+ assert temp_package_dir.exists()
1118
+ # Should have new file, not old file
1119
+ assert (temp_package_dir / "module.py").exists()
1120
+ assert not (temp_package_dir / "old_file.py").exists()
1121
+
1122
+ config.restore()