python-package-folder 5.0.1__tar.gz → 5.1.1__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 (61) hide show
  1. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/PKG-INFO +1 -1
  2. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/coverage.svg +2 -2
  3. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/docs/REFERENCE.md +23 -0
  4. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/docs/USAGE.md +10 -0
  5. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/pyproject.toml +1 -1
  6. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/_hatch_build.py +90 -1
  7. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/subfolder_build.py +275 -6
  8. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/utils.py +69 -0
  9. python_package_folder-5.1.1/tests/test_exclude_patterns.py +186 -0
  10. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_subfolder_build.py +3 -3
  11. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.copier-answers.yml +0 -0
  12. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  13. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.cursor/plans/replace_node.js_semantic-release_with_custom_python_implementation_64e05e1a.plan.md +0 -0
  14. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.cursor/rules/general.mdc +0 -0
  15. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.cursor/rules/python.mdc +0 -0
  16. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.github/workflows/ci.yml +0 -0
  17. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.github/workflows/publish.yml +0 -0
  18. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.gitignore +0 -0
  19. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/.vscode/settings.json +0 -0
  20. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/LICENSE +0 -0
  21. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/MANIFEST.in +0 -0
  22. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/Makefile +0 -0
  23. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/README.md +0 -0
  24. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/development.md +0 -0
  25. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/docs/DEVELOPMENT.md +0 -0
  26. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/docs/INSTALLATION.md +0 -0
  27. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/docs/PUBLISHING.md +0 -0
  28. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/docs/VERSION_RESOLUTION.md +0 -0
  29. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/installation.md +0 -0
  30. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/publishing.md +0 -0
  31. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/__init__.py +0 -0
  32. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/__main__.py +0 -0
  33. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/analyzer.py +0 -0
  34. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/finder.py +0 -0
  35. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/manager.py +0 -0
  36. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/publisher.py +0 -0
  37. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/py.typed +0 -0
  38. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/python_package_folder.py +0 -0
  39. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/types.py +0 -0
  40. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/version.py +0 -0
  41. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/src/python_package_folder/version_calculator.py +0 -0
  42. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/conftest.py +0 -0
  43. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/folder_structure/some_globals.py +0 -0
  44. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  45. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  46. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  47. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  48. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  49. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  50. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_build_with_external_deps.py +0 -0
  51. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_linting.py +0 -0
  52. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_preserve_directory_structure.py +0 -0
  53. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_publisher.py +0 -0
  54. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_shared_subdirectory_imports.py +0 -0
  55. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_spreadsheet_creation_imports.py +0 -0
  56. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_third_party_dependencies.py +0 -0
  57. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_utils.py +0 -0
  58. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_version_calculator.py +0 -0
  59. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/test_version_manager.py +0 -0
  60. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/tests/tests.py +0 -0
  61. {python_package_folder-5.0.1 → python_package_folder-5.1.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 5.0.1
3
+ Version: 5.1.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>
@@ -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">68%</text>
18
- <text x="81" y="14">68%</text>
17
+ <text x="81" y="15" fill="#010101" fill-opacity=".3">63%</text>
18
+ <text x="81" y="14">63%</text>
19
19
  </g>
20
20
  </svg>
@@ -1,5 +1,28 @@
1
1
  # API Reference
2
2
 
3
+ ## Configuration
4
+
5
+ ### Exclude Patterns
6
+
7
+ You can configure exclude patterns in `pyproject.toml` to prevent folders and files from being included in published packages (wheel/sdist). Patterns are matched using regex against any path component (directory or file name).
8
+
9
+ ```toml
10
+ [tool.python-package-folder]
11
+ exclude-patterns = ["_SS", "__SS", ".*_test.*", "sandbox"]
12
+ ```
13
+
14
+ **Pattern Matching:**
15
+ - Patterns are regex strings that match against any path component
16
+ - If any component in a path matches any pattern, the entire path is excluded
17
+ - Examples:
18
+ - `"_SS"` - Matches any path component containing `_SS` (e.g., `data_storage/_SS/...`)
19
+ - `".*_test.*"` - Matches any path component containing `_test` (e.g., `my_test_file.py`, `test_data/`)
20
+ - `"sandbox"` - Matches any path component containing `sandbox`
21
+
22
+ **Subfolder Builds:**
23
+ - Exclude patterns from the root `pyproject.toml` are automatically applied to subfolder builds
24
+ - Patterns are injected into the temporary `pyproject.toml` created for subfolder builds
25
+
3
26
  ## Command Line Options
4
27
 
5
28
  ```
@@ -46,6 +46,7 @@
46
46
  - Subfolder-specific package name (derived or custom)
47
47
  - Specified version
48
48
  - Correct package path for hatchling
49
+ - Exclude patterns from root `pyproject.toml` (if configured)
49
50
  6. **Build Execution**: Runs build command with all dependencies in place
50
51
  7. **Cleanup**: Restores original `pyproject.toml` and removes temporary `__init__.py`
51
52
 
@@ -114,6 +115,15 @@ python-package-folder --version "1.0.0" --dependency-group "dev" --publish pypi
114
115
 
115
116
  The specified dependency group will be copied from the parent `pyproject.toml`'s `[dependency-groups]` section into the temporary `pyproject.toml` used for the subfolder build.
116
117
 
118
+ **Exclude Patterns**: You can configure exclude patterns in the root `pyproject.toml` to prevent certain folders/files from being included in published packages:
119
+
120
+ ```toml
121
+ [tool.python-package-folder]
122
+ exclude-patterns = ["_SS", "__SS", ".*_test.*"]
123
+ ```
124
+
125
+ These patterns are automatically applied to both main package builds and subfolder builds. Patterns use regex matching against any path component (directory or file name). See the [API Reference](REFERENCE.md#exclude-patterns) for more details.
126
+
117
127
  ## Python API Usage
118
128
 
119
129
  You can also use the package programmatically:
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  # ---- Dev dependencies ----
45
45
 
46
- version = "5.0.1"
46
+ version = "5.1.1"
47
47
  [dependency-groups]
48
48
  dev = [
49
49
  "pytest>=8.3.5",
@@ -4,23 +4,37 @@ Hatch build hook to automatically include all files from the scripts directory.
4
4
  This hook ensures all non-Python files in the scripts directory are included
5
5
  in the wheel without creating duplicates, and automatically includes any new
6
6
  files added to the directory without requiring manual configuration updates.
7
+
8
+ Also filters files based on exclude patterns from pyproject.toml.
7
9
  """
8
10
 
11
+ import re
9
12
  import sys
10
13
  from pathlib import Path
11
14
  from typing import Any
12
15
 
13
16
  from hatchling.builders.hooks.plugin.interface import BuildHookInterface
14
17
 
18
+ from .utils import read_exclude_patterns
19
+
15
20
 
16
21
  class CustomBuildHook(BuildHookInterface):
17
- """Build hook to include all files from the scripts directory."""
22
+ """Build hook to include all files from the scripts directory and filter exclude patterns."""
18
23
 
19
24
  def initialize(self, version: str, build_data: dict[str, Any]) -> None:
20
25
  """Initialize the build hook and add scripts directory files."""
21
26
  # Debug: Print to stderr so it shows in build output
22
27
  print(f"[DEBUG] Build hook called. Root: {self.root}", file=sys.stderr)
23
28
 
29
+ # Read exclude patterns from pyproject.toml
30
+ pyproject_path = Path(self.root) / "pyproject.toml"
31
+ exclude_patterns = read_exclude_patterns(pyproject_path)
32
+
33
+ if exclude_patterns:
34
+ print(f"[DEBUG] Found {len(exclude_patterns)} exclude pattern(s): {exclude_patterns}", file=sys.stderr)
35
+ # Filter build_data entries based on exclude patterns
36
+ self._filter_build_data(build_data, exclude_patterns)
37
+
24
38
  # Try multiple possible locations for the scripts directory
25
39
  # 1. Source layout: src/python_package_folder/scripts
26
40
  # 2. Sdist layout: python_package_folder/scripts (after extraction)
@@ -79,6 +93,81 @@ class CustomBuildHook(BuildHookInterface):
79
93
 
80
94
  print(f"[DEBUG] force_include now has {len(build_data.get('force_include', {}))} entries", file=sys.stderr)
81
95
 
96
+ def _filter_build_data(self, build_data: dict[str, Any], exclude_patterns: list[str]) -> None:
97
+ """
98
+ Filter build_data entries based on exclude patterns.
99
+
100
+ Removes files/directories that match any of the exclude patterns from
101
+ build_data. Patterns are matched against any path component using regex.
102
+
103
+ Args:
104
+ build_data: Hatchling build data dictionary
105
+ exclude_patterns: List of regex patterns to match against paths
106
+ """
107
+ # Compile regex patterns for efficiency
108
+ compiled_patterns = [re.compile(pattern) for pattern in exclude_patterns]
109
+
110
+ def should_exclude(path_str: str) -> bool:
111
+ """Check if a path should be excluded based on patterns."""
112
+ # Check each component of the path
113
+ path = Path(path_str)
114
+ for part in path.parts:
115
+ # Check if any part matches any pattern
116
+ for pattern in compiled_patterns:
117
+ if pattern.search(part):
118
+ return True
119
+ return False
120
+
121
+ # Filter force_include entries
122
+ if "force_include" in build_data and isinstance(build_data["force_include"], dict):
123
+ original_count = len(build_data["force_include"])
124
+ filtered = {
125
+ source: target
126
+ for source, target in build_data["force_include"].items()
127
+ if not should_exclude(source) and not should_exclude(target)
128
+ }
129
+ build_data["force_include"] = filtered
130
+ excluded_count = original_count - len(filtered)
131
+ if excluded_count > 0:
132
+ print(
133
+ f"[DEBUG] Excluded {excluded_count} file(s) from force_include based on exclude patterns",
134
+ file=sys.stderr,
135
+ )
136
+
137
+ # Filter other file collections that might exist
138
+ # Hatchling may store files in different keys depending on the build target
139
+ for key in ["shared_data", "artifacts"]:
140
+ if key in build_data and isinstance(build_data[key], dict):
141
+ original_count = len(build_data[key])
142
+ filtered = {
143
+ source: target
144
+ for source, target in build_data[key].items()
145
+ if not should_exclude(source) and not should_exclude(target)
146
+ }
147
+ build_data[key] = filtered
148
+ excluded_count = original_count - len(filtered)
149
+ if excluded_count > 0:
150
+ print(
151
+ f"[DEBUG] Excluded {excluded_count} file(s) from {key} based on exclude patterns",
152
+ file=sys.stderr,
153
+ )
154
+
155
+ # Filter files list if it exists (for sdist)
156
+ if "files" in build_data and isinstance(build_data["files"], list):
157
+ original_count = len(build_data["files"])
158
+ filtered = [
159
+ file_entry
160
+ for file_entry in build_data["files"]
161
+ if not should_exclude(str(file_entry))
162
+ ]
163
+ build_data["files"] = filtered
164
+ excluded_count = original_count - len(filtered)
165
+ if excluded_count > 0:
166
+ print(
167
+ f"[DEBUG] Excluded {excluded_count} file(s) from files list based on exclude patterns",
168
+ file=sys.stderr,
169
+ )
170
+
82
171
 
83
172
  # Export the hook class (hatchling might need this)
84
173
  __all__ = ["CustomBuildHook"]
@@ -24,6 +24,8 @@ except ImportError:
24
24
  except ImportError:
25
25
  tomllib = None
26
26
 
27
+ from .utils import read_exclude_patterns
28
+
27
29
 
28
30
  class SubfolderBuildConfig:
29
31
  """
@@ -203,13 +205,80 @@ class SubfolderBuildConfig:
203
205
  has_build_system = any(line.strip().startswith("[build-system]") for line in result)
204
206
  if not has_build_system:
205
207
  # Insert build-system at the very beginning of the file
208
+ # Include python-package-folder in requires so the build hook is available
206
209
  build_system_lines = [
207
210
  "[build-system]",
208
- 'requires = ["hatchling"]',
211
+ 'requires = ["hatchling", "python-package-folder"]',
209
212
  'build-backend = "hatchling.build"',
210
213
  "",
211
214
  ]
212
215
  result = build_system_lines + result
216
+ else:
217
+ # Ensure python-package-folder is in requires for build hook availability
218
+ in_build_system = False
219
+ for i, line in enumerate(result):
220
+ if line.strip().startswith("[build-system]"):
221
+ in_build_system = True
222
+ elif in_build_system and line.strip().startswith("requires"):
223
+ # Check if python-package-folder is already in requires
224
+ if "python-package-folder" not in line:
225
+ # Add python-package-folder to requires
226
+ if "]" in line:
227
+ # Single-line requires
228
+ result[i] = line.rstrip().rstrip("]") + ', "python-package-folder"]'
229
+ else:
230
+ # Multi-line requires, find the closing bracket
231
+ for j in range(i + 1, len(result)):
232
+ if "]" in result[j]:
233
+ # Insert before closing
234
+ # Determine indentation from the previous line (last item in list)
235
+ if j > i + 1:
236
+ # Look at the previous line to get indentation
237
+ prev_line = result[j - 1]
238
+ # Extract leading whitespace from previous line
239
+ indent = len(prev_line) - len(prev_line.lstrip())
240
+ indent_str = " " * indent
241
+ else:
242
+ # Fallback: use standard 4-space indent
243
+ indent_str = " "
244
+ result.insert(j, f'{indent_str}"python-package-folder",')
245
+ break
246
+ break
247
+ elif in_build_system and line.strip().startswith("[") and not line.strip().startswith("[build-system"):
248
+ # End of build-system section
249
+ break
250
+
251
+ # Register the build hook to enable exclude patterns
252
+ build_hook_registered = any(
253
+ "[tool.hatch.build.hooks.custom]" in line for line in result
254
+ )
255
+ if not build_hook_registered:
256
+ # Add build hook registration after [tool.hatch.build.targets.wheel] section
257
+ hook_insert_index = len(result)
258
+ for i, line in enumerate(result):
259
+ if line.strip().startswith("[tool.hatch.build.targets.wheel]"):
260
+ # Find the end of this section
261
+ for j in range(i + 1, len(result)):
262
+ if result[j].strip().startswith("[") and not result[j].strip().startswith(
263
+ "[tool.hatch.build.targets"
264
+ ):
265
+ hook_insert_index = j
266
+ break
267
+ if hook_insert_index == len(result):
268
+ # Insert after packages line if section continues
269
+ for j in range(i + 1, len(result)):
270
+ if result[j].strip().startswith("["):
271
+ hook_insert_index = j
272
+ break
273
+ break
274
+
275
+ # Insert build hook registration
276
+ hook_lines = [
277
+ "",
278
+ "[tool.hatch.build.hooks.custom]",
279
+ 'path = "python_package_folder._hatch_build:CustomBuildHook"',
280
+ ]
281
+ result[hook_insert_index:hook_insert_index] = hook_lines
213
282
 
214
283
  # Ensure hatch build section exists if packages path is needed
215
284
  if not packages_set and correct_packages_path:
@@ -288,6 +357,11 @@ class SubfolderBuildConfig:
288
357
  subfolder_content = subfolder_pyproject.read_text(encoding="utf-8")
289
358
  # Adjust packages path to be relative to project root
290
359
  adjusted_content = self._adjust_subfolder_pyproject_packages_path(subfolder_content)
360
+
361
+ # Read exclude patterns from root pyproject.toml and inject them
362
+ exclude_patterns = read_exclude_patterns(original_pyproject)
363
+ if exclude_patterns:
364
+ adjusted_content = self._inject_exclude_patterns(adjusted_content, exclude_patterns)
291
365
 
292
366
  # Write adjusted content to temporary file
293
367
  temp_pyproject_path.write_text(adjusted_content, encoding="utf-8")
@@ -373,6 +447,9 @@ class SubfolderBuildConfig:
373
447
  file=sys.stderr,
374
448
  )
375
449
 
450
+ # Read exclude patterns from root pyproject.toml
451
+ exclude_patterns = read_exclude_patterns(original_pyproject)
452
+
376
453
  if data:
377
454
  # Modify using parsed data
378
455
  if "project" in data:
@@ -394,12 +471,12 @@ class SubfolderBuildConfig:
394
471
 
395
472
  # For now, use string manipulation (tomli-w not in stdlib)
396
473
  modified_content = self._modify_pyproject_string(
397
- original_content, parent_dependency_group
474
+ original_content, parent_dependency_group, exclude_patterns
398
475
  )
399
476
  else:
400
477
  # Use string manipulation
401
478
  modified_content = self._modify_pyproject_string(
402
- original_content, parent_dependency_group
479
+ original_content, parent_dependency_group, exclude_patterns
403
480
  )
404
481
 
405
482
  # Write the modified content to a temporary file
@@ -423,7 +500,10 @@ class SubfolderBuildConfig:
423
500
  return original_pyproject
424
501
 
425
502
  def _modify_pyproject_string(
426
- self, content: str, dependency_group: dict[str, list[str]] | None = None
503
+ self,
504
+ content: str,
505
+ dependency_group: dict[str, list[str]] | None = None,
506
+ exclude_patterns: list[str] | None = None,
427
507
  ) -> str:
428
508
  """Modify pyproject.toml content using string manipulation."""
429
509
  lines = content.split("\n")
@@ -557,13 +637,54 @@ class SubfolderBuildConfig:
557
637
  has_build_system = any(line.strip().startswith("[build-system]") for line in result)
558
638
  if not has_build_system:
559
639
  # Insert build-system at the very beginning of the file
640
+ # Include python-package-folder in requires so the build hook is available
560
641
  build_system_lines = [
561
642
  "[build-system]",
562
- 'requires = ["hatchling"]',
643
+ 'requires = ["hatchling", "python-package-folder"]',
563
644
  'build-backend = "hatchling.build"',
564
645
  "",
565
646
  ]
566
647
  result = build_system_lines + result
648
+ else:
649
+ # Ensure python-package-folder is in requires for build hook availability
650
+ in_build_system = False
651
+ requires_modified = False
652
+ for i, line in enumerate(result):
653
+ if line.strip().startswith("[build-system]"):
654
+ in_build_system = True
655
+ elif in_build_system and line.strip().startswith("requires"):
656
+ # Check if python-package-folder is already in requires
657
+ if "python-package-folder" not in line:
658
+ # Add python-package-folder to requires
659
+ if "]" in line:
660
+ # Single-line requires (may have closing bracket on same line)
661
+ if line.strip().endswith("]"):
662
+ result[i] = line.rstrip().rstrip("]") + ', "python-package-folder"]'
663
+ else:
664
+ # Closing bracket might be on same line but not at end
665
+ result[i] = line.rstrip().rstrip("]") + ', "python-package-folder"]'
666
+ else:
667
+ # Multi-line requires, find the closing bracket
668
+ for j in range(i + 1, len(result)):
669
+ if "]" in result[j]:
670
+ # Insert before closing
671
+ # Determine indentation from the previous line (last item in list)
672
+ if j > i + 1:
673
+ # Look at the previous line to get indentation
674
+ prev_line = result[j - 1]
675
+ # Extract leading whitespace from previous line
676
+ indent = len(prev_line) - len(prev_line.lstrip())
677
+ indent_str = " " * indent
678
+ else:
679
+ # Fallback: use standard 4-space indent
680
+ indent_str = " "
681
+ result.insert(j, f'{indent_str}"python-package-folder",')
682
+ break
683
+ requires_modified = True
684
+ break
685
+ elif in_build_system and line.strip().startswith("[") and not line.strip().startswith("[build-system"):
686
+ # End of build-system section
687
+ break
567
688
 
568
689
  # Ensure packages is always set for subfolder builds
569
690
  if not packages_set and package_dirs:
@@ -574,6 +695,50 @@ class SubfolderBuildConfig:
574
695
  packages_str = ", ".join(f'"{p}"' for p in package_dirs)
575
696
  result.append(f"packages = [{packages_str}]")
576
697
 
698
+ # Register the build hook to enable exclude patterns
699
+ # Always register the build hook if exclude patterns are present, or if we want to support them
700
+ # Check if build hook is already registered
701
+ build_hook_registered = any(
702
+ "[tool.hatch.build.hooks.custom]" in line for line in result
703
+ )
704
+
705
+ if not build_hook_registered:
706
+ # Add build hook registration after [tool.hatch.build.targets.wheel] section
707
+ hook_insert_index = len(result)
708
+ wheel_section_found = False
709
+ for i, line in enumerate(result):
710
+ if line.strip().startswith("[tool.hatch.build.targets.wheel]"):
711
+ wheel_section_found = True
712
+ # Find the end of this section
713
+ for j in range(i + 1, len(result)):
714
+ if result[j].strip().startswith("[") and not result[j].strip().startswith(
715
+ "[tool.hatch.build.targets"
716
+ ):
717
+ hook_insert_index = j
718
+ break
719
+ if hook_insert_index == len(result):
720
+ # Insert after packages line if section continues
721
+ for j in range(i + 1, len(result)):
722
+ if result[j].strip().startswith("["):
723
+ hook_insert_index = j
724
+ break
725
+ break
726
+
727
+ # If wheel section not found, insert before sdist section or at end
728
+ if not wheel_section_found:
729
+ for i, line in enumerate(result):
730
+ if line.strip().startswith("[tool.hatch.build.targets.sdist]"):
731
+ hook_insert_index = i
732
+ break
733
+
734
+ # Insert build hook registration
735
+ hook_lines = [
736
+ "",
737
+ "[tool.hatch.build.hooks.custom]",
738
+ 'path = "python_package_folder._hatch_build:CustomBuildHook"',
739
+ ]
740
+ result[hook_insert_index:hook_insert_index] = hook_lines
741
+
577
742
  # Use only-include for source distributions to ensure only the subfolder is included
578
743
  # This prevents including files from the project root
579
744
  if package_dirs:
@@ -635,8 +800,112 @@ class SubfolderBuildConfig:
635
800
  dep_lines.append("]")
636
801
  result[insert_index:insert_index] = dep_lines
637
802
 
803
+ # Add exclude patterns if specified
804
+ if exclude_patterns:
805
+ # Find where to insert [tool.python-package-folder] section
806
+ # Usually after [dependency-groups] or at the end
807
+ insert_index = len(result)
808
+ tool_section_exists = False
809
+ for i, line in enumerate(result):
810
+ if line.strip() == "[tool.python-package-folder]":
811
+ tool_section_exists = True
812
+ insert_index = i
813
+ break
814
+ elif line.strip().startswith("[tool.") and i > 0:
815
+ # Insert before other tool sections
816
+ insert_index = i
817
+ break
818
+
819
+ # Format exclude patterns
820
+ patterns_str = ", ".join(f'"{p}"' for p in exclude_patterns)
821
+ exclude_lines = [
822
+ "",
823
+ "[tool.python-package-folder]",
824
+ f'exclude-patterns = [{patterns_str}]',
825
+ ]
826
+
827
+ if tool_section_exists:
828
+ # Replace or update existing section
829
+ end_index = insert_index + 1
830
+ while end_index < len(result) and not result[end_index].strip().startswith("["):
831
+ end_index += 1
832
+ # Check if exclude-patterns already exists
833
+ has_exclude_patterns = any(
834
+ "exclude-patterns" in result[i] for i in range(insert_index, end_index)
835
+ )
836
+ if has_exclude_patterns:
837
+ # Update existing exclude-patterns line
838
+ for i in range(insert_index, end_index):
839
+ if "exclude-patterns" in result[i]:
840
+ result[i] = f'exclude-patterns = [{patterns_str}]'
841
+ break
842
+ else:
843
+ # Add exclude-patterns to existing section
844
+ result.insert(end_index - 1, f'exclude-patterns = [{patterns_str}]')
845
+ else:
846
+ # Insert new section
847
+ result[insert_index:insert_index] = exclude_lines
848
+
638
849
  return "\n".join(result)
639
850
 
851
+ def _inject_exclude_patterns(self, content: str, exclude_patterns: list[str]) -> str:
852
+ """
853
+ Inject exclude patterns into pyproject.toml content.
854
+
855
+ Adds or updates [tool.python-package-folder] exclude-patterns section.
856
+
857
+ Args:
858
+ content: pyproject.toml content
859
+ exclude_patterns: List of exclude patterns to inject
860
+
861
+ Returns:
862
+ Modified pyproject.toml content with exclude patterns
863
+ """
864
+ if not exclude_patterns:
865
+ return content
866
+
867
+ lines = content.split("\n")
868
+ result = []
869
+ tool_section_exists = False
870
+ tool_section_index = -1
871
+ tool_section_end = -1
872
+
873
+ # Find [tool.python-package-folder] section
874
+ for i, line in enumerate(lines):
875
+ if line.strip() == "[tool.python-package-folder]":
876
+ tool_section_exists = True
877
+ tool_section_index = i
878
+ # Find end of section
879
+ for j in range(i + 1, len(lines)):
880
+ if lines[j].strip().startswith("["):
881
+ tool_section_end = j
882
+ break
883
+ if tool_section_end == -1:
884
+ tool_section_end = len(lines)
885
+ break
886
+
887
+ if tool_section_exists:
888
+ # Update existing section
889
+ patterns_str = ", ".join(f'"{p}"' for p in exclude_patterns)
890
+ has_exclude_patterns = False
891
+ for i in range(tool_section_index + 1, tool_section_end):
892
+ if "exclude-patterns" in lines[i]:
893
+ # Update existing line
894
+ lines[i] = f'exclude-patterns = [{patterns_str}]'
895
+ has_exclude_patterns = True
896
+ break
897
+ if not has_exclude_patterns:
898
+ # Add exclude-patterns to existing section
899
+ lines.insert(tool_section_end, f'exclude-patterns = [{patterns_str}]')
900
+ return "\n".join(lines)
901
+ else:
902
+ # Add new section at the end
903
+ patterns_str = ", ".join(f'"{p}"' for p in exclude_patterns)
904
+ lines.append("")
905
+ lines.append("[tool.python-package-folder]")
906
+ lines.append(f'exclude-patterns = [{patterns_str}]')
907
+ return "\n".join(lines)
908
+
640
909
  def add_third_party_dependencies(self, dependencies: list[str]) -> None:
641
910
  """
642
911
  Add third-party dependencies to the temporary pyproject.toml.
@@ -894,4 +1163,4 @@ class SubfolderBuildConfig:
894
1163
 
895
1164
  def __exit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ARG002
896
1165
  """Context manager exit - always restore."""
897
- self.restore()
1166
+ self.restore()
@@ -6,6 +6,14 @@ from __future__ import annotations
6
6
 
7
7
  from pathlib import Path
8
8
 
9
+ try:
10
+ import tomllib
11
+ except ImportError:
12
+ try:
13
+ import tomli as tomllib
14
+ except ImportError:
15
+ tomllib = None
16
+
9
17
 
10
18
  def find_project_root(start_path: Path | None = None) -> Path | None:
11
19
  """
@@ -106,3 +114,64 @@ def is_python_package_directory(path: Path) -> bool:
106
114
  return True
107
115
 
108
116
  return False
117
+
118
+
119
+ def read_exclude_patterns(pyproject_path: Path) -> list[str]:
120
+ """
121
+ Read exclude patterns from pyproject.toml.
122
+
123
+ Reads the exclude-patterns configuration from [tool.python-package-folder] section.
124
+ Returns an empty list if the section or option doesn't exist.
125
+
126
+ Args:
127
+ pyproject_path: Path to the pyproject.toml file
128
+
129
+ Returns:
130
+ List of exclude patterns (regex strings), or empty list if not found
131
+ """
132
+ if not pyproject_path.exists():
133
+ return []
134
+
135
+ try:
136
+ if tomllib:
137
+ content = pyproject_path.read_text(encoding="utf-8")
138
+ data = tomllib.loads(content)
139
+ tool_section = data.get("tool", {})
140
+ tool_config = tool_section.get("python-package-folder", {})
141
+ # TOML keys with hyphens are preserved as-is
142
+ patterns = tool_config.get("exclude-patterns", [])
143
+ if isinstance(patterns, list):
144
+ return [str(p) for p in patterns]
145
+ return []
146
+ else:
147
+ # Fallback: simple string parsing
148
+ content = pyproject_path.read_text(encoding="utf-8")
149
+ in_section = False
150
+ patterns = []
151
+ for line in content.split("\n"):
152
+ stripped = line.strip()
153
+ if stripped == "[tool.python-package-folder]":
154
+ in_section = True
155
+ continue
156
+ elif stripped.startswith("[") and in_section:
157
+ # Moved to a different section
158
+ break
159
+ elif in_section and stripped.startswith("exclude-patterns"):
160
+ # Parse the array
161
+ if "=" in stripped:
162
+ value = stripped.split("=", 1)[1].strip()
163
+ # Simple parsing for array of strings
164
+ if value.startswith("[") and value.endswith("]"):
165
+ # Remove brackets and split
166
+ items = value[1:-1].split(",")
167
+ for item in items:
168
+ item = item.strip().strip('"').strip("'")
169
+ if item:
170
+ patterns.append(item)
171
+ break
172
+ return patterns
173
+ except Exception:
174
+ # If parsing fails, return empty list
175
+ return []
176
+
177
+ return []
@@ -0,0 +1,186 @@
1
+ """Tests for exclude patterns functionality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from python_package_folder import BuildManager, SubfolderBuildConfig
11
+ from python_package_folder.utils import read_exclude_patterns
12
+
13
+
14
+ @pytest.fixture
15
+ def test_project_with_exclude_patterns(tmp_path: Path) -> Path:
16
+ """Create a test project with exclude patterns in pyproject.toml."""
17
+ project_root = tmp_path / "test_project"
18
+ project_root.mkdir()
19
+
20
+ # Create pyproject.toml with exclude patterns
21
+ pyproject_content = """[project]
22
+ name = "test-package"
23
+ version = "0.1.0"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/test_package"]
31
+
32
+ [tool.python-package-folder]
33
+ exclude-patterns = ["_SS", ".*_test.*", "sandbox"]
34
+ """
35
+ (project_root / "pyproject.toml").write_text(pyproject_content)
36
+
37
+ # Create source directory structure
38
+ src_dir = project_root / "src" / "test_package"
39
+ src_dir.mkdir(parents=True)
40
+
41
+ # Create regular files
42
+ (src_dir / "__init__.py").write_text("")
43
+ (src_dir / "module.py").write_text("def func(): pass")
44
+
45
+ # Create files that should be excluded
46
+ (src_dir / "module_test.py").write_text("# test file")
47
+ (src_dir / "test_data.py").write_text("# test data")
48
+
49
+ # Create directories that should be excluded
50
+ ss_dir = src_dir / "data_storage" / "_SS"
51
+ ss_dir.mkdir(parents=True)
52
+ (ss_dir / "file.py").write_text("# SS file")
53
+
54
+ sandbox_dir = src_dir / "sandbox"
55
+ sandbox_dir.mkdir()
56
+ (sandbox_dir / "file.py").write_text("# sandbox file")
57
+
58
+ # Create nested excluded directory
59
+ nested_ss = src_dir / "nested" / "_SS_nested"
60
+ nested_ss.mkdir(parents=True)
61
+ (nested_ss / "file.py").write_text("# nested SS file")
62
+
63
+ return project_root
64
+
65
+
66
+ @pytest.fixture
67
+ def test_subfolder_project(tmp_path: Path) -> Path:
68
+ """Create a test project with subfolder and exclude patterns."""
69
+ project_root = tmp_path / "test_project"
70
+ project_root.mkdir()
71
+
72
+ # Create pyproject.toml with exclude patterns
73
+ pyproject_content = """[project]
74
+ name = "test-package"
75
+ version = "0.1.0"
76
+
77
+ [build-system]
78
+ requires = ["hatchling"]
79
+ build-backend = "hatchling.build"
80
+
81
+ [tool.hatch.build.targets.wheel]
82
+ packages = ["src/test_package"]
83
+
84
+ [tool.python-package-folder]
85
+ exclude-patterns = ["_SS", ".*_test.*"]
86
+ """
87
+ (project_root / "pyproject.toml").write_text(pyproject_content)
88
+
89
+ # Create subfolder
90
+ subfolder = project_root / "subfolder"
91
+ subfolder.mkdir()
92
+ (subfolder / "__init__.py").write_text("")
93
+ (subfolder / "module.py").write_text("def func(): pass")
94
+
95
+ # Create files that should be excluded
96
+ (subfolder / "module_test.py").write_text("# test file")
97
+ ss_dir = subfolder / "_SS"
98
+ ss_dir.mkdir()
99
+ (ss_dir / "file.py").write_text("# SS file")
100
+
101
+ return project_root
102
+
103
+
104
+ class TestReadExcludePatterns:
105
+ """Tests for reading exclude patterns from pyproject.toml."""
106
+
107
+ def test_read_exclude_patterns_exists(self, test_project_with_exclude_patterns: Path) -> None:
108
+ """Test reading exclude patterns when they exist."""
109
+ pyproject_path = test_project_with_exclude_patterns / "pyproject.toml"
110
+ patterns = read_exclude_patterns(pyproject_path)
111
+
112
+ assert len(patterns) == 3
113
+ assert "_SS" in patterns
114
+ assert ".*_test.*" in patterns
115
+ assert "sandbox" in patterns
116
+
117
+ def test_read_exclude_patterns_not_exists(self, tmp_path: Path) -> None:
118
+ """Test reading exclude patterns when section doesn't exist."""
119
+ pyproject_path = tmp_path / "pyproject.toml"
120
+ pyproject_path.write_text("[project]\nname = 'test'")
121
+
122
+ patterns = read_exclude_patterns(pyproject_path)
123
+ assert patterns == []
124
+
125
+ def test_read_exclude_patterns_file_not_exists(self, tmp_path: Path) -> None:
126
+ """Test reading exclude patterns when file doesn't exist."""
127
+ pyproject_path = tmp_path / "nonexistent.toml"
128
+ patterns = read_exclude_patterns(pyproject_path)
129
+ assert patterns == []
130
+
131
+
132
+ class TestExcludePatternsInBuild:
133
+ """Tests for exclude patterns in build process."""
134
+
135
+ def test_exclude_patterns_in_temp_pyproject(
136
+ self, test_project_with_exclude_patterns: Path
137
+ ) -> None:
138
+ """Test that exclude patterns are injected into temporary pyproject.toml."""
139
+ project_root = test_project_with_exclude_patterns
140
+ src_dir = project_root / "src" / "test_package"
141
+
142
+ # This is detected as a subfolder build
143
+ config = SubfolderBuildConfig(
144
+ project_root=project_root,
145
+ src_dir=src_dir,
146
+ package_name="test-package",
147
+ version="1.0.0",
148
+ )
149
+
150
+ temp_pyproject = config.create_temp_pyproject()
151
+ assert temp_pyproject is not None
152
+
153
+ # Check that exclude patterns are in the temporary pyproject.toml
154
+ content = temp_pyproject.read_text()
155
+ assert "[tool.python-package-folder]" in content
156
+ assert "exclude-patterns" in content
157
+ assert "_SS" in content
158
+ assert ".*_test.*" in content
159
+ assert "sandbox" in content
160
+
161
+ config.restore()
162
+
163
+ def test_exclude_patterns_subfolder_build(self, test_subfolder_project: Path) -> None:
164
+ """Test that exclude patterns from root are applied to subfolder builds."""
165
+ project_root = test_subfolder_project
166
+ subfolder = project_root / "subfolder"
167
+
168
+ config = SubfolderBuildConfig(
169
+ project_root=project_root,
170
+ src_dir=subfolder,
171
+ package_name="subfolder",
172
+ version="1.0.0",
173
+ )
174
+
175
+ # Create temporary pyproject.toml
176
+ temp_pyproject = config.create_temp_pyproject()
177
+ assert temp_pyproject is not None
178
+
179
+ # Check that exclude patterns are in the temporary pyproject.toml
180
+ content = temp_pyproject.read_text()
181
+ assert "[tool.python-package-folder]" in content
182
+ assert "exclude-patterns" in content
183
+ assert "_SS" in content
184
+ assert ".*_test.*" in content
185
+
186
+ config.restore()
@@ -661,7 +661,7 @@ class TestSubfolderBuildTemporaryPyprojectCreation:
661
661
 
662
662
  # Verify build-system section is added (required for hatchling)
663
663
  assert "[build-system]" in content
664
- assert 'requires = ["hatchling"]' in content
664
+ assert 'requires = ["hatchling", "python-package-folder"]' in content
665
665
  assert 'build-backend = "hatchling.build"' in content
666
666
  assert "[tool.hatch.version]" not in content
667
667
  assert "[tool.uv-dynamic-versioning]" not in content
@@ -894,7 +894,7 @@ description = "Subfolder package"
894
894
 
895
895
  # Verify build-system section uses hatchling, not setuptools
896
896
  assert "[build-system]" in content
897
- assert 'requires = ["hatchling"]' in content
897
+ assert 'requires = ["hatchling", "python-package-folder"]' in content
898
898
  assert 'build-backend = "hatchling.build"' in content
899
899
  assert "setuptools" not in content or 'build-backend = "setuptools' not in content
900
900
 
@@ -929,7 +929,7 @@ description = "Subfolder package"
929
929
 
930
930
  # Verify build-system section is added
931
931
  assert "[build-system]" in content
932
- assert 'requires = ["hatchling"]' in content
932
+ assert 'requires = ["hatchling", "python-package-folder"]' in content
933
933
  assert 'build-backend = "hatchling.build"' in content
934
934
 
935
935
  config.restore()