python-package-folder 1.1.3__py3-none-any.whl → 1.2.1__py3-none-any.whl

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.
@@ -45,13 +45,55 @@ class ImportAnalyzer:
45
45
  """
46
46
  Recursively find all Python files in a directory.
47
47
 
48
+ Excludes common directories like .venv, venv, __pycache__, etc.
49
+
48
50
  Args:
49
51
  directory: Directory to search for Python files
50
52
 
51
53
  Returns:
52
54
  List of paths to all .py files found in the directory tree
53
55
  """
54
- return [path for path in directory.rglob("*.py") if path.is_file()]
56
+ exclude_patterns = {
57
+ ".venv",
58
+ "venv",
59
+ "__pycache__",
60
+ ".git",
61
+ ".pytest_cache",
62
+ ".mypy_cache",
63
+ "node_modules",
64
+ ".tox",
65
+ "dist",
66
+ "build",
67
+ }
68
+
69
+ python_files = []
70
+ for path in directory.rglob("*.py"):
71
+ if not path.is_file():
72
+ continue
73
+
74
+ # Check if any part of the path matches exclusion patterns
75
+ should_exclude = False
76
+ for part in path.parts:
77
+ # Check exact matches
78
+ if part in exclude_patterns:
79
+ should_exclude = True
80
+ break
81
+ # Check if part starts with excluded pattern or contains .egg-info
82
+ for pattern in exclude_patterns:
83
+ if part.startswith(pattern):
84
+ should_exclude = True
85
+ break
86
+ # Also exclude .egg-info directories
87
+ if ".egg-info" in part:
88
+ should_exclude = True
89
+ break
90
+ if should_exclude:
91
+ break
92
+
93
+ if not should_exclude:
94
+ python_files.append(path)
95
+
96
+ return python_files
55
97
 
56
98
  def extract_imports(self, file_path: Path) -> list[ImportInfo]:
57
99
  """
@@ -85,15 +85,13 @@ class BuildManager:
85
85
  self.finder = ExternalDependencyFinder(
86
86
  self.project_root, self.src_dir, exclude_patterns=exclude_patterns
87
87
  )
88
+ self.subfolder_config: SubfolderBuildConfig | None = None
88
89
 
89
90
  # Check if it's a valid Python package directory
90
91
  if not any(self.src_dir.glob("*.py")) and not (self.src_dir / "__init__.py").exists():
91
92
  # Allow empty directories for now, but warn
92
93
  pass
93
94
 
94
- self.copied_files: list[Path] = []
95
- self.copied_dirs: list[Path] = []
96
-
97
95
  def find_src_package_dir(self) -> Path | None:
98
96
  """
99
97
  Find the main package directory within src/.
@@ -125,6 +123,21 @@ class BuildManager:
125
123
  # Return the first one or src_dir itself
126
124
  return package_dirs[0] if package_dirs else self.src_dir
127
125
 
126
+ def _is_subfolder_build(self) -> bool:
127
+ """
128
+ Check if we're building a subfolder (not the main src/ directory).
129
+
130
+ Returns:
131
+ True if this is a subfolder build, False otherwise
132
+ """
133
+ # Check if src_dir is not the main src/ directory
134
+ main_src = self.project_root / "src"
135
+ return (
136
+ self.src_dir != main_src
137
+ and self.src_dir != self.project_root
138
+ and self.src_dir.is_relative_to(self.project_root)
139
+ )
140
+
128
141
  def _get_project_name(self) -> str | None:
129
142
  """
130
143
  Get the project name from pyproject.toml.
@@ -155,19 +168,94 @@ class BuildManager:
155
168
 
156
169
  return None
157
170
 
158
- def prepare_build(self) -> list[ExternalDependency]:
171
+ def prepare_build(
172
+ self,
173
+ version: str | None = None,
174
+ package_name: str | None = None,
175
+ dependency_group: str | None = None,
176
+ ) -> list[ExternalDependency]:
159
177
  """
160
178
  Prepare for build by finding and copying external dependencies.
161
179
 
162
- This method:
163
- 1. Finds all Python files in the source directory
164
- 2. Analyzes them for external dependencies
165
- 3. Copies external files/directories into the source directory
166
- 4. Reports any ambiguous imports
180
+ This method automatically detects if you're building a subfolder (not the main src/
181
+ directory) and sets up pyproject.toml appropriately:
182
+ - If pyproject.toml exists in the subfolder, it will be used
183
+ - Otherwise, creates a temporary pyproject.toml with the correct package configuration
184
+ For subfolder builds without their own pyproject.toml, if no version is provided,
185
+ it defaults to "0.0.0" with a warning.
186
+
187
+ Process:
188
+ 1. Detects if this is a subfolder build and sets up pyproject.toml if needed:
189
+ - If pyproject.toml exists in subfolder: uses that file
190
+ - If no pyproject.toml in subfolder: creates temporary one from parent
191
+ 2. Finds all Python files in the source directory
192
+ 3. Analyzes them for external dependencies
193
+ 4. Copies external files/directories into the source directory
194
+ 5. Reports any ambiguous imports
195
+
196
+ Args:
197
+ version: Version for subfolder builds. If building a subfolder and version is None,
198
+ defaults to "0.0.0" with a warning. Only used when creating temporary pyproject.toml
199
+ (ignored if subfolder has its own pyproject.toml). For regular builds, this parameter
200
+ is ignored.
201
+ package_name: Package name for subfolder builds. If None, derived from src_dir name
202
+ (e.g., "empty_drawing_detection" -> "empty-drawing-detection"). Only used when
203
+ creating temporary pyproject.toml (ignored if subfolder has its own pyproject.toml).
204
+ Ignored for regular builds.
205
+ dependency_group: Name of dependency group to copy from parent pyproject.toml.
206
+ Only used for subfolder builds when creating temporary pyproject.toml.
167
207
 
168
208
  Returns:
169
209
  List of ExternalDependency objects that were copied
210
+
211
+ Example:
212
+ ```python
213
+ # Regular build (main src/ directory)
214
+ manager = BuildManager(project_root=Path("."), src_dir=Path("src"))
215
+ deps = manager.prepare_build() # No version needed
216
+
217
+ # Subfolder build (automatic detection)
218
+ manager = BuildManager(
219
+ project_root=Path("."),
220
+ src_dir=Path("src/integration/empty_drawing_detection")
221
+ )
222
+ # Version defaults to "0.0.0" if not provided
223
+ deps = manager.prepare_build(version="1.0.0", package_name="my-package")
224
+ ```
170
225
  """
226
+ # Check if this is a subfolder build and set up config if needed
227
+ if self._is_subfolder_build():
228
+ # For subfolder builds, we need a version
229
+ # If not provided, use a default version
230
+ if not version:
231
+ version = "0.0.0"
232
+ print(
233
+ f"Warning: No version specified for subfolder build. Using default version '{version}'",
234
+ file=sys.stderr,
235
+ )
236
+
237
+ if not package_name:
238
+ # Derive package name from subfolder
239
+ package_name = (
240
+ self.src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
241
+ )
242
+
243
+ print(
244
+ f"Detected subfolder build. Setting up package '{package_name}' version '{version}'..."
245
+ )
246
+ self.subfolder_config = SubfolderBuildConfig(
247
+ project_root=self.project_root,
248
+ src_dir=self.src_dir,
249
+ package_name=package_name,
250
+ version=version,
251
+ dependency_group=dependency_group,
252
+ )
253
+ temp_pyproject = self.subfolder_config.create_temp_pyproject()
254
+ # If temp_pyproject is None, it means no parent pyproject.toml exists
255
+ # This is acceptable for tests or dependency-only operations
256
+ if temp_pyproject is None:
257
+ self.subfolder_config = None
258
+
171
259
  analyzer = ImportAnalyzer(self.project_root)
172
260
 
173
261
  # Find all Python files in src/
@@ -325,10 +413,30 @@ class BuildManager:
325
413
  """
326
414
  Remove all copied files and directories.
327
415
 
328
- This method removes all files and directories that were copied
329
- during prepare_build(). It handles errors gracefully and clears
330
- the internal tracking lists.
416
+ This method removes all files and directories that were copied during prepare_build().
417
+ It also restores the original pyproject.toml if a temporary one was created for a
418
+ subfolder build. It handles errors gracefully and clears the internal tracking lists.
419
+
420
+ This is automatically called by run_build(), but you can call it manually if you
421
+ use prepare_build() directly.
422
+
423
+ Example:
424
+ ```python
425
+ manager = BuildManager(project_root=Path("."), src_dir=Path("src"))
426
+ deps = manager.prepare_build()
427
+ # ... do your build ...
428
+ manager.cleanup() # Restores pyproject.toml and removes copied files
429
+ ```
331
430
  """
431
+ # Restore subfolder config if it was created
432
+ if self.subfolder_config:
433
+ try:
434
+ self.subfolder_config.restore()
435
+ print("Restored original pyproject.toml")
436
+ except Exception as e:
437
+ print(f"Warning: Could not restore pyproject.toml: {e}", file=sys.stderr)
438
+ self.subfolder_config = None
439
+
332
440
  # Remove copied directories first (they may contain files)
333
441
  for dir_path in reversed(self.copied_dirs):
334
442
  if dir_path.exists():
@@ -350,29 +458,59 @@ class BuildManager:
350
458
  self.copied_files.clear()
351
459
  self.copied_dirs.clear()
352
460
 
353
- def run_build(self, build_command: Callable[[], None]) -> None:
461
+ def run_build(
462
+ self,
463
+ build_command: Callable[[], None],
464
+ version: str | None = None,
465
+ package_name: str | None = None,
466
+ dependency_group: str | None = None,
467
+ ) -> None:
354
468
  """
355
469
  Run the build process with dependency management.
356
470
 
357
- This is a convenience method that:
358
- 1. Calls prepare_build() to find and copy dependencies
471
+ This is a convenience method that automatically handles the full build lifecycle:
472
+ 1. Calls prepare_build() to find and copy dependencies (with automatic subfolder detection)
359
473
  2. Executes the provided build_command
360
474
  3. Always calls cleanup() afterward, even if build fails
361
475
 
476
+ For subfolder builds, this method automatically detects the subfolder and creates a
477
+ temporary pyproject.toml with the correct package configuration. The build command
478
+ should be runnable from the project root (e.g., "uv build", "python -m build").
479
+
362
480
  Args:
363
- build_command: Callable that executes the build process
481
+ build_command: Callable that executes the build process. Should run from project root.
482
+ version: Version for subfolder builds. If building a subfolder and version is None,
483
+ defaults to "0.0.0" with a warning. Ignored for regular builds.
484
+ package_name: Package name for subfolder builds. If None, derived from src_dir name.
485
+ Ignored for regular builds.
486
+ dependency_group: Name of dependency group to copy from parent pyproject.toml.
487
+ Only used for subfolder builds.
364
488
 
365
489
  Example:
366
490
  ```python
491
+ from pathlib import Path
492
+ from python_package_folder import BuildManager
493
+ import subprocess
494
+
495
+ # Regular build
496
+ manager = BuildManager(project_root=Path("."), src_dir=Path("src"))
367
497
  def build():
368
498
  subprocess.run(["uv", "build"], check=True)
369
-
370
499
  manager.run_build(build)
500
+
501
+ # Subfolder build (automatic detection)
502
+ manager = BuildManager(
503
+ project_root=Path("."),
504
+ src_dir=Path("src/integration/empty_drawing_detection")
505
+ )
506
+ manager.run_build(build, version="1.0.0")
371
507
  ```
372
508
  """
373
509
  try:
374
510
  print("Analyzing project for external dependencies...")
375
- external_deps = self.prepare_build()
511
+ external_deps = self.prepare_build(
512
+ version=version, package_name=package_name, dependency_group=dependency_group
513
+ )
376
514
 
377
515
  if external_deps:
378
516
  print(f"\nFound {len(external_deps)} external dependencies")
@@ -444,49 +582,37 @@ class BuildManager:
444
582
 
445
583
  version_manager = None
446
584
  original_version = None
447
- subfolder_config = None
448
-
449
- # Check if we're building a subfolder (not the main src/ directory)
450
- is_subfolder_build = not self.src_dir.is_relative_to(self.project_root / "src") or (
451
- self.src_dir != self.project_root / "src" and self.src_dir != self.project_root
452
- )
453
585
 
454
586
  try:
455
- # For subfolder builds, create a temporary pyproject.toml
456
- if is_subfolder_build and version:
457
- if not package_name:
458
- # Derive package name from subfolder
459
- package_name = (
460
- self.src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
461
- )
462
- print(f"Building subfolder as package '{package_name}' version '{version}'...")
463
- subfolder_config = SubfolderBuildConfig(
464
- project_root=self.project_root,
465
- src_dir=self.src_dir,
466
- package_name=package_name,
467
- version=version,
468
- dependency_group=dependency_group,
469
- )
470
- subfolder_config.create_temp_pyproject()
471
- elif version:
472
- # Regular build with version override
587
+ # For non-subfolder builds with version, use VersionManager
588
+ if version and not self._is_subfolder_build():
473
589
  version_manager = VersionManager(self.project_root)
474
590
  original_version = version_manager.get_current_version()
475
591
  print(f"Setting version to {version}...")
476
592
  version_manager.set_version(version)
477
593
 
478
- # Build the package
479
- self.run_build(build_command)
594
+ # Build the package (prepare_build will handle subfolder config if needed)
595
+ self.run_build(
596
+ build_command,
597
+ version=version,
598
+ package_name=package_name,
599
+ dependency_group=dependency_group,
600
+ )
480
601
 
481
602
  # Publish if repository is specified
482
603
  if repository:
483
604
  # Determine package name and version for filtering
484
605
  publish_package_name = None
485
606
  publish_version = version
486
-
487
- if is_subfolder_build and package_name:
488
- publish_package_name = package_name
489
- elif not is_subfolder_build:
607
+ is_subfolder_build = self._is_subfolder_build()
608
+
609
+ if is_subfolder_build:
610
+ # Get package name from subfolder_config if it was created, otherwise use provided
611
+ if self.subfolder_config:
612
+ publish_package_name = self.subfolder_config.package_name
613
+ elif package_name:
614
+ publish_package_name = package_name
615
+ else:
490
616
  # For regular builds, get package name from pyproject.toml
491
617
  try:
492
618
  import tomllib
@@ -515,15 +641,7 @@ class BuildManager:
515
641
  )
516
642
  publisher.publish(skip_existing=skip_existing)
517
643
  finally:
518
- # Restore subfolder config if used
519
- if subfolder_config and restore_versioning:
520
- try:
521
- subfolder_config.restore()
522
- print("Restored original pyproject.toml")
523
- except Exception as e:
524
- print(f"Warning: Could not restore pyproject.toml: {e}", file=sys.stderr)
525
-
526
- # Restore versioning if needed
644
+ # Restore versioning if needed (subfolder config is handled by cleanup)
527
645
  if version_manager and restore_versioning:
528
646
  try:
529
647
  if original_version:
@@ -122,7 +122,8 @@ def main() -> int:
122
122
  src_dir = Path(args.src_dir).resolve()
123
123
  else:
124
124
  # Auto-detect: use current directory if it has Python files, otherwise use project_root/src
125
- src_dir = find_source_directory(project_root)
125
+ current_dir = Path.cwd()
126
+ src_dir = find_source_directory(project_root, current_dir=current_dir)
126
127
  if src_dir:
127
128
  print(f"Auto-detected source directory: {src_dir}")
128
129
  else:
@@ -29,8 +29,13 @@ class SubfolderBuildConfig:
29
29
  """
30
30
  Manages temporary build configuration for subfolder builds.
31
31
 
32
- When building a subfolder as a separate package, this class creates
33
- a temporary pyproject.toml with the appropriate package name and version.
32
+ When building a subfolder as a separate package, this class:
33
+ - Uses the subfolder's pyproject.toml if it exists (adjusts package paths and ensures
34
+ [build-system] uses hatchling)
35
+ - Otherwise creates a temporary pyproject.toml with the appropriate package name and version
36
+ - Always ensures [build-system] section uses hatchling (replaces any existing build-system
37
+ configuration from parent or subfolder)
38
+ - Handles README files similarly (uses subfolder README if present)
34
39
  """
35
40
 
36
41
  def __init__(
@@ -47,9 +52,12 @@ class SubfolderBuildConfig:
47
52
  Args:
48
53
  project_root: Root directory containing the main pyproject.toml
49
54
  src_dir: Source directory being built (subfolder)
50
- package_name: Name for the subfolder package (default: derived from src_dir name)
51
- version: Version for the subfolder package (required if building subfolder)
52
- dependency_group: Name of dependency group to copy from parent pyproject.toml
55
+ package_name: Name for the subfolder package (default: derived from src_dir name).
56
+ Only used if subfolder doesn't have its own pyproject.toml.
57
+ version: Version for the subfolder package (required if building subfolder).
58
+ Only used if subfolder doesn't have its own pyproject.toml.
59
+ dependency_group: Name of dependency group to copy from parent pyproject.toml.
60
+ Only used if subfolder doesn't have its own pyproject.toml.
53
61
  """
54
62
  self.project_root = project_root.resolve()
55
63
  self.src_dir = src_dir.resolve()
@@ -61,6 +69,7 @@ class SubfolderBuildConfig:
61
69
  self._temp_init_created = False
62
70
  self.temp_readme: Path | None = None
63
71
  self.original_readme_backup: Path | None = None
72
+ self._used_subfolder_pyproject = False
64
73
 
65
74
  def _derive_package_name(self) -> str:
66
75
  """Derive package name from source directory name."""
@@ -105,15 +114,100 @@ class SubfolderBuildConfig:
105
114
  # If it's a package or has subpackages, return the path
106
115
  return packages_path, [packages_path] if packages_path else []
107
116
 
108
- def create_temp_pyproject(self) -> Path:
117
+ def _adjust_subfolder_pyproject_packages_path(self, content: str) -> str:
118
+ """
119
+ Adjust packages path in subfolder pyproject.toml to be relative to project root.
120
+
121
+ When a subfolder's pyproject.toml is copied to project root, the packages path
122
+ needs to be adjusted to point to the subfolder relative to the project root.
123
+
124
+ Args:
125
+ content: Content of the subfolder's pyproject.toml
126
+
127
+ Returns:
128
+ Adjusted content with correct packages path
129
+ """
130
+ # Get the correct packages path relative to project root
131
+ _, package_dirs = self._get_package_structure()
132
+ if not package_dirs:
133
+ # No adjustment needed if we can't determine the path
134
+ return content
135
+
136
+ correct_packages_path = package_dirs[0]
137
+ lines = content.split("\n")
138
+ result = []
139
+ in_hatch_build = False
140
+ packages_set = False
141
+
142
+ for line in lines:
143
+ # Detect hatch build section
144
+ if line.strip().startswith("[tool.hatch.build.targets.wheel]"):
145
+ in_hatch_build = True
146
+ result.append(line)
147
+ continue
148
+ elif line.strip().startswith("[") and in_hatch_build:
149
+ # End of hatch build section, add packages if not set
150
+ if not packages_set and correct_packages_path:
151
+ packages_str = f'"{correct_packages_path}"'
152
+ result.append(f"packages = [{packages_str}]")
153
+ in_hatch_build = False
154
+ result.append(line)
155
+ elif in_hatch_build:
156
+ # Modify packages path if found
157
+ if re.match(r"^\s*packages\s*=", line):
158
+ packages_str = f'"{correct_packages_path}"'
159
+ result.append(f"packages = [{packages_str}]")
160
+ packages_set = True
161
+ continue
162
+ # Keep other lines in hatch build section
163
+ result.append(line)
164
+ else:
165
+ result.append(line)
166
+
167
+ # Add packages if we're still in hatch build section and haven't set it
168
+ if in_hatch_build and not packages_set and correct_packages_path:
169
+ packages_str = f'"{correct_packages_path}"'
170
+ result.append(f"packages = [{packages_str}]")
171
+
172
+ # Ensure build-system section exists (required for hatchling)
173
+ # Check if build-system section exists in the result
174
+ has_build_system = any(line.strip().startswith("[build-system]") for line in result)
175
+ if not has_build_system:
176
+ # Insert build-system at the very beginning of the file
177
+ build_system_lines = [
178
+ "[build-system]",
179
+ 'requires = ["hatchling"]',
180
+ 'build-backend = "hatchling.build"',
181
+ "",
182
+ ]
183
+ result = build_system_lines + result
184
+
185
+ # Ensure hatch build section exists if packages path is needed
186
+ if not packages_set and correct_packages_path:
187
+ # Check if we need to add the section
188
+ if "[tool.hatch.build.targets.wheel]" not in content:
189
+ result.append("")
190
+ result.append("[tool.hatch.build.targets.wheel]")
191
+ packages_str = f'"{correct_packages_path}"'
192
+ result.append(f"packages = [{packages_str}]")
193
+
194
+ return "\n".join(result)
195
+
196
+ def create_temp_pyproject(self) -> Path | None:
109
197
  """
110
198
  Create a temporary pyproject.toml for the subfolder build.
111
199
 
112
- This creates a pyproject.toml in the project root that overrides
113
- the package name and version for building the subfolder.
200
+ If a pyproject.toml exists in the subfolder, it will be used (copied to project root
201
+ with adjusted package paths and ensuring [build-system] uses hatchling). Otherwise,
202
+ creates a pyproject.toml in the project root based on the parent pyproject.toml with
203
+ the appropriate package name and version.
204
+
205
+ The [build-system] section is always set to use hatchling, even if the parent or
206
+ subfolder pyproject.toml uses a different build backend (e.g., setuptools).
114
207
 
115
208
  Returns:
116
- Path to the temporary pyproject.toml file
209
+ Path to the pyproject.toml file (either from subfolder or created temporary),
210
+ or None if no parent pyproject.toml exists (in which case subfolder config is skipped)
117
211
  """
118
212
  if not self.version:
119
213
  raise ValueError("Version is required for subfolder builds")
@@ -127,10 +221,52 @@ class SubfolderBuildConfig:
127
221
  else:
128
222
  self._temp_init_created = False
129
223
 
224
+ # Check if pyproject.toml exists in subfolder
225
+ subfolder_pyproject = self.src_dir / "pyproject.toml"
226
+ if subfolder_pyproject.exists():
227
+ # Use the subfolder's pyproject.toml
228
+ print(f"Using existing pyproject.toml from subfolder: {subfolder_pyproject}")
229
+ self._used_subfolder_pyproject = True
230
+
231
+ # Backup the original project root pyproject.toml if it exists
232
+ original_pyproject = self.project_root / "pyproject.toml"
233
+ if original_pyproject.exists():
234
+ backup_path = self.project_root / "pyproject.toml.backup"
235
+ shutil.copy2(original_pyproject, backup_path)
236
+ self.original_pyproject_backup = backup_path
237
+
238
+ # Read and adjust the subfolder pyproject.toml
239
+ subfolder_content = subfolder_pyproject.read_text(encoding="utf-8")
240
+ # Adjust packages path to be relative to project root
241
+ adjusted_content = self._adjust_subfolder_pyproject_packages_path(subfolder_content)
242
+
243
+ # Write adjusted content to project root
244
+ original_pyproject.write_text(adjusted_content, encoding="utf-8")
245
+ self.temp_pyproject = original_pyproject
246
+
247
+ # Handle README file
248
+ self._handle_readme()
249
+
250
+ return original_pyproject
251
+
252
+ # No pyproject.toml in subfolder, create one from parent
253
+ self._used_subfolder_pyproject = False
254
+ print("No pyproject.toml found in subfolder, creating temporary one from parent")
255
+
130
256
  # Read the original pyproject.toml
131
257
  original_pyproject = self.project_root / "pyproject.toml"
132
258
  if not original_pyproject.exists():
133
- raise FileNotFoundError(f"pyproject.toml not found: {original_pyproject}")
259
+ # If no parent pyproject.toml exists, we can't create a temporary one
260
+ # This is acceptable for tests or cases where only dependency copying is needed
261
+ print(
262
+ f"Warning: No pyproject.toml found in project root ({original_pyproject}). "
263
+ "Skipping subfolder build configuration. Only dependency copying will be performed.",
264
+ file=sys.stderr,
265
+ )
266
+ # Still handle README file
267
+ self._handle_readme()
268
+ # Return None to indicate no pyproject.toml was created
269
+ return None
134
270
 
135
271
  original_content = original_pyproject.read_text(encoding="utf-8")
136
272
 
@@ -214,6 +350,7 @@ class SubfolderBuildConfig:
214
350
  skip_uv_dynamic = False
215
351
  in_hatch_build = False
216
352
  packages_set = False
353
+ build_system_set = False
217
354
 
218
355
  # Get package structure
219
356
  packages_path, package_dirs = self._get_package_structure()
@@ -221,6 +358,19 @@ class SubfolderBuildConfig:
221
358
  package_dirs = []
222
359
 
223
360
  for _i, line in enumerate(lines):
361
+ # Skip build-system section - we'll add our own for subfolder builds
362
+ if line.strip().startswith("[build-system]"):
363
+ build_system_set = True
364
+ continue # Skip the [build-system] line
365
+ elif build_system_set and line.strip().startswith("["):
366
+ # End of build-system section
367
+ build_system_set = False
368
+ result.append(line)
369
+ continue
370
+ elif build_system_set:
371
+ # Skip build-system content - we'll add our own
372
+ continue
373
+
224
374
  # Skip hatch versioning and uv-dynamic-versioning sections
225
375
  if line.strip().startswith("[tool.hatch.version]"):
226
376
  skip_hatch_version = True
@@ -316,6 +466,19 @@ class SubfolderBuildConfig:
316
466
  packages_str = ", ".join(f'"{p}"' for p in package_dirs)
317
467
  result.append(f"packages = [{packages_str}]")
318
468
 
469
+ # Ensure build-system section exists (required for hatchling)
470
+ # Check if build-system section exists in the result
471
+ has_build_system = any(line.strip().startswith("[build-system]") for line in result)
472
+ if not has_build_system:
473
+ # Insert build-system at the very beginning of the file
474
+ build_system_lines = [
475
+ "[build-system]",
476
+ 'requires = ["hatchling"]',
477
+ 'build-backend = "hatchling.build"',
478
+ "",
479
+ ]
480
+ result = build_system_lines + result
481
+
319
482
  # Ensure packages is always set for subfolder builds
320
483
  if not packages_set and package_dirs:
321
484
  # Add the section if it doesn't exist
@@ -419,7 +582,13 @@ class SubfolderBuildConfig:
419
582
  self.temp_readme = target_readme
420
583
 
421
584
  def restore(self) -> None:
422
- """Restore the original pyproject.toml and remove temporary __init__.py if created."""
585
+ """
586
+ Restore the original pyproject.toml and remove temporary __init__.py if created.
587
+
588
+ If a subfolder pyproject.toml was used, it restores the original project root
589
+ pyproject.toml from backup. If a temporary pyproject.toml was created, it
590
+ restores the original as well.
591
+ """
423
592
  # Remove temporary __init__.py if we created it
424
593
  if self._temp_init_created:
425
594
  init_file = self.src_dir / "__init__.py"
@@ -460,13 +629,18 @@ class SubfolderBuildConfig:
460
629
  pass # Ignore errors during cleanup
461
630
  self.temp_readme = None
462
631
 
463
- # Restore original pyproject.toml
464
- if self.original_pyproject_backup and self.original_pyproject_backup.exists():
632
+ # Restore original pyproject.toml (only if we created/used one)
633
+ if (
634
+ self.temp_pyproject
635
+ and self.original_pyproject_backup
636
+ and self.original_pyproject_backup.exists()
637
+ ):
465
638
  original_pyproject = self.project_root / "pyproject.toml"
466
639
  shutil.copy2(self.original_pyproject_backup, original_pyproject)
467
640
  self.original_pyproject_backup.unlink()
468
641
  self.original_pyproject_backup = None
469
642
  self.temp_pyproject = None
643
+ self._used_subfolder_pyproject = False
470
644
 
471
645
  def __enter__(self) -> Self:
472
646
  """Context manager entry."""
@@ -63,7 +63,8 @@ def find_source_directory(project_root: Path, current_dir: Path | None = None) -
63
63
  project_root = project_root.resolve()
64
64
 
65
65
  # Check if current directory is a subdirectory with Python files
66
- if current_dir.is_relative_to(project_root) or current_dir == project_root:
66
+ # Prioritize current directory if it's within the project and has Python files
67
+ if current_dir.is_relative_to(project_root) and current_dir != project_root:
67
68
  python_files = list(current_dir.glob("*.py"))
68
69
  if python_files:
69
70
  # Current directory has Python files, use it as source
@@ -74,10 +75,11 @@ def find_source_directory(project_root: Path, current_dir: Path | None = None) -
74
75
  if src_dir.exists() and src_dir.is_dir():
75
76
  return src_dir
76
77
 
77
- # Check if project_root itself has Python files
78
- python_files = list(project_root.glob("*.py"))
79
- if python_files:
80
- return project_root
78
+ # Only check project_root if current_dir is the project_root
79
+ if current_dir == project_root:
80
+ python_files = list(project_root.glob("*.py"))
81
+ if python_files:
82
+ return project_root
81
83
 
82
84
  return None
83
85
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 1.1.3
3
+ Version: 1.2.1
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>
@@ -125,9 +125,11 @@ This package will automatically:
125
125
 
126
126
  ## Features
127
127
 
128
- - **Subfolder Build Support**: Build subfolders as separate packages with automatic project root detection
128
+ - **Subfolder Build Support**: Build subfolders as separate packages with automatic detection and configuration
129
+ - **Automatic subfolder detection**: Detects when building a subfolder (not the main `src/` directory)
129
130
  - Creates any needed file for publishing automatically, cleaning up if not originally in the subfolder after the build/publish process. E.g. copies external dependencies into the source directory before build and cleans them up afterward; temporary `__init__.py` creation for non-package subfolders; uses subfolder README if present, otherwise creates minimal README
130
131
  - Automatic package name derivation from subfolder name
132
+ - Automatic temporary `pyproject.toml` creation with correct package structure
131
133
  - Dependency group selection: specify which dependency group from parent `pyproject.toml` to include.
132
134
 
133
135
  - **Smart Import Classification and analysis**:
@@ -245,6 +247,7 @@ python-package-folder --project-root /path/to/project --src-dir /path/to/src --b
245
247
  - If found, copies the subfolder README to project root (backing up the original parent README)
246
248
  - If not found, creates a minimal README with just the folder name
247
249
  5. **Configuration Creation**: Creates temporary `pyproject.toml` with:
250
+ - `[build-system]` section using hatchling (replaces any existing build-system configuration)
248
251
  - Subfolder-specific package name (derived or custom)
249
252
  - Specified version
250
253
  - Correct package path for hatchling
@@ -268,16 +271,21 @@ cd my_project/subfolder_to_build
268
271
  python-package-folder --version "1.0.0" --publish pypi
269
272
  ```
270
273
 
271
- When building from a subdirectory, you **must** specify `--version` because subfolders are built as separate packages with their own version.
274
+ The tool **automatically detects** when you're building a subfolder (any directory that's not the main `src/` directory) and sets up the appropriate build configuration.
272
275
 
273
276
  The tool automatically:
277
+ - **Detects subfolder builds**: Automatically identifies when building from a subdirectory
274
278
  - Finds the project root by looking for `pyproject.toml` in parent directories
275
279
  - Uses the current directory as the source directory if it contains Python files
276
280
  - Falls back to `project_root/src` if the current directory isn't suitable
277
- - For subfolder builds: creates a temporary `pyproject.toml` with:
278
- - Package name derived from the subfolder name (or use `--package-name` to override)
279
- - Version from `--version` argument
280
- - Proper package path configuration for hatchling
281
+ - **For subfolder builds**: Handles `pyproject.toml` configuration:
282
+ - **If `pyproject.toml` exists in subfolder**: Uses that file (copies it to project root temporarily, adjusting package paths and ensuring `[build-system]` uses hatchling)
283
+ - **If no `pyproject.toml` in subfolder**: Creates a temporary `pyproject.toml` with:
284
+ - `[build-system]` section using hatchling (always uses hatchling, even if parent uses setuptools)
285
+ - Package name derived from the subfolder name (e.g., `empty_drawing_detection` → `empty-drawing-detection`)
286
+ - Version from `--version` argument (defaults to `0.0.0` with a warning if not provided)
287
+ - Proper package path configuration for hatchling
288
+ - Dependency groups from parent `pyproject.toml` if specified
281
289
  - Creates temporary `__init__.py` files if needed to make subfolders valid Python packages
282
290
  - **README handling for subfolder builds**:
283
291
  - If a README file (README.md, README.rst, README.txt, or README) exists in the subfolder, it will be used instead of the parent README
@@ -285,6 +293,8 @@ The tool automatically:
285
293
  - Restores the original `pyproject.toml` after build (unless `--no-restore-versioning` is used)
286
294
  - Cleans up temporary `__init__.py` files after build
287
295
 
296
+ **Note**: While version is not strictly required (defaults to `0.0.0`), it's recommended to specify `--version` for subfolder builds to ensure proper versioning.
297
+
288
298
  **Subfolder Build Example:**
289
299
  ```bash
290
300
  # Build a subfolder as a separate package
@@ -293,6 +303,11 @@ python-package-folder --version "0.1.0" --package-name "my-subfolder-package" --
293
303
 
294
304
  # Build with a specific dependency group from parent pyproject.toml
295
305
  python-package-folder --version "0.1.0" --dependency-group "dev" --publish pypi
306
+
307
+ # If subfolder has its own pyproject.toml, it will be used automatically
308
+ # (package-name and version arguments are ignored in this case)
309
+ cd src/integration/my_package # assuming my_package/pyproject.toml exists
310
+ python-package-folder --publish pypi
296
311
  ```
297
312
 
298
313
  **Dependency Groups**: When building a subfolder, you can specify a dependency group from the parent `pyproject.toml` to include in the subfolder's build configuration. This allows subfolders to inherit specific dependencies from the parent project:
@@ -308,6 +323,8 @@ The specified dependency group will be copied from the parent `pyproject.toml`'s
308
323
 
309
324
  You can also use the package programmatically:
310
325
 
326
+ ### Basic Usage
327
+
311
328
  ```python
312
329
  from pathlib import Path
313
330
  from python_package_folder import BuildManager
@@ -328,11 +345,11 @@ for dep in external_deps:
328
345
  # Run your build process here
329
346
  # ...
330
347
 
331
- # Cleanup copied files
348
+ # Cleanup copied files (also restores pyproject.toml if subfolder build)
332
349
  manager.cleanup()
333
350
  ```
334
351
 
335
- Or use the convenience method:
352
+ ### Using the Convenience Method
336
353
 
337
354
  ```python
338
355
  from pathlib import Path
@@ -348,6 +365,55 @@ def build_command():
348
365
  manager.run_build(build_command)
349
366
  ```
350
367
 
368
+ ### Subfolder Builds (Automatic Detection)
369
+
370
+ The tool automatically detects when you're building a subfolder and sets up the appropriate configuration:
371
+
372
+ ```python
373
+ from pathlib import Path
374
+ from python_package_folder import BuildManager
375
+ import subprocess
376
+
377
+ # Building a subfolder - automatic detection!
378
+ manager = BuildManager(
379
+ project_root=Path("."),
380
+ src_dir=Path("src/integration/empty_drawing_detection")
381
+ )
382
+
383
+ def build_command():
384
+ subprocess.run(["uv", "build"], check=True)
385
+
386
+ # prepare_build() automatically:
387
+ # - Detects this is a subfolder build
388
+ # - If pyproject.toml exists in subfolder: uses that file
389
+ # - If no pyproject.toml in subfolder: creates temporary one with package name "empty-drawing-detection"
390
+ # - Uses version "0.0.0" (or pass version="1.0.0" to override) if creating temporary pyproject.toml
391
+ external_deps = manager.prepare_build(version="1.0.0")
392
+
393
+ # Run build - uses the pyproject.toml (either from subfolder or temporary)
394
+ build_command()
395
+
396
+ # Cleanup restores original pyproject.toml and removes copied files
397
+ manager.cleanup()
398
+ ```
399
+
400
+ **Note**: If the subfolder has its own `pyproject.toml`, it will be used automatically. The `version` and `package_name` parameters are only used when creating a temporary `pyproject.toml` from the parent configuration.
401
+
402
+ Or use the convenience method:
403
+
404
+ ```python
405
+ manager = BuildManager(
406
+ project_root=Path("."),
407
+ src_dir=Path("src/integration/empty_drawing_detection")
408
+ )
409
+
410
+ def build_command():
411
+ subprocess.run(["uv", "build"], check=True)
412
+
413
+ # All handled automatically: subfolder detection, pyproject.toml setup, build, cleanup
414
+ manager.run_build(build_command, version="1.0.0", package_name="my-custom-name")
415
+ ```
416
+
351
417
  ## Working with sysappend
352
418
 
353
419
  This package works well with projects using [sysappend](https://pypi.org/project/sysappend/) for flexible import management. When you have imports like:
@@ -393,20 +459,28 @@ The `--version` option:
393
459
 
394
460
  ### Subfolder Versioning
395
461
 
396
- When building from a subdirectory (not the main `src/` directory), you **must** specify `--version`:
462
+ When building from a subdirectory (not the main `src/` directory), the tool automatically detects the subfolder and sets up the build configuration:
397
463
 
398
464
  ```bash
399
- # Build a subfolder as a separate package
465
+ # Build a subfolder as a separate package (version recommended but not required)
400
466
  cd my_project/subfolder_to_build
401
467
  python-package-folder --version "1.0.0" --publish pypi
402
468
 
403
469
  # With custom package name
404
470
  python-package-folder --version "1.0.0" --package-name "my-custom-name" --publish pypi
471
+
472
+ # Version defaults to "0.0.0" if not specified (with a warning)
473
+ python-package-folder --publish pypi
405
474
  ```
406
475
 
407
476
  For subfolder builds:
408
- - **Version is required**: The tool will error if `--version` is not provided
409
- - **Package name**: Automatically derived from the subfolder name (e.g., `subfolder_to_build` → `subfolder-to-build`)
477
+ - **Automatic detection**: The tool automatically detects subfolder builds
478
+ - **pyproject.toml handling**:
479
+ - If `pyproject.toml` exists in subfolder: Uses that file (copied to project root temporarily)
480
+ - If no `pyproject.toml` in subfolder: Creates temporary one with correct package structure
481
+ - **Version**: Recommended but not required when creating temporary pyproject.toml. If not provided, defaults to `0.0.0` with a warning. Ignored if subfolder has its own `pyproject.toml`.
482
+ - **Package name**: Automatically derived from the subfolder name (e.g., `subfolder_to_build` → `subfolder-to-build`). Only used when creating temporary pyproject.toml.
483
+ - **Restoration**: Original `pyproject.toml` is restored after build
410
484
  - **Temporary configuration**: Creates a temporary `pyproject.toml` with:
411
485
  - Custom package name (from `--package-name` or derived)
412
486
  - Specified version
@@ -715,7 +789,8 @@ version_manager.restore_dynamic_versioning()
715
789
 
716
790
  ### SubfolderBuildConfig
717
791
 
718
- Manages temporary build configuration for subfolder builds.
792
+ Manages temporary build configuration for subfolder builds. If a `pyproject.toml` exists
793
+ in the subfolder, it will be used instead of creating a new one.
719
794
 
720
795
  ```python
721
796
  from python_package_folder import SubfolderBuildConfig
@@ -724,11 +799,11 @@ from pathlib import Path
724
799
  config = SubfolderBuildConfig(
725
800
  project_root=Path("."),
726
801
  src_dir=Path("subfolder"),
727
- package_name="my-subfolder",
728
- version="1.0.0"
802
+ package_name="my-subfolder", # Only used if subfolder has no pyproject.toml
803
+ version="1.0.0" # Only used if subfolder has no pyproject.toml
729
804
  )
730
805
 
731
- # Create temporary pyproject.toml
806
+ # Create temporary pyproject.toml (or use subfolder's if it exists)
732
807
  config.create_temp_pyproject()
733
808
 
734
809
  # ... build process ...
@@ -738,13 +813,13 @@ config.restore()
738
813
  ```
739
814
 
740
815
  **Methods:**
741
- - `create_temp_pyproject() -> Path`: Create temporary `pyproject.toml` with subfolder-specific configuration
816
+ - `create_temp_pyproject() -> Path`: Use subfolder's `pyproject.toml` if it exists (adjusting package paths and ensuring `[build-system]` uses hatchling), otherwise create temporary `pyproject.toml` with subfolder-specific configuration including `[build-system]` section using hatchling
742
817
  - `restore() -> None`: Restore original `pyproject.toml` and clean up temporary files
743
818
 
744
- **Note**: This class automatically creates `__init__.py` files if needed to make subfolders valid Python packages. It also handles README files:
745
- - If a README exists in the subfolder, it will be used instead of the parent README
746
- - If no README exists in the subfolder, a minimal README with just the folder name will be created
747
- - The original parent README is backed up and restored after the build completes
819
+ **Note**: This class automatically:
820
+ - **pyproject.toml handling**: If a `pyproject.toml` exists in the subfolder, it will be used (copied to project root temporarily with adjusted package paths). Otherwise, creates a temporary one from the parent configuration. In both cases, the `[build-system]` section is always set to use hatchling, replacing any existing build-system configuration.
821
+ - **README handling**: If a README exists in the subfolder, it will be used instead of the parent README. If no README exists in the subfolder, a minimal README with just the folder name will be created. The original parent README is backed up and restored after the build completes.
822
+ - **Package initialization**: Creates `__init__.py` files if needed to make subfolders valid Python packages.
748
823
 
749
824
 
750
825
  ## Development
@@ -0,0 +1,17 @@
1
+ python_package_folder/__init__.py,sha256=DQt-uldOEKfh0MUqCvKdeNKOnpuOvpb7blYvXMyO9Wc,719
2
+ python_package_folder/__main__.py,sha256=a-__-VLhYw-J7S7CsHdhtEvQr3RiAZxiYDvKhKTgMX4,291
3
+ python_package_folder/analyzer.py,sha256=w7hc2oyOoPK7tvlwcJDXnB3eiJsuGZc4BkOpTfZP7Vo,12257
4
+ python_package_folder/finder.py,sha256=_LvJ9xBVKv41UK5sbwbNyKmuYjAOqUbzvZhK7NCYQF8,9130
5
+ python_package_folder/manager.py,sha256=AlzEqI7q0Q2mVmc_HjIEBmomlT053qqLBMZZ52X7IDQ,26360
6
+ python_package_folder/publisher.py,sha256=1xa6PuduOXNVTTp4IrJcx4qOskugX2fTeJ9QsLDLuUM,11535
7
+ python_package_folder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ python_package_folder/python_package_folder.py,sha256=RPsqRcIy_LjzzTHdp4qdtFJ4-4xhtR_0YLIC0RlUxFo,8841
9
+ python_package_folder/subfolder_build.py,sha256=C_Hksc7JqeNfYvP1HUqXnxlaFFYde3ACXCAE-8SYh9M,27712
10
+ python_package_folder/types.py,sha256=3yeSRR5p_3PDKEAaehW_RJ7NwJHexOIeA08bGaT1iSY,2368
11
+ python_package_folder/utils.py,sha256=lIkWsFKeAYAJ9TDUM99T4pUBHJVbUvCdUgkWQN-LUho,3111
12
+ python_package_folder/version.py,sha256=kIDP6S9trEfs9gj7lBYGxrWm4RPssRla24UtlO9Jkh4,9111
13
+ python_package_folder-1.2.1.dist-info/METADATA,sha256=rkIOYmrjFZxN0AMgT6LshC4qxRbQW1s0CzBgWNsalaQ,32711
14
+ python_package_folder-1.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ python_package_folder-1.2.1.dist-info/entry_points.txt,sha256=ttu4wAhoYSHGhWQNercLz9IVTTpXxhVlRA9vSTvaLe0,91
16
+ python_package_folder-1.2.1.dist-info/licenses/LICENSE,sha256=vNgRJh8YiecqZoZld7TtwPI5I72HIymKD9g32fiJjCE,1073
17
+ python_package_folder-1.2.1.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- python_package_folder/__init__.py,sha256=DQt-uldOEKfh0MUqCvKdeNKOnpuOvpb7blYvXMyO9Wc,719
2
- python_package_folder/__main__.py,sha256=a-__-VLhYw-J7S7CsHdhtEvQr3RiAZxiYDvKhKTgMX4,291
3
- python_package_folder/analyzer.py,sha256=Iw5bdg9NahO57L3CZgGYbhU-m2mh0DpQQ-xqIINUfic,10976
4
- python_package_folder/finder.py,sha256=_LvJ9xBVKv41UK5sbwbNyKmuYjAOqUbzvZhK7NCYQF8,9130
5
- python_package_folder/manager.py,sha256=VZ2e6ydQUPlDnve-PlqGfa86XksSCCqFDZ46GDwZr2A,20549
6
- python_package_folder/publisher.py,sha256=1xa6PuduOXNVTTp4IrJcx4qOskugX2fTeJ9QsLDLuUM,11535
7
- python_package_folder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- python_package_folder/python_package_folder.py,sha256=3ECayVwjXQAnujr2e21xvsuwetcqgRF5Zawt3qgtI9M,8779
9
- python_package_folder/subfolder_build.py,sha256=NIlQRMjBQvBJdxYpwYyOcQWqijPKmgRogsRyY9rElkk,19567
10
- python_package_folder/types.py,sha256=3yeSRR5p_3PDKEAaehW_RJ7NwJHexOIeA08bGaT1iSY,2368
11
- python_package_folder/utils.py,sha256=hoCNRiTHe_-zjAFztxyqjNEMKiEl7XCkSDl36iaMcjM,2966
12
- python_package_folder/version.py,sha256=kIDP6S9trEfs9gj7lBYGxrWm4RPssRla24UtlO9Jkh4,9111
13
- python_package_folder-1.1.3.dist-info/METADATA,sha256=7dD2K5qmYNEBJS8m6i-I5_lrZeAm_jab21rULqHp2mw,28134
14
- python_package_folder-1.1.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
- python_package_folder-1.1.3.dist-info/entry_points.txt,sha256=ttu4wAhoYSHGhWQNercLz9IVTTpXxhVlRA9vSTvaLe0,91
16
- python_package_folder-1.1.3.dist-info/licenses/LICENSE,sha256=vNgRJh8YiecqZoZld7TtwPI5I72HIymKD9g32fiJjCE,1073
17
- python_package_folder-1.1.3.dist-info/RECORD,,