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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,477 @@
1
+ """
2
+ Subfolder build configuration management.
3
+
4
+ This module handles creating temporary build configurations for subfolders
5
+ that need to be built as separate packages with their own names and versions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shutil
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from typing import Self
18
+
19
+ try:
20
+ import tomllib
21
+ except ImportError:
22
+ try:
23
+ import tomli as tomllib
24
+ except ImportError:
25
+ tomllib = None
26
+
27
+
28
+ class SubfolderBuildConfig:
29
+ """
30
+ Manages temporary build configuration for subfolder builds.
31
+
32
+ When building a subfolder as a separate package, this class creates
33
+ a temporary pyproject.toml with the appropriate package name and version.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ project_root: Path,
39
+ src_dir: Path,
40
+ package_name: str | None = None,
41
+ version: str | None = None,
42
+ dependency_group: str | None = None,
43
+ ) -> None:
44
+ """
45
+ Initialize subfolder build configuration.
46
+
47
+ Args:
48
+ project_root: Root directory containing the main pyproject.toml
49
+ src_dir: Source directory being built (subfolder)
50
+ package_name: Name for the subfolder package (default: derived from src_dir name)
51
+ version: Version for the subfolder package (required if building subfolder)
52
+ dependency_group: Name of dependency group to copy from parent pyproject.toml
53
+ """
54
+ self.project_root = project_root.resolve()
55
+ self.src_dir = src_dir.resolve()
56
+ self.package_name = package_name or self._derive_package_name()
57
+ self.version = version
58
+ self.dependency_group = dependency_group
59
+ self.temp_pyproject: Path | None = None
60
+ self.original_pyproject_backup: Path | None = None
61
+ self._temp_init_created = False
62
+ self.temp_readme: Path | None = None
63
+ self.original_readme_backup: Path | None = None
64
+
65
+ def _derive_package_name(self) -> str:
66
+ """Derive package name from source directory name."""
67
+ # Use the directory name, replacing invalid characters
68
+ name = self.src_dir.name
69
+ # Replace invalid characters with hyphens
70
+ name = name.replace("_", "-").replace(" ", "-").lower()
71
+ # Remove any leading/trailing hyphens
72
+ name = name.strip("-")
73
+ return name
74
+
75
+ def _get_package_structure(self) -> tuple[str, list[str]]:
76
+ """
77
+ Determine the package structure for hatchling.
78
+
79
+ Returns:
80
+ Tuple of (packages_path, package_dirs) where:
81
+ - packages_path: The path to the directory containing packages
82
+ - package_dirs: List of package directories to include
83
+ """
84
+ # Check if src_dir itself is a package (has __init__.py)
85
+ has_init = (self.src_dir / "__init__.py").exists()
86
+
87
+ # Check for Python files directly in src_dir
88
+ py_files = list(self.src_dir.glob("*.py"))
89
+ has_py_files = bool(py_files)
90
+
91
+ # Calculate relative path
92
+ try:
93
+ rel_path = self.src_dir.relative_to(self.project_root)
94
+ packages_path = str(rel_path).replace("\\", "/")
95
+ except ValueError:
96
+ packages_path = None
97
+
98
+ # If src_dir has Python files but no __init__.py, we need to make it a package
99
+ # or include it as a module directory
100
+ if has_py_files and not has_init:
101
+ # For flat structures, we include the directory itself
102
+ # Hatchling will treat Python files in the directory as modules
103
+ return packages_path, [packages_path] if packages_path else []
104
+
105
+ # If it's a package or has subpackages, return the path
106
+ return packages_path, [packages_path] if packages_path else []
107
+
108
+ def create_temp_pyproject(self) -> Path:
109
+ """
110
+ Create a temporary pyproject.toml for the subfolder build.
111
+
112
+ This creates a pyproject.toml in the project root that overrides
113
+ the package name and version for building the subfolder.
114
+
115
+ Returns:
116
+ Path to the temporary pyproject.toml file
117
+ """
118
+ if not self.version:
119
+ raise ValueError("Version is required for subfolder builds")
120
+
121
+ # Ensure src_dir is a package (has __init__.py) for hatchling
122
+ init_file = self.src_dir / "__init__.py"
123
+ if not init_file.exists():
124
+ # Create a temporary __init__.py to make it a package
125
+ init_file.write_text("# Temporary __init__.py for build\n", encoding="utf-8")
126
+ self._temp_init_created = True
127
+ else:
128
+ self._temp_init_created = False
129
+
130
+ # Read the original pyproject.toml
131
+ original_pyproject = self.project_root / "pyproject.toml"
132
+ if not original_pyproject.exists():
133
+ raise FileNotFoundError(f"pyproject.toml not found: {original_pyproject}")
134
+
135
+ original_content = original_pyproject.read_text(encoding="utf-8")
136
+
137
+ # Create a backup
138
+ backup_path = self.project_root / "pyproject.toml.backup"
139
+ shutil.copy2(original_pyproject, backup_path)
140
+ self.original_pyproject_backup = backup_path
141
+
142
+ # Parse and modify the pyproject.toml
143
+ if tomllib:
144
+ try:
145
+ data = tomllib.loads(original_content.encode())
146
+ except Exception:
147
+ # Fallback to string manipulation if parsing fails
148
+ data = None
149
+ else:
150
+ data = None
151
+
152
+ # Extract dependency group from parent if specified
153
+ parent_dependency_group = None
154
+ if data and self.dependency_group and "dependency-groups" in data:
155
+ if self.dependency_group in data["dependency-groups"]:
156
+ parent_dependency_group = {
157
+ self.dependency_group: data["dependency-groups"][self.dependency_group]
158
+ }
159
+ else:
160
+ print(
161
+ f"Warning: Dependency group '{self.dependency_group}' not found in parent pyproject.toml",
162
+ file=sys.stderr,
163
+ )
164
+
165
+ if data:
166
+ # Modify using parsed data
167
+ if "project" in data:
168
+ data["project"]["name"] = self.package_name
169
+ if "version" in data["project"]:
170
+ data["project"]["version"] = self.version
171
+ elif "dynamic" in data["project"]:
172
+ # Remove version from dynamic and set it
173
+ if "version" in data["project"]["dynamic"]:
174
+ data["project"]["dynamic"].remove("version")
175
+ data["project"]["version"] = self.version
176
+
177
+ # Add dependency group if specified
178
+ if parent_dependency_group:
179
+ if "dependency-groups" not in data:
180
+ data["dependency-groups"] = {}
181
+ # Add the specified dependency group from parent
182
+ data["dependency-groups"].update(parent_dependency_group)
183
+
184
+ # For now, use string manipulation (tomli-w not in stdlib)
185
+ modified_content = self._modify_pyproject_string(
186
+ original_content, parent_dependency_group
187
+ )
188
+ else:
189
+ # Use string manipulation
190
+ modified_content = self._modify_pyproject_string(
191
+ original_content, parent_dependency_group
192
+ )
193
+
194
+ # Write the modified content
195
+ original_pyproject.write_text(modified_content, encoding="utf-8")
196
+ self.temp_pyproject = original_pyproject
197
+
198
+ # Handle README file
199
+ self._handle_readme()
200
+
201
+ return original_pyproject
202
+
203
+ def _modify_pyproject_string(
204
+ self, content: str, dependency_group: dict[str, list[str]] | None = None
205
+ ) -> str:
206
+ """Modify pyproject.toml content using string manipulation."""
207
+ lines = content.split("\n")
208
+ result = []
209
+ in_project = False
210
+ name_set = False
211
+ version_set = False
212
+ in_dynamic = False
213
+ skip_hatch_version = False
214
+ skip_uv_dynamic = False
215
+ in_hatch_build = False
216
+ packages_set = False
217
+
218
+ # Get package structure
219
+ packages_path, package_dirs = self._get_package_structure()
220
+ if not package_dirs:
221
+ package_dirs = []
222
+
223
+ for _i, line in enumerate(lines):
224
+ # Skip hatch versioning and uv-dynamic-versioning sections
225
+ if line.strip().startswith("[tool.hatch.version]"):
226
+ skip_hatch_version = True
227
+ continue
228
+ elif line.strip().startswith("[tool.uv-dynamic-versioning]"):
229
+ skip_uv_dynamic = True
230
+ continue
231
+ elif skip_hatch_version and line.strip().startswith("["):
232
+ skip_hatch_version = False
233
+ elif skip_uv_dynamic and line.strip().startswith("["):
234
+ skip_uv_dynamic = False
235
+
236
+ if skip_hatch_version or skip_uv_dynamic:
237
+ continue
238
+
239
+ # Handle hatch build targets
240
+ if line.strip().startswith("[tool.hatch.build.targets.wheel]"):
241
+ in_hatch_build = True
242
+ result.append(line)
243
+ continue
244
+ elif line.strip().startswith("[") and in_hatch_build:
245
+ # End of hatch build section, add packages if not set
246
+ if not packages_set and package_dirs:
247
+ packages_str = ", ".join(f'"{p}"' for p in package_dirs)
248
+ result.append(f"packages = [{packages_str}]")
249
+ in_hatch_build = False
250
+ result.append(line)
251
+ elif in_hatch_build:
252
+ # Modify packages path
253
+ if re.match(r"^\s*packages\s*=", line):
254
+ if package_dirs:
255
+ packages_str = ", ".join(f'"{p}"' for p in package_dirs)
256
+ result.append(f"packages = [{packages_str}]")
257
+ else:
258
+ result.append(line)
259
+ packages_set = True
260
+ continue
261
+ # Keep other lines in hatch build section
262
+ result.append(line)
263
+
264
+ elif line.strip().startswith("[project]"):
265
+ in_project = True
266
+ result.append(line)
267
+ elif line.strip().startswith("[") and in_project:
268
+ # End of [project] section
269
+ if not name_set:
270
+ result.append(f'name = "{self.package_name}"')
271
+ if not version_set:
272
+ result.append(f'version = "{self.version}"')
273
+ in_project = False
274
+ result.append(line)
275
+ elif in_project:
276
+ # Modify name
277
+ if re.match(r"^\s*name\s*=", line):
278
+ result.append(f'name = "{self.package_name}"')
279
+ name_set = True
280
+ continue
281
+ # Modify version
282
+ elif re.match(r"^\s*version\s*=", line):
283
+ result.append(f'version = "{self.version}"')
284
+ version_set = True
285
+ continue
286
+ # Remove version from dynamic
287
+ elif re.match(r"^\s*dynamic\s*=\s*\[", line):
288
+ in_dynamic = True
289
+ # Remove "version" from the list
290
+ line = re.sub(r'"version"', "", line)
291
+ line = re.sub(r"'version'", "", line)
292
+ line = re.sub(r",\s*,", ",", line)
293
+ line = re.sub(r"\[\s*,", "[", line)
294
+ line = re.sub(r",\s*\]", "]", line)
295
+ if re.match(r"^\s*dynamic\s*=\s*\[\s*\]", line):
296
+ continue # Skip empty dynamic list
297
+ elif in_dynamic and "]" in line:
298
+ in_dynamic = False
299
+ # Remove version from the closing bracket line if present
300
+ line = re.sub(r'"version"', "", line)
301
+ line = re.sub(r"'version'", "", line)
302
+
303
+ result.append(line)
304
+ else:
305
+ result.append(line)
306
+
307
+ # Add name and version if not set (still in project section)
308
+ if in_project:
309
+ if not name_set:
310
+ result.append(f'name = "{self.package_name}"')
311
+ if not version_set:
312
+ result.append(f'version = "{self.version}"')
313
+
314
+ # Add packages configuration if not set
315
+ if in_hatch_build and not packages_set and package_dirs:
316
+ packages_str = ", ".join(f'"{p}"' for p in package_dirs)
317
+ result.append(f"packages = [{packages_str}]")
318
+
319
+ # Ensure packages is always set for subfolder builds
320
+ if not packages_set and package_dirs:
321
+ # Add the section if it doesn't exist
322
+ if "[tool.hatch.build.targets.wheel]" not in "\n".join(result):
323
+ result.append("")
324
+ result.append("[tool.hatch.build.targets.wheel]")
325
+ packages_str = ", ".join(f'"{p}"' for p in package_dirs)
326
+ result.append(f"packages = [{packages_str}]")
327
+
328
+ # Add dependency group if specified
329
+ if dependency_group:
330
+ # Find where to insert dependency-groups section
331
+ # Usually after [project] section or at the end
332
+ insert_index = len(result)
333
+ for i, line in enumerate(result):
334
+ if line.strip().startswith("[dependency-groups]"):
335
+ # Update existing dependency-groups section
336
+ insert_index = i
337
+ break
338
+ elif line.strip().startswith("[") and i > 0:
339
+ # Insert before the last section (usually before [tool.*] sections)
340
+ if not line.strip().startswith("[tool."):
341
+ insert_index = i
342
+ break
343
+
344
+ # Format dependency group
345
+ if insert_index < len(result) and result[insert_index].strip().startswith(
346
+ "[dependency-groups]"
347
+ ):
348
+ # Replace existing section
349
+ dep_lines = ["[dependency-groups]"]
350
+ for group_name, deps in dependency_group.items():
351
+ dep_lines.append(f"{group_name} = [")
352
+ for dep in deps:
353
+ dep_lines.append(f' "{dep}",')
354
+ dep_lines.append("]")
355
+ dep_lines.append("")
356
+
357
+ # Find end of existing dependency-groups section
358
+ end_index = insert_index + 1
359
+ while end_index < len(result) and not result[end_index].strip().startswith("["):
360
+ end_index += 1
361
+
362
+ result[insert_index:end_index] = dep_lines
363
+ else:
364
+ # Insert new section
365
+ dep_lines = ["", "[dependency-groups]"]
366
+ for group_name, deps in dependency_group.items():
367
+ dep_lines.append(f"{group_name} = [")
368
+ for dep in deps:
369
+ dep_lines.append(f' "{dep}",')
370
+ dep_lines.append("]")
371
+ result[insert_index:insert_index] = dep_lines
372
+
373
+ return "\n".join(result)
374
+
375
+ def _handle_readme(self) -> None:
376
+ """
377
+ Handle README file for subfolder builds.
378
+
379
+ - If README exists in subfolder, copy it to project root
380
+ - If no README exists, create a minimal one with folder name
381
+ - Backup original README if it exists in project root
382
+ """
383
+ # Common README file names
384
+ readme_names = ["README.md", "README.rst", "README.txt", "README"]
385
+
386
+ # Check for README in subfolder
387
+ subfolder_readme = None
388
+ for name in readme_names:
389
+ readme_path = self.src_dir / name
390
+ if readme_path.exists():
391
+ subfolder_readme = readme_path
392
+ break
393
+
394
+ # Check for existing README in project root
395
+ project_readme = None
396
+ for name in readme_names:
397
+ readme_path = self.project_root / name
398
+ if readme_path.exists():
399
+ project_readme = readme_path
400
+ break
401
+
402
+ # Backup original README if it exists
403
+ if project_readme:
404
+ backup_path = self.project_root / f"{project_readme.name}.backup"
405
+ shutil.copy2(project_readme, backup_path)
406
+ self.original_readme_backup = backup_path
407
+
408
+ # Use subfolder README if it exists
409
+ if subfolder_readme:
410
+ # Copy subfolder README to project root
411
+ target_readme = self.project_root / subfolder_readme.name
412
+ shutil.copy2(subfolder_readme, target_readme)
413
+ self.temp_readme = target_readme
414
+ else:
415
+ # Create minimal README with folder name
416
+ readme_content = f"# {self.src_dir.name}\n"
417
+ target_readme = self.project_root / "README.md"
418
+ target_readme.write_text(readme_content, encoding="utf-8")
419
+ self.temp_readme = target_readme
420
+
421
+ def restore(self) -> None:
422
+ """Restore the original pyproject.toml and remove temporary __init__.py if created."""
423
+ # Remove temporary __init__.py if we created it
424
+ if self._temp_init_created:
425
+ init_file = self.src_dir / "__init__.py"
426
+ if init_file.exists():
427
+ try:
428
+ init_file.unlink()
429
+ except Exception:
430
+ pass # Ignore errors during cleanup
431
+ self._temp_init_created = False
432
+
433
+ # Restore original README if it was backed up
434
+ backup_path = self.original_readme_backup
435
+ had_backup = backup_path and backup_path.exists()
436
+ original_readme_path = None
437
+ if had_backup:
438
+ original_readme_name = backup_path.stem # Get name without .backup extension
439
+ original_readme_path = self.project_root / original_readme_name
440
+ shutil.copy2(backup_path, original_readme_path)
441
+ backup_path.unlink()
442
+ self.original_readme_backup = None
443
+
444
+ # Remove temporary README if we created it or copied from subfolder
445
+ # Only remove if it's different from the original we just restored
446
+ if self.temp_readme and self.temp_readme.exists():
447
+ # If we restored an original README and the temp is the same file, don't remove it
448
+ if (
449
+ had_backup
450
+ and original_readme_path
451
+ and self.temp_readme.samefile(original_readme_path)
452
+ ):
453
+ # Temp README is the same as the restored original, so don't remove it
454
+ pass
455
+ else:
456
+ # Remove the temp README (either no original existed, or it's a different file)
457
+ try:
458
+ self.temp_readme.unlink()
459
+ except Exception:
460
+ pass # Ignore errors during cleanup
461
+ self.temp_readme = None
462
+
463
+ # Restore original pyproject.toml
464
+ if self.original_pyproject_backup and self.original_pyproject_backup.exists():
465
+ original_pyproject = self.project_root / "pyproject.toml"
466
+ shutil.copy2(self.original_pyproject_backup, original_pyproject)
467
+ self.original_pyproject_backup.unlink()
468
+ self.original_pyproject_backup = None
469
+ self.temp_pyproject = None
470
+
471
+ def __enter__(self) -> Self:
472
+ """Context manager entry."""
473
+ return self
474
+
475
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # noqa: ARG002
476
+ """Context manager exit - always restore."""
477
+ self.restore()
@@ -0,0 +1,66 @@
1
+ """
2
+ Type definitions for the package.
3
+
4
+ This module contains the core data structures used throughout the package
5
+ for representing import information and external dependencies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+
15
+ @dataclass
16
+ class ImportInfo:
17
+ """
18
+ Information about a detected import statement.
19
+
20
+ This class represents a single import statement found in a Python file,
21
+ including its classification and resolved file path.
22
+
23
+ Attributes:
24
+ module_name: The name of the module being imported (e.g., "os", "my_module.utils")
25
+ import_type: Type of import - either "import" or "from"
26
+ from_module: For "from" imports, the module name (same as module_name)
27
+ line_number: Line number where the import appears in the source file
28
+ file_path: Path to the file containing this import
29
+ classification: Classification result - one of:
30
+ - "stdlib": Standard library module
31
+ - "third_party": Third-party package from site-packages
32
+ - "local": Module within the source directory
33
+ - "external": Module outside source directory but in the project
34
+ - "ambiguous": Cannot be resolved
35
+ resolved_path: Resolved file path for local/external imports, None otherwise
36
+ """
37
+
38
+ module_name: str
39
+ import_type: Literal["import", "from"]
40
+ from_module: str | None = None
41
+ line_number: int = 0
42
+ file_path: Path | None = None
43
+ classification: Literal["stdlib", "third_party", "local", "external", "ambiguous"] | None = None
44
+ resolved_path: Path | None = None
45
+
46
+
47
+ @dataclass
48
+ class ExternalDependency:
49
+ """
50
+ Information about an external dependency that needs to be copied.
51
+
52
+ This class represents a file or directory that is imported from outside
53
+ the source directory and needs to be temporarily copied into the source
54
+ directory during the build process.
55
+
56
+ Attributes:
57
+ source_path: Original location of the dependency (outside src_dir)
58
+ target_path: Destination path within src_dir where it will be copied
59
+ import_name: The module name used in the import statement
60
+ file_path: Path to the file that contains the import statement
61
+ """
62
+
63
+ source_path: Path
64
+ target_path: Path
65
+ import_name: str
66
+ file_path: Path
@@ -0,0 +1,106 @@
1
+ """
2
+ Utility functions for project discovery and path resolution.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+
10
+ def find_project_root(start_path: Path | None = None) -> Path | None:
11
+ """
12
+ Find the project root by searching for pyproject.toml in parent directories.
13
+
14
+ Starts from the given path (or current directory) and walks up the directory
15
+ tree until it finds a directory containing pyproject.toml.
16
+
17
+ Args:
18
+ start_path: Starting directory for the search (default: current directory)
19
+
20
+ Returns:
21
+ Path to the project root directory, or None if not found
22
+ """
23
+ if start_path is None:
24
+ start_path = Path.cwd()
25
+
26
+ current = Path(start_path).resolve()
27
+
28
+ # Walk up the directory tree
29
+ while current != current.parent:
30
+ pyproject_path = current / "pyproject.toml"
31
+ if pyproject_path.exists():
32
+ return current
33
+ current = current.parent
34
+
35
+ # Check the root directory itself
36
+ if (current / "pyproject.toml").exists():
37
+ return current
38
+
39
+ return None
40
+
41
+
42
+ def find_source_directory(project_root: Path, current_dir: Path | None = None) -> Path | None:
43
+ """
44
+ Find the appropriate source directory for building.
45
+
46
+ Priority:
47
+ 1. If current_dir is provided and contains Python files, use it
48
+ 2. If project_root/src exists, use it
49
+ 3. If project_root contains Python files directly, use project_root
50
+ 4. Return None if nothing suitable is found
51
+
52
+ Args:
53
+ project_root: Root directory of the project
54
+ current_dir: Current working directory (default: cwd)
55
+
56
+ Returns:
57
+ Path to the source directory, or None if not found
58
+ """
59
+ if current_dir is None:
60
+ current_dir = Path.cwd()
61
+
62
+ current_dir = current_dir.resolve()
63
+ project_root = project_root.resolve()
64
+
65
+ # Check if current directory is a subdirectory with Python files
66
+ if current_dir.is_relative_to(project_root) or current_dir == project_root:
67
+ python_files = list(current_dir.glob("*.py"))
68
+ if python_files:
69
+ # Current directory has Python files, use it as source
70
+ return current_dir
71
+
72
+ # Check for standard src/ directory
73
+ src_dir = project_root / "src"
74
+ if src_dir.exists() and src_dir.is_dir():
75
+ return src_dir
76
+
77
+ # Check if project_root itself has Python files
78
+ python_files = list(project_root.glob("*.py"))
79
+ if python_files:
80
+ return project_root
81
+
82
+ return None
83
+
84
+
85
+ def is_python_package_directory(path: Path) -> bool:
86
+ """
87
+ Check if a directory contains Python package files.
88
+
89
+ Args:
90
+ path: Directory to check
91
+
92
+ Returns:
93
+ True if the directory contains .py files or __init__.py
94
+ """
95
+ if not path.exists() or not path.is_dir():
96
+ return False
97
+
98
+ # Check for Python files
99
+ if any(path.glob("*.py")):
100
+ return True
101
+
102
+ # Check for __init__.py
103
+ if (path / "__init__.py").exists():
104
+ return True
105
+
106
+ return False