python-package-folder 9.0.0__tar.gz → 9.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 (60) hide show
  1. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/PKG-INFO +1 -1
  2. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/coverage.svg +2 -2
  3. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/docs/PUBLISHING.md +11 -0
  4. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/docs/USAGE.md +29 -2
  5. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/pyproject.toml +1 -1
  6. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/manager.py +35 -17
  7. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/publisher.py +33 -2
  8. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/subfolder_build.py +417 -0
  9. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_subfolder_build.py +723 -97
  10. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.copier-answers.yml +0 -0
  11. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.cursor/plans/optional_version_+_semantic-release_efed88a6.plan.md +0 -0
  12. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.cursor/plans/replace_node.js_semantic-release_with_custom_python_implementation_64e05e1a.plan.md +0 -0
  13. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.cursor/rules/general.mdc +0 -0
  14. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.cursor/rules/python.mdc +0 -0
  15. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.github/workflows/ci.yml +0 -0
  16. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.github/workflows/publish.yml +0 -0
  17. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.gitignore +0 -0
  18. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/.vscode/settings.json +0 -0
  19. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/LICENSE +0 -0
  20. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/MANIFEST.in +0 -0
  21. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/Makefile +0 -0
  22. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/README.md +0 -0
  23. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/development.md +0 -0
  24. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/docs/DEVELOPMENT.md +0 -0
  25. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/docs/INSTALLATION.md +0 -0
  26. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/docs/REFERENCE.md +0 -0
  27. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/docs/VERSION_RESOLUTION.md +0 -0
  28. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/installation.md +0 -0
  29. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/publishing.md +0 -0
  30. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/__init__.py +0 -0
  31. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/__main__.py +0 -0
  32. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/analyzer.py +0 -0
  33. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/finder.py +0 -0
  34. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/py.typed +0 -0
  35. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/python_package_folder.py +0 -0
  36. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/types.py +0 -0
  37. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/utils.py +0 -0
  38. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/version.py +0 -0
  39. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/src/python_package_folder/version_calculator.py +0 -0
  40. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/conftest.py +0 -0
  41. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/folder_structure/some_globals.py +0 -0
  42. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/folder_structure/subfolder_to_build/README.md +0 -0
  43. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/folder_structure/subfolder_to_build/__init__.py +0 -0
  44. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/folder_structure/subfolder_to_build/some_function.py +0 -0
  45. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/folder_structure/subfolder_to_build/some_globals.py +0 -0
  46. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/folder_structure/utility_folder/_SS/some_superseded_file.py +0 -0
  47. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/folder_structure/utility_folder/some_utility.py +0 -0
  48. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_build_with_external_deps.py +0 -0
  49. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_exclude_patterns.py +0 -0
  50. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_linting.py +0 -0
  51. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_preserve_directory_structure.py +0 -0
  52. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_publisher.py +0 -0
  53. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_shared_subdirectory_imports.py +0 -0
  54. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_spreadsheet_creation_imports.py +0 -0
  55. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_third_party_dependencies.py +0 -0
  56. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_utils.py +0 -0
  57. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_version_calculator.py +0 -0
  58. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/test_version_manager.py +0 -0
  59. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/tests/tests.py +0 -0
  60. {python_package_folder-9.0.0 → python_package_folder-9.1.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-package-folder
3
- Version: 9.0.0
3
+ Version: 9.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">67%</text>
18
- <text x="81" y="14">67%</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>
@@ -51,6 +51,17 @@ When publishing, the tool automatically filters distribution files to only uploa
51
51
 
52
52
  This ensures that when building a subfolder package, only that package's distribution files are uploaded, not files from previous builds of other packages.
53
53
 
54
+ ## Version Mismatch Detection
55
+
56
+ If there's a version mismatch between the built package and the expected version (e.g., when a subfolder's `pyproject.toml` has a different version than the derived version), the error message will show:
57
+
58
+ - What version was actually built
59
+ - What version is expected for publishing
60
+ - An explanation of the mismatch
61
+ - A solution suggestion
62
+
63
+ The tool automatically updates the version in the subfolder's `pyproject.toml` to match the derived version, so this error should only occur if the build process fails before the version update takes effect.
64
+
54
65
  To get a PyPI API token:
55
66
  1. Go to https://pypi.org/manage/account/token/
56
67
  2. Create a new API token
@@ -75,7 +75,13 @@ The tool automatically:
75
75
  - Uses the current directory as the source directory if it contains Python files
76
76
  - Falls back to `project_root/src` if the current directory isn't suitable
77
77
  - **For subfolder builds**: Handles `pyproject.toml` configuration:
78
- - **If `pyproject.toml` exists in subfolder**: Uses that file (copies it to project root temporarily, adjusting package paths and ensuring `[build-system]` uses hatchling)
78
+ - **If `pyproject.toml` exists in subfolder**:
79
+ - Uses that file (copies it to project root temporarily, adjusting package paths and ensuring `[build-system]` uses hatchling)
80
+ - **Version handling**: The version in the subfolder's `pyproject.toml` is automatically updated to match the derived version (from conventional commits or `--version` argument). A warning is shown if versions differ.
81
+ - **Name handling**: If the subfolder's `pyproject.toml` has a `name` field that differs from the derived name, a warning is shown but the subfolder's name is used (not the derived one).
82
+ - **Dependencies handling**: If the subfolder's `pyproject.toml` has a non-empty `dependencies` field, automatic dependency detection is skipped with a warning. To enable automatic detection, remove or empty the `dependencies` field.
83
+ - **Field merging**: Missing fields (like `description`, `authors`, `keywords`, `classifiers`, `license`, `urls`, etc.) are automatically filled from the parent `pyproject.toml` if available.
84
+ - **Exclude patterns**: Exclude patterns from the parent `pyproject.toml` are merged into the subfolder's configuration.
79
85
  - **If no `pyproject.toml` in subfolder**: Creates a temporary `pyproject.toml` with:
80
86
  - `[build-system]` section using hatchling (always uses hatchling, even if parent uses setuptools)
81
87
  - Package name derived from the subfolder name (e.g., `empty_drawing_detection` → `empty-drawing-detection`)
@@ -101,11 +107,32 @@ python-package-folder --version "0.1.0" --package-name "my-subfolder-package" --
101
107
  python-package-folder --version "0.1.0" --dependency-group "dev" --publish pypi
102
108
 
103
109
  # If subfolder has its own pyproject.toml, it will be used automatically
104
- # (package-name and version arguments are ignored in this case)
110
+ # The version will be updated to match the derived version (from conventional commits)
111
+ # The package name from the subfolder toml will be used (even if it differs from derived)
105
112
  cd src/integration/my_package # assuming my_package/pyproject.toml exists
106
113
  python-package-folder --publish pypi
107
114
  ```
108
115
 
116
+ **Subfolder `pyproject.toml` Behavior:**
117
+
118
+ When a subfolder has its own `pyproject.toml`, the tool intelligently merges it with derived information:
119
+
120
+ 1. **Version**: Always updated to match the derived version (from conventional commits or `--version`). If the subfolder's version differs, a warning is shown but the derived version is used.
121
+
122
+ 2. **Name**: If the subfolder's `pyproject.toml` has a `name` field, it takes precedence over the derived name. A warning is shown if they differ.
123
+
124
+ 3. **Dependencies**:
125
+ - If the subfolder's `pyproject.toml` has a non-empty `dependencies` field, automatic dependency detection is **skipped** (with a warning).
126
+ - To enable automatic dependency detection, remove or empty the `dependencies` field in the subfolder's `pyproject.toml`.
127
+ - If the `dependencies` field is empty or missing, automatic detection proceeds normally.
128
+
129
+ 4. **Field Merging**: Missing fields are automatically filled from the parent `pyproject.toml`:
130
+ - `description`, `readme`, `requires-python`, `authors`, `keywords`, `classifiers`, `license`
131
+ - `[project.urls]` section
132
+ - Other `[tool.*]` sections (except `tool.hatch.build.*` and `tool.python-package-folder.*`)
133
+
134
+ 5. **Exclude Patterns**: Automatically merged from parent `pyproject.toml`.
135
+
109
136
  **Dependency Groups**: When building a subfolder, you can specify a dependency group from the parent `pyproject.toml` to include in the subfolder's build configuration. This allows subfolders to inherit specific dependencies from the parent project:
110
137
 
111
138
  ```bash
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  # ---- Dev dependencies ----
45
45
 
46
- version = "9.0.0"
46
+ version = "9.1.0"
47
47
  [dependency-groups]
48
48
  dev = [
49
49
  "pytest>=8.3.5",
@@ -255,28 +255,46 @@ class BuildManager:
255
255
  # Fallback to just subfolder name if root project name not found
256
256
  package_name = subfolder_name
257
257
 
258
- print(
259
- f"Detected subfolder build. Setting up package '{package_name}' version '{version}'..."
260
- )
261
- self.subfolder_config = SubfolderBuildConfig(
262
- project_root=self.project_root,
263
- src_dir=self.src_dir,
264
- package_name=package_name,
265
- version=version,
266
- dependency_group=dependency_group,
267
- )
268
- temp_pyproject = self.subfolder_config.create_temp_pyproject()
269
- # If temp_pyproject is None, it means no parent pyproject.toml exists
270
- # This is acceptable for tests or dependency-only operations
271
- if temp_pyproject is None:
272
- self.subfolder_config = None
258
+ # Check if subfolder_config already exists with the same parameters
259
+ # This makes prepare_build() idempotent - safe to call multiple times
260
+ if (
261
+ self.subfolder_config
262
+ and self.subfolder_config.package_name == package_name
263
+ and self.subfolder_config.version == version
264
+ and self.subfolder_config.dependency_group == dependency_group
265
+ and self.subfolder_config._temp_package_dir
266
+ and self.subfolder_config._temp_package_dir.exists()
267
+ ):
268
+ print(
269
+ f"Subfolder config already exists for '{package_name}' version '{version}', reusing it..."
270
+ )
271
+ # Still need to find external dependencies, so continue with that
272
+ # temp_pyproject should already exist from previous call
273
+ temp_pyproject = self.subfolder_config.temp_pyproject
273
274
  else:
275
+ print(
276
+ f"Detected subfolder build. Setting up package '{package_name}' version '{version}'..."
277
+ )
278
+ self.subfolder_config = SubfolderBuildConfig(
279
+ project_root=self.project_root,
280
+ src_dir=self.src_dir,
281
+ package_name=package_name,
282
+ version=version,
283
+ dependency_group=dependency_group,
284
+ )
285
+ temp_pyproject = self.subfolder_config.create_temp_pyproject()
286
+ # If temp_pyproject is None, it means no parent pyproject.toml exists
287
+ # This is acceptable for tests or dependency-only operations
288
+ if temp_pyproject is None:
289
+ self.subfolder_config = None
290
+
291
+ # If we have a subfolder_config (either newly created or reused), use temp package dir
292
+ if self.subfolder_config:
274
293
  # If temporary package directory was created, use it for all operations
275
294
  # This ensures dependencies are copied to the correct location and
276
295
  # imports are fixed in the files that will actually be packaged
277
296
  if (
278
- self.subfolder_config
279
- and self.subfolder_config._temp_package_dir
297
+ self.subfolder_config._temp_package_dir
280
298
  and self.subfolder_config._temp_package_dir.exists()
281
299
  ):
282
300
  # Update src_dir to point to temp package directory
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import getpass
11
11
  import os
12
+ import re
12
13
  import subprocess
13
14
  import sys
14
15
  from enum import Enum
@@ -284,10 +285,40 @@ class Publisher:
284
285
 
285
286
  if not dist_files:
286
287
  if self.package_name and self.version:
287
- raise ValueError(
288
+ # Try to find what version was actually built
289
+ built_versions = set()
290
+ for dist_file in all_dist_files:
291
+ # Extract version from filename: package-name-version-*.whl
292
+ # Pattern: {package_name}-{version}-{...}
293
+ if self.package_name.replace("-", "_") in dist_file.name or self.package_name in dist_file.name:
294
+ # Try to extract version from filename
295
+ parts = dist_file.stem.split("-")
296
+ # Look for version-like pattern (e.g., 1.2.3)
297
+ for i, part in enumerate(parts):
298
+ if re.match(r"^\d+\.\d+\.\d+", part):
299
+ built_versions.add(part)
300
+ break
301
+
302
+ error_msg = (
288
303
  f"No distribution files found matching package '{self.package_name}' "
289
- f"version '{self.version}' in {self.dist_dir}"
304
+ f"version '{self.version}' in {self.dist_dir}\n"
290
305
  )
306
+
307
+ if built_versions:
308
+ error_msg += (
309
+ f" - Built package version(s): {', '.join(sorted(built_versions))}\n"
310
+ f" - Expected version for publishing: {self.version}\n"
311
+ f" - This usually indicates a version mismatch between the built package and the expected version.\n"
312
+ f" - Solution: The version in the subfolder's pyproject.toml will be automatically updated to match the derived version."
313
+ )
314
+ else:
315
+ error_msg += (
316
+ f" - No distribution files found for package '{self.package_name}' in {self.dist_dir}\n"
317
+ f" - Available files: {[f.name for f in all_dist_files[:5]]}"
318
+ + (f" (and {len(all_dist_files) - 5} more)" if len(all_dist_files) > 5 else "")
319
+ )
320
+
321
+ raise ValueError(error_msg)
291
322
  else:
292
323
  raise ValueError(f"No distribution files found in {self.dist_dir}")
293
324
 
@@ -79,6 +79,7 @@ class SubfolderBuildConfig:
79
79
  self._excluded_files: list[tuple[Path, Path]] = [] # List of (original_path, temp_path) tuples
80
80
  self._exclude_temp_dir: Path | None = None
81
81
  self._temp_package_dir: Path | None = None
82
+ self._has_existing_dependencies = False # Track if subfolder toml has dependencies
82
83
 
83
84
  def _derive_package_name(self) -> str:
84
85
  """
@@ -549,6 +550,361 @@ class SubfolderBuildConfig:
549
550
 
550
551
  return "\n".join(result)
551
552
 
553
+ def _update_version_in_pyproject(self, content: str) -> str:
554
+ """
555
+ Update the version in pyproject.toml content to match self.version.
556
+
557
+ Also checks if version differs and warns the user.
558
+
559
+ Args:
560
+ content: Content of pyproject.toml
561
+
562
+ Returns:
563
+ Content with updated version
564
+ """
565
+ if not self.version:
566
+ return content
567
+
568
+ lines = content.split("\n")
569
+ result = []
570
+ in_project_section = False
571
+ version_set = False
572
+ existing_version = None
573
+
574
+ for line in lines:
575
+ # Detect [project] section
576
+ if line.strip() == "[project]":
577
+ in_project_section = True
578
+ result.append(line)
579
+ continue
580
+ elif line.strip().startswith("[") and in_project_section:
581
+ # End of [project] section - add version if not set
582
+ if not version_set:
583
+ result.append(f'version = "{self.version}"')
584
+ version_set = True
585
+ in_project_section = False
586
+ result.append(line)
587
+ elif in_project_section:
588
+ # Check if this is a version line
589
+ version_match = re.match(r'^\s*version\s*=\s*["\']([^"\']+)["\']', line)
590
+ if version_match:
591
+ existing_version = version_match.group(1)
592
+ result.append(f'version = "{self.version}"')
593
+ version_set = True
594
+ continue # Skip the original version line
595
+ else:
596
+ result.append(line)
597
+ else:
598
+ result.append(line)
599
+
600
+ # If we never found [project] section or version wasn't set, add it
601
+ if not version_set:
602
+ # Try to find where to insert it - after [project] if it exists
603
+ if "[project]" in content:
604
+ # Insert after [project] line
605
+ new_lines = []
606
+ inserted = False
607
+ for i, line in enumerate(lines):
608
+ new_lines.append(line)
609
+ if line.strip() == "[project]" and not inserted:
610
+ # Insert version right after [project]
611
+ new_lines.append(f'version = "{self.version}"')
612
+ inserted = True
613
+ result = new_lines
614
+ else:
615
+ # No [project] section, add it at the beginning
616
+ result.insert(0, "[project]")
617
+ result.insert(1, f'version = "{self.version}"')
618
+
619
+ # Warn if version differs
620
+ if existing_version and existing_version != self.version:
621
+ print(
622
+ f"\nWarning: Version mismatch in subfolder pyproject.toml",
623
+ file=sys.stderr,
624
+ )
625
+ print(
626
+ f" - Version in file: {existing_version}",
627
+ file=sys.stderr,
628
+ )
629
+ print(
630
+ f" - Derived version: {self.version} (from conventional commits/CLI)",
631
+ file=sys.stderr,
632
+ )
633
+ print(
634
+ f" - Using derived version: {self.version}",
635
+ file=sys.stderr,
636
+ )
637
+ print(
638
+ f" - The version in the subfolder's pyproject.toml will be updated for this build.\n",
639
+ file=sys.stderr,
640
+ )
641
+
642
+ return "\n".join(result)
643
+
644
+ def _check_and_warn_about_dependencies(self, content: str) -> bool:
645
+ """
646
+ Check if subfolder pyproject.toml has a non-empty dependencies field.
647
+
648
+ Args:
649
+ content: Content of pyproject.toml
650
+
651
+ Returns:
652
+ True if dependencies field exists and is non-empty, False otherwise
653
+ """
654
+ if not tomllib:
655
+ # Fallback: simple regex check
656
+ # Look for dependencies = [...] pattern
657
+ deps_match = re.search(r'^\s*dependencies\s*=\s*\[', content, re.MULTILINE)
658
+ if deps_match:
659
+ # Try to find if there are any dependencies in the list
660
+ # This is a simple heuristic - look for non-empty content between [ and ]
661
+ lines = content.split("\n")
662
+ in_project = False
663
+ in_dependencies = False
664
+ dependency_count = 0
665
+
666
+ for line in lines:
667
+ if line.strip() == "[project]":
668
+ in_project = True
669
+ elif line.strip().startswith("[") and in_project:
670
+ in_project = False
671
+ elif in_project and re.match(r"^\s*dependencies\s*=\s*\[", line):
672
+ in_dependencies = True
673
+ # Check if line has content after [
674
+ if "]" not in line:
675
+ # Multi-line dependencies
676
+ continue
677
+ else:
678
+ # Single line: dependencies = ["pkg1", "pkg2"]
679
+ deps_str = line.split("[", 1)[1].rsplit("]", 1)[0]
680
+ if deps_str.strip():
681
+ return True
682
+ return False
683
+ elif in_dependencies:
684
+ # Check if this line has a dependency (not just whitespace or closing bracket)
685
+ stripped = line.strip()
686
+ if stripped and not stripped.startswith("#") and stripped != "]":
687
+ # Check if it looks like a dependency string
688
+ if re.search(r'["\'][^"\']+["\']', stripped):
689
+ dependency_count += 1
690
+ if "]" in line:
691
+ return dependency_count > 0
692
+
693
+ return dependency_count > 0
694
+ return False
695
+
696
+ # Use tomllib for more accurate parsing
697
+ try:
698
+ data = tomllib.loads(content.encode())
699
+ project = data.get("project", {})
700
+ dependencies = project.get("dependencies", [])
701
+
702
+ # Check if dependencies list is non-empty
703
+ if dependencies and len(dependencies) > 0:
704
+ return True
705
+ return False
706
+ except Exception:
707
+ # If parsing fails, fall back to regex-based check
708
+ # Look for dependencies = [...] pattern
709
+ deps_match = re.search(r'^\s*dependencies\s*=\s*\[', content, re.MULTILINE)
710
+ if deps_match:
711
+ # Try to find if there are any dependencies in the list
712
+ lines = content.split("\n")
713
+ in_project = False
714
+ in_dependencies = False
715
+ dependency_count = 0
716
+
717
+ for line in lines:
718
+ if line.strip() == "[project]":
719
+ in_project = True
720
+ elif line.strip().startswith("[") and in_project:
721
+ in_project = False
722
+ elif in_project and re.match(r"^\s*dependencies\s*=\s*\[", line):
723
+ in_dependencies = True
724
+ # Check if line has content after [
725
+ if "]" not in line:
726
+ # Multi-line dependencies
727
+ continue
728
+ else:
729
+ # Single line: dependencies = ["pkg1", "pkg2"]
730
+ deps_str = line.split("[", 1)[1].rsplit("]", 1)[0]
731
+ if deps_str.strip():
732
+ return True
733
+ return False
734
+ elif in_dependencies:
735
+ # Check if this line has a dependency (not just whitespace or closing bracket)
736
+ stripped = line.strip()
737
+ if stripped and not stripped.startswith("#") and stripped != "]":
738
+ # Check if it looks like a dependency string
739
+ if re.search(r'["\'][^"\']+["\']', stripped):
740
+ dependency_count += 1
741
+ if "]" in line:
742
+ return dependency_count > 0
743
+
744
+ return dependency_count > 0
745
+ return False
746
+
747
+ def _check_and_warn_about_name(self, content: str) -> str | None:
748
+ """
749
+ Check if subfolder pyproject.toml has a name field and warn if it differs from derived.
750
+
751
+ Args:
752
+ content: Content of pyproject.toml
753
+
754
+ Returns:
755
+ Name from subfolder toml if found, None otherwise
756
+ """
757
+ if not tomllib:
758
+ # Fallback: simple regex check
759
+ name_match = re.search(r'^\s*name\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE)
760
+ if name_match:
761
+ return name_match.group(1)
762
+ return None
763
+
764
+ # Use tomllib for more accurate parsing
765
+ try:
766
+ data = tomllib.loads(content.encode())
767
+ project = data.get("project", {})
768
+ name = project.get("name")
769
+ return name
770
+ except Exception:
771
+ # If parsing fails, fall back to regex
772
+ name_match = re.search(r'^\s*name\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE)
773
+ if name_match:
774
+ return name_match.group(1)
775
+ return None
776
+
777
+ def _merge_from_parent_pyproject(self, subfolder_content: str, parent_content: str) -> str:
778
+ """
779
+ Merge missing fields from parent pyproject.toml into subfolder content.
780
+
781
+ Priority: subfolder > parent (only fill missing fields from parent)
782
+
783
+ This uses string manipulation to add missing fields since we don't have tomli-w
784
+ for proper TOML round-trip. Only common fields are merged.
785
+
786
+ Args:
787
+ subfolder_content: Content of subfolder pyproject.toml
788
+ parent_content: Content of parent pyproject.toml
789
+
790
+ Returns:
791
+ Merged content
792
+ """
793
+ if not tomllib:
794
+ # If tomllib not available, return subfolder content as-is
795
+ return subfolder_content
796
+
797
+ try:
798
+ subfolder_data = tomllib.loads(subfolder_content.encode())
799
+ parent_data = tomllib.loads(parent_content.encode())
800
+
801
+ # Fields to merge from parent if missing in subfolder
802
+ fields_to_merge = [
803
+ "description", "readme", "requires-python", "authors",
804
+ "keywords", "classifiers", "license", "urls"
805
+ ]
806
+
807
+ # Check what's missing and needs to be added
808
+ missing_fields = []
809
+ if "project" in parent_data and "project" in subfolder_data:
810
+ parent_project = parent_data["project"]
811
+ subfolder_project = subfolder_data["project"]
812
+
813
+ for field in fields_to_merge:
814
+ if field not in subfolder_project and field in parent_project:
815
+ missing_fields.append((field, parent_project[field]))
816
+
817
+ # If no missing fields, return as-is
818
+ if not missing_fields:
819
+ return subfolder_content
820
+
821
+ # Add missing fields using string manipulation
822
+ # Find [project] section and add fields after it
823
+ lines = subfolder_content.split("\n")
824
+ result = []
825
+ in_project = False
826
+ project_section_end = -1
827
+
828
+ for i, line in enumerate(lines):
829
+ if line.strip() == "[project]":
830
+ in_project = True
831
+ result.append(line)
832
+ elif line.strip().startswith("[") and in_project:
833
+ # End of [project] section - insert missing fields here
834
+ project_section_end = i
835
+ in_project = False
836
+ # Add missing fields before the next section
837
+ for field_name, field_value in missing_fields:
838
+ if field_name == "urls" and isinstance(field_value, dict):
839
+ # Handle [project.urls] separately
840
+ continue
841
+ # Format the field value appropriately
842
+ formatted = self._format_toml_value(field_name, field_value)
843
+ if formatted:
844
+ result.append(formatted)
845
+ result.append(line)
846
+ else:
847
+ result.append(line)
848
+
849
+ # Handle [project.urls] separately if it exists in parent
850
+ if "project" in parent_data and "urls" in parent_data["project"]:
851
+ urls = parent_data["project"]["urls"]
852
+ if isinstance(urls, dict) and "project.urls" not in subfolder_content:
853
+ # Add [project.urls] section at the end
854
+ result.append("")
855
+ result.append("[project.urls]")
856
+ for key, value in urls.items():
857
+ result.append(f'{key} = "{value}"')
858
+
859
+ return "\n".join(result)
860
+
861
+ except Exception as e:
862
+ print(
863
+ f"Warning: Could not merge from parent pyproject.toml: {e}. Using subfolder content as-is.",
864
+ file=sys.stderr,
865
+ )
866
+ return subfolder_content
867
+
868
+ def _format_toml_value(self, field_name: str, value: any) -> str | None:
869
+ """
870
+ Format a TOML field value as a string.
871
+
872
+ Args:
873
+ field_name: Name of the field
874
+ value: Value to format
875
+
876
+ Returns:
877
+ Formatted string or None if cannot format
878
+ """
879
+ if value is None:
880
+ return None
881
+
882
+ if isinstance(value, str):
883
+ return f'{field_name} = "{value}"'
884
+ elif isinstance(value, list):
885
+ if not value:
886
+ return None
887
+ # Format list items
888
+ if isinstance(value[0], dict):
889
+ # List of dicts (e.g., authors)
890
+ items = []
891
+ for item in value:
892
+ if isinstance(item, dict):
893
+ # Format as inline table: {name = "...", email = "..."}
894
+ parts = [f'{k} = "{v}"' for k, v in item.items() if v]
895
+ items.append("{" + ", ".join(parts) + "}")
896
+ return f"{field_name} = [\n " + ",\n ".join(items) + "\n]"
897
+ else:
898
+ # List of strings
899
+ items = [f'"{v}"' for v in value]
900
+ return f"{field_name} = [\n " + ",\n ".join(items) + "\n]"
901
+ elif isinstance(value, bool):
902
+ return f"{field_name} = {str(value).lower()}"
903
+ elif isinstance(value, (int, float)):
904
+ return f"{field_name} = {value}"
905
+ else:
906
+ return None
907
+
552
908
  def create_temp_pyproject(self) -> Path | None:
553
909
  """
554
910
  Create a temporary pyproject.toml for the subfolder build.
@@ -634,11 +990,64 @@ class SubfolderBuildConfig:
634
990
  # Create temporary pyproject.toml file
635
991
  temp_pyproject_path = self.project_root / "pyproject.toml.temp"
636
992
 
993
+ # Check for name mismatch and warn (but use subfolder name)
994
+ subfolder_name = self._check_and_warn_about_name(subfolder_content)
995
+ if subfolder_name and subfolder_name != self.package_name:
996
+ print(
997
+ f"\nWarning: Package name mismatch in subfolder pyproject.toml",
998
+ file=sys.stderr,
999
+ )
1000
+ print(
1001
+ f" - Name in file: {subfolder_name}",
1002
+ file=sys.stderr,
1003
+ )
1004
+ print(
1005
+ f" - Derived name: {self.package_name}",
1006
+ file=sys.stderr,
1007
+ )
1008
+ print(
1009
+ f" - Using name from subfolder toml: {subfolder_name}\n",
1010
+ file=sys.stderr,
1011
+ )
1012
+ # Update package_name to use subfolder's name
1013
+ self.package_name = subfolder_name
1014
+
1015
+ # Check for dependencies and warn if automatic detection will be skipped
1016
+ self._has_existing_dependencies = self._check_and_warn_about_dependencies(subfolder_content)
1017
+ if self._has_existing_dependencies:
1018
+ print(
1019
+ f"\nWarning: Subfolder pyproject.toml contains a non-empty 'dependencies' field.",
1020
+ file=sys.stderr,
1021
+ )
1022
+ print(
1023
+ f" - Automatic dependency detection will be SKIPPED.",
1024
+ file=sys.stderr,
1025
+ )
1026
+ print(
1027
+ f" - To enable automatic dependency detection, remove or empty the 'dependencies' field in the subfolder's pyproject.toml.\n",
1028
+ file=sys.stderr,
1029
+ )
1030
+
1031
+ # Merge missing fields from parent pyproject.toml if it exists
1032
+ if original_pyproject.exists():
1033
+ try:
1034
+ parent_content = original_pyproject.read_text(encoding="utf-8")
1035
+ subfolder_content = self._merge_from_parent_pyproject(subfolder_content, parent_content)
1036
+ except Exception as e:
1037
+ print(
1038
+ f"Warning: Could not merge from parent pyproject.toml: {e}",
1039
+ file=sys.stderr,
1040
+ )
1041
+
637
1042
  # Adjust packages path to be relative to project root
638
1043
  # This must be called AFTER _create_temp_package_directory() so _get_package_structure()
639
1044
  # can find the temporary directory
640
1045
  adjusted_content = self._adjust_subfolder_pyproject_packages_path(subfolder_content)
641
1046
 
1047
+ # Update version in subfolder pyproject.toml to match calculated version
1048
+ # This ensures the built package version matches what we're trying to publish
1049
+ adjusted_content = self._update_version_in_pyproject(adjusted_content)
1050
+
642
1051
  # Read exclude patterns from root pyproject.toml and inject them (if it exists)
643
1052
  exclude_patterns = []
644
1053
  if original_pyproject.exists():
@@ -1213,6 +1622,14 @@ class SubfolderBuildConfig:
1213
1622
  Args:
1214
1623
  dependencies: List of third-party package names to add (e.g., ["pypdf", "requests"])
1215
1624
  """
1625
+ # Skip if subfolder toml already has dependencies
1626
+ if self._has_existing_dependencies:
1627
+ print(
1628
+ f"Skipping automatic dependency detection - subfolder pyproject.toml already has dependencies defined.",
1629
+ file=sys.stderr,
1630
+ )
1631
+ return
1632
+
1216
1633
  if not self.temp_pyproject or not self.temp_pyproject.exists():
1217
1634
  print(
1218
1635
  f"Warning: Cannot add third-party dependencies - pyproject.toml not found at {self.temp_pyproject}",