python-package-folder 1.2.0__tar.gz → 1.2.2__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 (41) hide show
  1. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/PKG-INFO +23 -10
  2. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/README.md +22 -9
  3. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/analyzer.py +43 -1
  4. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/publisher.py +39 -34
  5. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/python_package_folder.py +2 -1
  6. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/subfolder_build.py +124 -6
  7. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/utils.py +7 -5
  8. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/test_subfolder_build.py +73 -0
  9. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/.copier-answers.yml +0 -0
  10. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/.cursor/rules/general.mdc +0 -0
  11. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/.cursor/rules/python.mdc +0 -0
  12. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/.github/workflows/ci.yml +0 -0
  13. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/.github/workflows/publish.yml +0 -0
  14. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/.gitignore +0 -0
  15. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/.vscode/settings.json +0 -0
  16. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/LICENSE +0 -0
  17. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/Makefile +0 -0
  18. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/coverage.svg +0 -0
  19. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/development.md +0 -0
  20. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/installation.md +0 -0
  21. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/publishing.md +0 -0
  22. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/pyproject.toml +0 -0
  23. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/__init__.py +0 -0
  24. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/__main__.py +0 -0
  25. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/finder.py +0 -0
  26. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/manager.py +0 -0
  27. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/py.typed +0 -0
  28. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/types.py +0 -0
  29. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/src/python_package_folder/version.py +0 -0
  30. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/folder_structure/some_globals.py +0 -0
  31. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  32. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  33. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  34. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  35. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/test_build_with_external_deps.py +0 -0
  36. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/test_linting.py +0 -0
  37. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/test_publisher.py +0 -0
  38. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/test_utils.py +0 -0
  39. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/test_version_manager.py +0 -0
  40. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/tests/tests.py +0 -0
  41. {python_package_folder-1.2.0 → python_package_folder-1.2.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 1.2.0
3
+ Version: 1.2.2
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>
@@ -229,8 +229,8 @@ python-package-folder --project-root /path/to/project --src-dir /path/to/src --b
229
229
  1. **Build Verification**: Ensures distribution files exist in the `dist/` directory
230
230
  2. **File Filtering**: Automatically filters distribution files to only include those matching the current package name and version (prevents uploading old artifacts)
231
231
  3. **Credential Management**:
232
- - Prompts for credentials if not provided
233
- - Uses `keyring` for secure storage (if available)
232
+ - Prompts for credentials if not provided via command-line arguments
233
+ - Credentials are not stored - you'll be prompted each time (unless provided via `--username` and `--password`)
234
234
  - Supports both username/password and API tokens
235
235
  - Auto-detects API tokens and uses `__token__` as username
236
236
  4. **Repository Configuration**: Configures the target repository (PyPI, TestPyPI, or Azure)
@@ -247,6 +247,7 @@ python-package-folder --project-root /path/to/project --src-dir /path/to/src --b
247
247
  - If found, copies the subfolder README to project root (backing up the original parent README)
248
248
  - If not found, creates a minimal README with just the folder name
249
249
  5. **Configuration Creation**: Creates temporary `pyproject.toml` with:
250
+ - `[build-system]` section using hatchling (replaces any existing build-system configuration)
250
251
  - Subfolder-specific package name (derived or custom)
251
252
  - Specified version
252
253
  - Correct package path for hatchling
@@ -278,8 +279,9 @@ The tool automatically:
278
279
  - Uses the current directory as the source directory if it contains Python files
279
280
  - Falls back to `project_root/src` if the current directory isn't suitable
280
281
  - **For subfolder builds**: Handles `pyproject.toml` configuration:
281
- - **If `pyproject.toml` exists in subfolder**: Uses that file (copies it to project root temporarily)
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)
282
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)
283
285
  - Package name derived from the subfolder name (e.g., `empty_drawing_detection` → `empty-drawing-detection`)
284
286
  - Version from `--version` argument (defaults to `0.0.0` with a warning if not provided)
285
287
  - Proper package path configuration for hatchling
@@ -637,11 +639,22 @@ publisher.publish()
637
639
 
638
640
  ### Credential Storage
639
641
 
640
- The package uses the `keyring` library (if installed) to securely store credentials. Credentials are stored per repository and will be reused on subsequent runs.
642
+ **Note**: The package does not store credentials by default. Credentials must be provided via command-line arguments (`--username` and `--password`) or will be prompted each time you run the publish command. This ensures credentials are not persisted and must be entered fresh each time.
641
643
 
642
- Install keyring for secure credential storage:
643
- ```bash
644
- pip install keyring
644
+ If you previously used an older version that stored credentials in keyring, you can clear them using:
645
+
646
+ ```python
647
+ from python_package_folder import Publisher, Repository
648
+
649
+ publisher = Publisher(repository=Repository.AZURE)
650
+ publisher.clear_stored_credentials()
651
+ ```
652
+
653
+ Or manually using Python:
654
+ ```python
655
+ import keyring
656
+ keyring.delete_password("python-package-folder-azure", "username")
657
+ # Also delete the password if you know the username
645
658
  ```
646
659
 
647
660
  ## Command Line Options
@@ -811,11 +824,11 @@ config.restore()
811
824
  ```
812
825
 
813
826
  **Methods:**
814
- - `create_temp_pyproject() -> Path`: Use subfolder's `pyproject.toml` if it exists, otherwise create temporary `pyproject.toml` with subfolder-specific configuration
827
+ - `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
815
828
  - `restore() -> None`: Restore original `pyproject.toml` and clean up temporary files
816
829
 
817
830
  **Note**: This class automatically:
818
- - **pyproject.toml handling**: If a `pyproject.toml` exists in the subfolder, it will be used (copied to project root temporarily). Otherwise, creates a temporary one from the parent configuration.
831
+ - **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.
819
832
  - **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.
820
833
  - **Package initialization**: Creates `__init__.py` files if needed to make subfolders valid Python packages.
821
834
 
@@ -209,8 +209,8 @@ python-package-folder --project-root /path/to/project --src-dir /path/to/src --b
209
209
  1. **Build Verification**: Ensures distribution files exist in the `dist/` directory
210
210
  2. **File Filtering**: Automatically filters distribution files to only include those matching the current package name and version (prevents uploading old artifacts)
211
211
  3. **Credential Management**:
212
- - Prompts for credentials if not provided
213
- - Uses `keyring` for secure storage (if available)
212
+ - Prompts for credentials if not provided via command-line arguments
213
+ - Credentials are not stored - you'll be prompted each time (unless provided via `--username` and `--password`)
214
214
  - Supports both username/password and API tokens
215
215
  - Auto-detects API tokens and uses `__token__` as username
216
216
  4. **Repository Configuration**: Configures the target repository (PyPI, TestPyPI, or Azure)
@@ -227,6 +227,7 @@ python-package-folder --project-root /path/to/project --src-dir /path/to/src --b
227
227
  - If found, copies the subfolder README to project root (backing up the original parent README)
228
228
  - If not found, creates a minimal README with just the folder name
229
229
  5. **Configuration Creation**: Creates temporary `pyproject.toml` with:
230
+ - `[build-system]` section using hatchling (replaces any existing build-system configuration)
230
231
  - Subfolder-specific package name (derived or custom)
231
232
  - Specified version
232
233
  - Correct package path for hatchling
@@ -258,8 +259,9 @@ The tool automatically:
258
259
  - Uses the current directory as the source directory if it contains Python files
259
260
  - Falls back to `project_root/src` if the current directory isn't suitable
260
261
  - **For subfolder builds**: Handles `pyproject.toml` configuration:
261
- - **If `pyproject.toml` exists in subfolder**: Uses that file (copies it to project root temporarily)
262
+ - **If `pyproject.toml` exists in subfolder**: Uses that file (copies it to project root temporarily, adjusting package paths and ensuring `[build-system]` uses hatchling)
262
263
  - **If no `pyproject.toml` in subfolder**: Creates a temporary `pyproject.toml` with:
264
+ - `[build-system]` section using hatchling (always uses hatchling, even if parent uses setuptools)
263
265
  - Package name derived from the subfolder name (e.g., `empty_drawing_detection` → `empty-drawing-detection`)
264
266
  - Version from `--version` argument (defaults to `0.0.0` with a warning if not provided)
265
267
  - Proper package path configuration for hatchling
@@ -617,11 +619,22 @@ publisher.publish()
617
619
 
618
620
  ### Credential Storage
619
621
 
620
- The package uses the `keyring` library (if installed) to securely store credentials. Credentials are stored per repository and will be reused on subsequent runs.
622
+ **Note**: The package does not store credentials by default. Credentials must be provided via command-line arguments (`--username` and `--password`) or will be prompted each time you run the publish command. This ensures credentials are not persisted and must be entered fresh each time.
621
623
 
622
- Install keyring for secure credential storage:
623
- ```bash
624
- pip install keyring
624
+ If you previously used an older version that stored credentials in keyring, you can clear them using:
625
+
626
+ ```python
627
+ from python_package_folder import Publisher, Repository
628
+
629
+ publisher = Publisher(repository=Repository.AZURE)
630
+ publisher.clear_stored_credentials()
631
+ ```
632
+
633
+ Or manually using Python:
634
+ ```python
635
+ import keyring
636
+ keyring.delete_password("python-package-folder-azure", "username")
637
+ # Also delete the password if you know the username
625
638
  ```
626
639
 
627
640
  ## Command Line Options
@@ -791,11 +804,11 @@ config.restore()
791
804
  ```
792
805
 
793
806
  **Methods:**
794
- - `create_temp_pyproject() -> Path`: Use subfolder's `pyproject.toml` if it exists, otherwise create temporary `pyproject.toml` with subfolder-specific configuration
807
+ - `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
795
808
  - `restore() -> None`: Restore original `pyproject.toml` and clean up temporary files
796
809
 
797
810
  **Note**: This class automatically:
798
- - **pyproject.toml handling**: If a `pyproject.toml` exists in the subfolder, it will be used (copied to project root temporarily). Otherwise, creates a temporary one from the parent configuration.
811
+ - **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.
799
812
  - **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.
800
813
  - **Package initialization**: Creates `__init__.py` files if needed to make subfolders valid Python packages.
801
814
 
@@ -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
  """
@@ -41,12 +41,15 @@ class Publisher:
41
41
  This class manages the publishing process, including credential handling
42
42
  and repository configuration. It uses twine under the hood for actual publishing.
43
43
 
44
+ Credentials are not stored - they must be provided via command-line arguments
45
+ or will be prompted each time. This ensures credentials are not persisted.
46
+
44
47
  Attributes:
45
48
  repository: Target repository for publishing
46
49
  dist_dir: Directory containing built distribution files
47
50
  repository_url: Custom repository URL (for Azure or custom PyPI servers)
48
- username: Username for authentication (optional, can be prompted)
49
- password: Password/token for authentication (optional, can be prompted)
51
+ username: Username for authentication (optional, will be prompted if not provided)
52
+ password: Password/token for authentication (optional, will be prompted if not provided)
50
53
  """
51
54
 
52
55
  def __init__(
@@ -109,8 +112,9 @@ class Publisher:
109
112
  """
110
113
  Get credentials for publishing.
111
114
 
112
- Prompts for username and password/token if not already provided.
113
- Uses keyring if available to store/retrieve credentials securely.
115
+ Always prompts for username and password/token if not already provided.
116
+ Does not use keyring to store/retrieve credentials - credentials must be
117
+ provided via command-line arguments or will be prompted each time.
114
118
 
115
119
  Returns:
116
120
  Tuple of (username, password/token)
@@ -118,24 +122,8 @@ class Publisher:
118
122
  username = self.username
119
123
  password = self.password
120
124
 
121
- # Try to get from keyring if available
122
- if keyring and not username:
123
- try:
124
- username = keyring.get_password(
125
- f"python-package-folder-{self.repository.value}", "username"
126
- )
127
- except Exception:
128
- pass
129
-
130
- if keyring and not password:
131
- try:
132
- password = keyring.get_password(
133
- f"python-package-folder-{self.repository.value}", username or "token"
134
- )
135
- except Exception:
136
- pass
137
-
138
- # Prompt if still not available
125
+ # Always prompt if not provided via command-line arguments
126
+ # We don't use keyring to avoid storing credentials
139
127
  if not username:
140
128
  username = input(f"Enter username for {self.repository.value}: ").strip()
141
129
  if not username:
@@ -160,18 +148,7 @@ class Publisher:
160
148
  )
161
149
  username = "__token__"
162
150
 
163
- # Store in keyring if available
164
- if keyring:
165
- try:
166
- keyring.set_password(
167
- f"python-package-folder-{self.repository.value}", "username", username
168
- )
169
- keyring.set_password(
170
- f"python-package-folder-{self.repository.value}", username, password
171
- )
172
- except Exception:
173
- # Keyring storage is optional, continue if it fails
174
- pass
151
+ # Do not store in keyring - credentials are not persisted
175
152
 
176
153
  return username, password
177
154
 
@@ -253,6 +230,9 @@ class Publisher:
253
230
  cmd = ["twine", "upload"]
254
231
  if skip_existing:
255
232
  cmd.append("--skip-existing")
233
+ # Always use verbose for Azure Artifacts to get better error details
234
+ if self.repository == Repository.AZURE:
235
+ cmd.append("--verbose")
256
236
  cmd.extend(["--repository-url", repo_url])
257
237
  cmd.extend(["--username", username])
258
238
  cmd.extend(["--password", password])
@@ -284,6 +264,31 @@ class Publisher:
284
264
  self.password = None
285
265
  self.publish(skip_existing=skip_existing)
286
266
 
267
+ def clear_stored_credentials(self) -> None:
268
+ """
269
+ Clear any stored credentials from keyring for this repository.
270
+
271
+ This method can be used to remove previously stored credentials.
272
+ Note: The current implementation does not store credentials, but this
273
+ method is provided for compatibility and to clear any old stored credentials.
274
+ """
275
+ if keyring:
276
+ try:
277
+ service_name = f"python-package-folder-{self.repository.value}"
278
+ # Try to get and delete stored username
279
+ stored_username = keyring.get_password(service_name, "username")
280
+ if stored_username:
281
+ try:
282
+ keyring.delete_password(service_name, stored_username)
283
+ except Exception:
284
+ pass
285
+ try:
286
+ keyring.delete_password(service_name, "username")
287
+ except Exception:
288
+ pass
289
+ except Exception:
290
+ pass
291
+
287
292
 
288
293
  def get_repository_help() -> str:
289
294
  """
@@ -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:
@@ -30,8 +30,11 @@ class SubfolderBuildConfig:
30
30
  Manages temporary build configuration for subfolder builds.
31
31
 
32
32
  When building a subfolder as a separate package, this class:
33
- - Uses the subfolder's pyproject.toml if it exists
33
+ - Uses the subfolder's pyproject.toml if it exists (adjusts package paths and ensures
34
+ [build-system] uses hatchling)
34
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)
35
38
  - Handles README files similarly (uses subfolder README if present)
36
39
  """
37
40
 
@@ -111,13 +114,96 @@ class SubfolderBuildConfig:
111
114
  # If it's a package or has subpackages, return the path
112
115
  return packages_path, [packages_path] if packages_path else []
113
116
 
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
+
114
196
  def create_temp_pyproject(self) -> Path | None:
115
197
  """
116
198
  Create a temporary pyproject.toml for the subfolder build.
117
199
 
118
- If a pyproject.toml exists in the subfolder, it will be used instead of creating
119
- a new one. Otherwise, creates a pyproject.toml in the project root based on the
120
- parent pyproject.toml with the appropriate package name and version.
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).
121
207
 
122
208
  Returns:
123
209
  Path to the pyproject.toml file (either from subfolder or created temporary),
@@ -149,8 +235,13 @@ class SubfolderBuildConfig:
149
235
  shutil.copy2(original_pyproject, backup_path)
150
236
  self.original_pyproject_backup = backup_path
151
237
 
152
- # Copy subfolder pyproject.toml to project root
153
- shutil.copy2(subfolder_pyproject, original_pyproject)
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")
154
245
  self.temp_pyproject = original_pyproject
155
246
 
156
247
  # Handle README file
@@ -259,6 +350,7 @@ class SubfolderBuildConfig:
259
350
  skip_uv_dynamic = False
260
351
  in_hatch_build = False
261
352
  packages_set = False
353
+ build_system_set = False
262
354
 
263
355
  # Get package structure
264
356
  packages_path, package_dirs = self._get_package_structure()
@@ -266,6 +358,19 @@ class SubfolderBuildConfig:
266
358
  package_dirs = []
267
359
 
268
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
+
269
374
  # Skip hatch versioning and uv-dynamic-versioning sections
270
375
  if line.strip().startswith("[tool.hatch.version]"):
271
376
  skip_hatch_version = True
@@ -361,6 +466,19 @@ class SubfolderBuildConfig:
361
466
  packages_str = ", ".join(f'"{p}"' for p in package_dirs)
362
467
  result.append(f"packages = [{packages_str}]")
363
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
+
364
482
  # Ensure packages is always set for subfolder builds
365
483
  if not packages_set and package_dirs:
366
484
  # Add the section if it doesn't exist
@@ -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
 
@@ -584,6 +584,11 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
584
584
 
585
585
  # Verify dynamic versioning is removed
586
586
  assert 'dynamic = ["version"]' not in content
587
+
588
+ # Verify build-system section is added (required for hatchling)
589
+ assert "[build-system]" in content
590
+ assert 'requires = ["hatchling"]' in content
591
+ assert 'build-backend = "hatchling.build"' in content
587
592
  assert "[tool.hatch.version]" not in content
588
593
  assert "[tool.uv-dynamic-versioning]" not in content
589
594
 
@@ -648,3 +653,71 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
648
653
  restored_content = (project_root / "pyproject.toml").read_text()
649
654
  assert restored_content == original_content
650
655
  assert 'name = "test-package"' in restored_content
656
+
657
+ def test_build_system_section_replaces_setuptools(
658
+ self, test_project_with_pyproject: Path
659
+ ) -> None:
660
+ """Test that build-system section replaces existing setuptools configuration."""
661
+ project_root = test_project_with_pyproject
662
+ subfolder = project_root / "subfolder"
663
+
664
+ # Modify parent pyproject.toml to have setuptools build-system
665
+ pyproject_path = project_root / "pyproject.toml"
666
+ original_content = pyproject_path.read_text()
667
+ modified_content = (
668
+ original_content
669
+ + '\n[build-system]\nrequires = ["setuptools"]\nbuild-backend = "setuptools.build_meta"\n'
670
+ )
671
+ pyproject_path.write_text(modified_content)
672
+
673
+ try:
674
+ config = SubfolderBuildConfig(
675
+ project_root=project_root,
676
+ src_dir=subfolder,
677
+ version="1.0.0",
678
+ )
679
+
680
+ pyproject_path = config.create_temp_pyproject()
681
+ content = pyproject_path.read_text()
682
+
683
+ # Verify build-system section uses hatchling, not setuptools
684
+ assert "[build-system]" in content
685
+ assert 'requires = ["hatchling"]' in content
686
+ assert 'build-backend = "hatchling.build"' in content
687
+ assert "setuptools" not in content or 'build-backend = "setuptools' not in content
688
+
689
+ config.restore()
690
+ finally:
691
+ # Restore original content
692
+ pyproject_path.write_text(original_content)
693
+
694
+ def test_build_system_section_with_subfolder_pyproject(
695
+ self, test_project_with_pyproject: Path
696
+ ) -> None:
697
+ """Test that build-system section is added when using subfolder's pyproject.toml."""
698
+ project_root = test_project_with_pyproject
699
+ subfolder = project_root / "subfolder"
700
+
701
+ # Create pyproject.toml in subfolder without build-system
702
+ subfolder_pyproject_content = """[project]
703
+ name = "subfolder-package"
704
+ version = "3.0.0"
705
+ description = "Subfolder package"
706
+ """
707
+ (subfolder / "pyproject.toml").write_text(subfolder_pyproject_content)
708
+
709
+ config = SubfolderBuildConfig(
710
+ project_root=project_root,
711
+ src_dir=subfolder,
712
+ version="1.0.0",
713
+ )
714
+
715
+ pyproject_path = config.create_temp_pyproject()
716
+ content = pyproject_path.read_text()
717
+
718
+ # Verify build-system section is added
719
+ assert "[build-system]" in content
720
+ assert 'requires = ["hatchling"]' in content
721
+ assert 'build-backend = "hatchling.build"' in content
722
+
723
+ config.restore()