python-package-folder 5.0.0__tar.gz → 5.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/PKG-INFO +1 -1
  2. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/coverage.svg +2 -2
  3. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/docs/REFERENCE.md +23 -0
  4. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/docs/USAGE.md +10 -0
  5. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/pyproject.toml +1 -1
  6. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/_hatch_build.py +90 -1
  7. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/python_package_folder.py +9 -0
  8. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/subfolder_build.py +120 -3
  9. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/utils.py +69 -0
  10. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/version_calculator.py +121 -14
  11. python_package_folder-5.1.0/tests/test_exclude_patterns.py +186 -0
  12. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_version_calculator.py +12 -4
  13. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.copier-answers.yml +0 -0
  14. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  15. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.cursor/plans/replace_node.js_semantic-release_with_custom_python_implementation_64e05e1a.plan.md +0 -0
  16. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.cursor/rules/general.mdc +0 -0
  17. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.cursor/rules/python.mdc +0 -0
  18. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.github/workflows/ci.yml +0 -0
  19. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.github/workflows/publish.yml +0 -0
  20. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.gitignore +0 -0
  21. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/.vscode/settings.json +0 -0
  22. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/LICENSE +0 -0
  23. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/MANIFEST.in +0 -0
  24. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/Makefile +0 -0
  25. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/README.md +0 -0
  26. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/development.md +0 -0
  27. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/docs/DEVELOPMENT.md +0 -0
  28. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/docs/INSTALLATION.md +0 -0
  29. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/docs/PUBLISHING.md +0 -0
  30. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/docs/VERSION_RESOLUTION.md +0 -0
  31. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/installation.md +0 -0
  32. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/publishing.md +0 -0
  33. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/__init__.py +0 -0
  34. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/__main__.py +0 -0
  35. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/analyzer.py +0 -0
  36. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/finder.py +0 -0
  37. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/manager.py +0 -0
  38. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/publisher.py +0 -0
  39. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/py.typed +0 -0
  40. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/types.py +0 -0
  41. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/src/python_package_folder/version.py +0 -0
  42. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/conftest.py +0 -0
  43. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/folder_structure/some_globals.py +0 -0
  44. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  45. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  46. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  47. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  48. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  49. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  50. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_build_with_external_deps.py +0 -0
  51. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_linting.py +0 -0
  52. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_preserve_directory_structure.py +0 -0
  53. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_publisher.py +0 -0
  54. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_shared_subdirectory_imports.py +0 -0
  55. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_spreadsheet_creation_imports.py +0 -0
  56. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_subfolder_build.py +0 -0
  57. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_third_party_dependencies.py +0 -0
  58. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_utils.py +0 -0
  59. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/test_version_manager.py +0 -0
  60. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/tests/tests.py +0 -0
  61. {python_package_folder-5.0.0 → python_package_folder-5.1.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 5.0.0
3
+ Version: 5.1.0
4
4
  Summary: Python package to automatically package and build a folder, fetching all relevant dependencies.
5
5
  Project-URL: Repository, https://github.com/alelom/python-package-folder
6
6
  Author-email: Alessio Lombardi <work@alelom.com>
@@ -14,7 +14,7 @@
14
14
  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
15
15
  <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
16
16
  <text x="31.5" y="14">coverage</text>
17
- <text x="81" y="15" fill="#010101" fill-opacity=".3">68%</text>
18
- <text x="81" y="14">68%</text>
17
+ <text x="81" y="15" fill="#010101" fill-opacity=".3">65%</text>
18
+ <text x="81" y="14">65%</text>
19
19
  </g>
20
20
  </svg>
@@ -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.0"
46
+ version = "5.1.0"
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"]
@@ -10,6 +10,8 @@ It can be invoked via:
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import logging
14
+ import os
13
15
  import subprocess
14
16
  import sys
15
17
  from pathlib import Path
@@ -18,6 +20,13 @@ from .manager import BuildManager
18
20
  from .utils import find_project_root, find_source_directory
19
21
  from .version_calculator import resolve_version
20
22
 
23
+ # Configure logging for version resolution
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format="%(levelname)s: %(message)s",
27
+ handlers=[logging.StreamHandler(sys.stderr)],
28
+ )
29
+
21
30
 
22
31
  def is_github_actions() -> bool:
23
32
  """Check if running in GitHub Actions."""
@@ -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
  """
@@ -288,6 +290,11 @@ class SubfolderBuildConfig:
288
290
  subfolder_content = subfolder_pyproject.read_text(encoding="utf-8")
289
291
  # Adjust packages path to be relative to project root
290
292
  adjusted_content = self._adjust_subfolder_pyproject_packages_path(subfolder_content)
293
+
294
+ # Read exclude patterns from root pyproject.toml and inject them
295
+ exclude_patterns = read_exclude_patterns(original_pyproject)
296
+ if exclude_patterns:
297
+ adjusted_content = self._inject_exclude_patterns(adjusted_content, exclude_patterns)
291
298
 
292
299
  # Write adjusted content to temporary file
293
300
  temp_pyproject_path.write_text(adjusted_content, encoding="utf-8")
@@ -373,6 +380,9 @@ class SubfolderBuildConfig:
373
380
  file=sys.stderr,
374
381
  )
375
382
 
383
+ # Read exclude patterns from root pyproject.toml
384
+ exclude_patterns = read_exclude_patterns(original_pyproject)
385
+
376
386
  if data:
377
387
  # Modify using parsed data
378
388
  if "project" in data:
@@ -394,12 +404,12 @@ class SubfolderBuildConfig:
394
404
 
395
405
  # For now, use string manipulation (tomli-w not in stdlib)
396
406
  modified_content = self._modify_pyproject_string(
397
- original_content, parent_dependency_group
407
+ original_content, parent_dependency_group, exclude_patterns
398
408
  )
399
409
  else:
400
410
  # Use string manipulation
401
411
  modified_content = self._modify_pyproject_string(
402
- original_content, parent_dependency_group
412
+ original_content, parent_dependency_group, exclude_patterns
403
413
  )
404
414
 
405
415
  # Write the modified content to a temporary file
@@ -423,7 +433,10 @@ class SubfolderBuildConfig:
423
433
  return original_pyproject
424
434
 
425
435
  def _modify_pyproject_string(
426
- self, content: str, dependency_group: dict[str, list[str]] | None = None
436
+ self,
437
+ content: str,
438
+ dependency_group: dict[str, list[str]] | None = None,
439
+ exclude_patterns: list[str] | None = None,
427
440
  ) -> str:
428
441
  """Modify pyproject.toml content using string manipulation."""
429
442
  lines = content.split("\n")
@@ -635,8 +648,112 @@ class SubfolderBuildConfig:
635
648
  dep_lines.append("]")
636
649
  result[insert_index:insert_index] = dep_lines
637
650
 
651
+ # Add exclude patterns if specified
652
+ if exclude_patterns:
653
+ # Find where to insert [tool.python-package-folder] section
654
+ # Usually after [dependency-groups] or at the end
655
+ insert_index = len(result)
656
+ tool_section_exists = False
657
+ for i, line in enumerate(result):
658
+ if line.strip() == "[tool.python-package-folder]":
659
+ tool_section_exists = True
660
+ insert_index = i
661
+ break
662
+ elif line.strip().startswith("[tool.") and i > 0:
663
+ # Insert before other tool sections
664
+ insert_index = i
665
+ break
666
+
667
+ # Format exclude patterns
668
+ patterns_str = ", ".join(f'"{p}"' for p in exclude_patterns)
669
+ exclude_lines = [
670
+ "",
671
+ "[tool.python-package-folder]",
672
+ f'exclude-patterns = [{patterns_str}]',
673
+ ]
674
+
675
+ if tool_section_exists:
676
+ # Replace or update existing section
677
+ end_index = insert_index + 1
678
+ while end_index < len(result) and not result[end_index].strip().startswith("["):
679
+ end_index += 1
680
+ # Check if exclude-patterns already exists
681
+ has_exclude_patterns = any(
682
+ "exclude-patterns" in result[i] for i in range(insert_index, end_index)
683
+ )
684
+ if has_exclude_patterns:
685
+ # Update existing exclude-patterns line
686
+ for i in range(insert_index, end_index):
687
+ if "exclude-patterns" in result[i]:
688
+ result[i] = f'exclude-patterns = [{patterns_str}]'
689
+ break
690
+ else:
691
+ # Add exclude-patterns to existing section
692
+ result.insert(end_index - 1, f'exclude-patterns = [{patterns_str}]')
693
+ else:
694
+ # Insert new section
695
+ result[insert_index:insert_index] = exclude_lines
696
+
638
697
  return "\n".join(result)
639
698
 
699
+ def _inject_exclude_patterns(self, content: str, exclude_patterns: list[str]) -> str:
700
+ """
701
+ Inject exclude patterns into pyproject.toml content.
702
+
703
+ Adds or updates [tool.python-package-folder] exclude-patterns section.
704
+
705
+ Args:
706
+ content: pyproject.toml content
707
+ exclude_patterns: List of exclude patterns to inject
708
+
709
+ Returns:
710
+ Modified pyproject.toml content with exclude patterns
711
+ """
712
+ if not exclude_patterns:
713
+ return content
714
+
715
+ lines = content.split("\n")
716
+ result = []
717
+ tool_section_exists = False
718
+ tool_section_index = -1
719
+ tool_section_end = -1
720
+
721
+ # Find [tool.python-package-folder] section
722
+ for i, line in enumerate(lines):
723
+ if line.strip() == "[tool.python-package-folder]":
724
+ tool_section_exists = True
725
+ tool_section_index = i
726
+ # Find end of section
727
+ for j in range(i + 1, len(lines)):
728
+ if lines[j].strip().startswith("["):
729
+ tool_section_end = j
730
+ break
731
+ if tool_section_end == -1:
732
+ tool_section_end = len(lines)
733
+ break
734
+
735
+ if tool_section_exists:
736
+ # Update existing section
737
+ patterns_str = ", ".join(f'"{p}"' for p in exclude_patterns)
738
+ has_exclude_patterns = False
739
+ for i in range(tool_section_index + 1, tool_section_end):
740
+ if "exclude-patterns" in lines[i]:
741
+ # Update existing line
742
+ lines[i] = f'exclude-patterns = [{patterns_str}]'
743
+ has_exclude_patterns = True
744
+ break
745
+ if not has_exclude_patterns:
746
+ # Add exclude-patterns to existing section
747
+ lines.insert(tool_section_end, f'exclude-patterns = [{patterns_str}]')
748
+ return "\n".join(lines)
749
+ else:
750
+ # Add new section at the end
751
+ patterns_str = ", ".join(f'"{p}"' for p in exclude_patterns)
752
+ lines.append("")
753
+ lines.append("[tool.python-package-folder]")
754
+ lines.append(f'exclude-patterns = [{patterns_str}]')
755
+ return "\n".join(lines)
756
+
640
757
  def add_third_party_dependencies(self, dependencies: list[str]) -> None:
641
758
  """
642
759
  Add third-party dependencies to the temporary pyproject.toml.
@@ -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 []
@@ -10,12 +10,15 @@ Reference: https://semantic-release.gitbook.io/semantic-release/
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import logging
13
14
  import re
14
15
  import subprocess
15
16
  from pathlib import Path
16
17
 
17
18
  import requests
18
19
 
20
+ logger = logging.getLogger(__name__)
21
+
19
22
 
20
23
  def query_registry_version(
21
24
  package_name: str,
@@ -38,13 +41,26 @@ def query_registry_version(
38
41
 
39
42
  try:
40
43
  if repository in ("pypi", "testpypi"):
41
- return _query_pypi_version(package_name, repository)
44
+ logger.info(f"Querying {repository} for package '{package_name}'")
45
+ version = _query_pypi_version(package_name, repository)
46
+ if version:
47
+ logger.info(f"Found version {version} on {repository}")
48
+ else:
49
+ logger.info(f"Package '{package_name}' not found on {repository} (first release)")
50
+ return version
42
51
  elif repository == "azure":
43
52
  if not repository_url:
53
+ logger.warning("Azure Artifacts repository URL not provided")
44
54
  return None
45
- return _query_azure_artifacts_version(package_name, repository_url)
46
- except Exception:
47
- # Log error but don't fail - fall back to git tags
55
+ logger.info(f"Querying Azure Artifacts for package '{package_name}' at {repository_url}")
56
+ version = _query_azure_artifacts_version(package_name, repository_url)
57
+ if version:
58
+ logger.info(f"Found version {version} on Azure Artifacts")
59
+ else:
60
+ logger.info(f"Could not retrieve version from Azure Artifacts for '{package_name}' (will fall back to git tags)")
61
+ return version
62
+ except Exception as e:
63
+ logger.warning(f"Error querying {repository} for package '{package_name}': {e}", exc_info=True)
48
64
  return None
49
65
 
50
66
  return None
@@ -68,6 +84,7 @@ def _query_pypi_version(package_name: str, registry: str) -> str | None:
68
84
  response = requests.get(url, timeout=10)
69
85
  if response.status_code == 404:
70
86
  # Package doesn't exist yet (first release)
87
+ logger.debug(f"Package '{package_name}' not found on {registry} (404)")
71
88
  return None
72
89
  if response.status_code == 200:
73
90
  json_data = response.json()
@@ -80,9 +97,16 @@ def _query_pypi_version(package_name: str, registry: str) -> str | None:
80
97
  # Sort versions and get latest
81
98
  versions = sorted(releases.keys(), key=_parse_version_for_sort)
82
99
  version = versions[-1] if versions else None
100
+ if version:
101
+ logger.debug(f"Retrieved version {version} from {registry} for '{package_name}'")
83
102
  return version
103
+ logger.warning(f"Unexpected status code {response.status_code} from {registry} for '{package_name}'")
84
104
  return None
85
- except Exception:
105
+ except requests.RequestException as e:
106
+ logger.warning(f"Network error querying {registry} for '{package_name}': {e}")
107
+ return None
108
+ except Exception as e:
109
+ logger.warning(f"Error parsing response from {registry} for '{package_name}': {e}", exc_info=True)
86
110
  return None
87
111
 
88
112
 
@@ -111,17 +135,35 @@ def _query_azure_artifacts_version(
111
135
  simple_index_url = repository_url.replace("/upload", f"/simple/{package_name}/")
112
136
  else:
113
137
  simple_index_url = repository_url.rstrip("/") + f"/simple/{package_name}/"
114
- except Exception:
138
+ logger.debug(f"Constructed Azure Artifacts simple index URL: {simple_index_url}")
139
+ except Exception as e:
140
+ logger.warning(f"Error constructing Azure Artifacts URL for '{package_name}': {e}")
115
141
  return None
116
142
 
117
143
  try:
118
144
  response = requests.get(simple_index_url, timeout=5)
145
+ logger.debug(f"Azure Artifacts response status: {response.status_code}")
146
+
147
+ if response.status_code == 401:
148
+ logger.warning(f"Authentication required for Azure Artifacts (401). Package '{package_name}' may require authentication to query.")
149
+ elif response.status_code == 403:
150
+ logger.warning(f"Access forbidden for Azure Artifacts (403). Package '{package_name}' may not be accessible or requires different permissions.")
151
+ elif response.status_code == 404:
152
+ logger.debug(f"Package '{package_name}' not found on Azure Artifacts (404) - first release")
153
+ elif response.status_code != 200:
154
+ logger.warning(f"Unexpected status code {response.status_code} from Azure Artifacts for '{package_name}'")
155
+
119
156
  # Azure Artifacts simple index returns HTML, not JSON
120
157
  # Parsing HTML is complex and may require authentication
121
158
  # For now, we'll return None to fall back to git tags
122
159
  # This can be enhanced later with proper HTML parsing or API endpoint discovery
160
+ logger.info(f"Azure Artifacts version query not fully implemented (HTML parsing required). Falling back to git tags.")
123
161
  return None
124
- except Exception:
162
+ except requests.RequestException as e:
163
+ logger.warning(f"Network error querying Azure Artifacts for '{package_name}': {e}")
164
+ return None
165
+ except Exception as e:
166
+ logger.warning(f"Unexpected error querying Azure Artifacts for '{package_name}': {e}", exc_info=True)
125
167
  return None
126
168
 
127
169
 
@@ -465,26 +507,91 @@ def resolve_version(
465
507
  # Step 1: Try to get baseline version from registry
466
508
  baseline_version = None
467
509
  if repository and package_name:
510
+ logger.info(f"Attempting to query {repository} for baseline version of '{package_name}'")
468
511
  baseline_version = query_registry_version(package_name, repository, repository_url)
469
512
 
470
513
  # Step 2: Fallback to git tags if registry query failed
471
514
  if not baseline_version:
515
+ logger.info(f"Registry query did not return a version, falling back to git tags")
516
+ if is_subfolder:
517
+ logger.debug(f"Looking for subfolder git tags matching '{package_name}-v*'")
518
+ else:
519
+ logger.debug("Looking for main package git tags matching 'v*'")
472
520
  baseline_version = get_latest_git_tag(project_root, package_name, is_subfolder)
521
+ if baseline_version:
522
+ logger.info(f"Found baseline version {baseline_version} from git tags")
523
+ else:
524
+ logger.info("No git tags found")
473
525
 
474
- # Step 3: If still no baseline, we can't calculate next version
526
+ # Step 3: If still no baseline, this is likely the first release
527
+ # Default to 0.0.0 as the starting version (standard semantic-release behavior)
475
528
  if not baseline_version:
476
- # This could be the first release - we'd need to decide on a starting version
477
- # For now, return None to indicate we can't determine the version
478
- return None, "No baseline version found (no registry version or git tags)"
479
-
480
- # Step 4: Get commits since baseline
481
- commits = get_commits_since(project_root, baseline_version, subfolder_path, package_name)
529
+ logger.info("No baseline version found (no registry version or git tags). Treating as first release (baseline: 0.0.0)")
530
+ baseline_version = "0.0.0"
531
+ # For first release, get all commits (no baseline to compare against)
532
+ # Use HEAD as the reference point to get all commits
533
+ try:
534
+ cmd = ["git", "log", "--format=%B", "HEAD"]
535
+ if subfolder_path:
536
+ # Convert to relative path from project root
537
+ if subfolder_path.is_absolute():
538
+ rel_path = subfolder_path.relative_to(project_root)
539
+ else:
540
+ rel_path = subfolder_path
541
+ cmd.append("--")
542
+ cmd.append(str(rel_path))
543
+
544
+ result = subprocess.run(
545
+ cmd,
546
+ cwd=project_root,
547
+ capture_output=True,
548
+ text=True,
549
+ check=False,
550
+ )
551
+
552
+ commits = []
553
+ if result.returncode == 0:
554
+ # Split commits (they're separated by double newlines in --format=%B)
555
+ current_commit = []
556
+ for line in result.stdout.split("\n"):
557
+ if line.strip() == "" and current_commit:
558
+ commit_msg = "\n".join(current_commit).strip()
559
+ if commit_msg:
560
+ commits.append(commit_msg)
561
+ current_commit = []
562
+ else:
563
+ current_commit.append(line)
564
+
565
+ # Don't forget the last commit if there's no trailing newline
566
+ if current_commit:
567
+ commit_msg = "\n".join(current_commit).strip()
568
+ if commit_msg:
569
+ commits.append(commit_msg)
570
+ logger.debug(f"Retrieved {len(commits)} commits for first release")
571
+ except Exception as e:
572
+ logger.warning(f"Error retrieving commits for first release: {e}", exc_info=True)
573
+ commits = []
574
+ else:
575
+ # Step 4: Get commits since baseline
576
+ logger.info(f"Retrieving commits since version {baseline_version}")
577
+ if subfolder_path:
578
+ logger.debug(f"Filtering commits for subfolder path: {subfolder_path}")
579
+ commits = get_commits_since(project_root, baseline_version, subfolder_path, package_name)
580
+ logger.debug(f"Found {len(commits)} commits since {baseline_version}")
482
581
 
483
582
  # Step 5: Calculate next version
583
+ logger.info(f"Calculating next version from baseline {baseline_version} and {len(commits)} commits")
484
584
  next_version = calculate_next_version(baseline_version, commits)
485
585
 
486
586
  if next_version:
587
+ logger.info(f"Calculated next version: {next_version}")
487
588
  return next_version, None
488
589
  else:
489
590
  # No relevant commits for version bump
591
+ # For first release (0.0.0), default to 0.1.0 if there are any commits
592
+ if baseline_version == "0.0.0" and commits:
593
+ # Even if commits don't match conventional format, start at 0.1.0 for first release
594
+ logger.info("No conventional commits found, but commits exist. Defaulting to 0.1.0 for first release")
595
+ return "0.1.0", None
596
+ logger.info("No version bump needed (no relevant conventional commits found)")
490
597
  return None, None
@@ -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()
@@ -414,14 +414,22 @@ class TestResolveVersion:
414
414
 
415
415
  @patch("python_package_folder.version_calculator.query_registry_version")
416
416
  @patch("python_package_folder.version_calculator.get_latest_git_tag")
417
+ @patch("python_package_folder.version_calculator.subprocess.run")
417
418
  def test_resolve_version_no_baseline(
418
419
  self,
420
+ mock_subprocess: MagicMock,
419
421
  mock_git_tag: MagicMock,
420
422
  mock_registry: MagicMock,
421
423
  ) -> None:
422
- """Test error when no baseline version is found."""
424
+ """Test first release behavior when no baseline version is found."""
423
425
  mock_registry.return_value = None
424
426
  mock_git_tag.return_value = None
427
+
428
+ # Mock git log for first release with conventional commits
429
+ mock_result = Mock()
430
+ mock_result.returncode = 0
431
+ mock_result.stdout = "feat: initial feature\n\n"
432
+ mock_subprocess.return_value = mock_result
425
433
 
426
434
  version, error = resolve_version(
427
435
  Path("/tmp/test"),
@@ -429,9 +437,9 @@ class TestResolveVersion:
429
437
  repository="pypi",
430
438
  )
431
439
 
432
- assert version is None
433
- assert error is not None
434
- assert "baseline version" in error.lower()
440
+ # For first release with feat commit, should calculate 0.1.0 (0.0.0 + minor)
441
+ assert version == "0.1.0"
442
+ assert error is None
435
443
 
436
444
  @patch("python_package_folder.version_calculator.query_registry_version")
437
445
  @patch("python_package_folder.version_calculator.get_commits_since")