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.
- python_package_folder/__init__.py +24 -0
- python_package_folder/__main__.py +13 -0
- python_package_folder/analyzer.py +313 -0
- python_package_folder/finder.py +234 -0
- python_package_folder/manager.py +539 -0
- python_package_folder/publisher.py +310 -0
- python_package_folder/py.typed +0 -0
- python_package_folder/python_package_folder.py +239 -0
- python_package_folder/subfolder_build.py +477 -0
- python_package_folder/types.py +66 -0
- python_package_folder/utils.py +106 -0
- python_package_folder/version.py +253 -0
- python_package_folder-1.1.3.dist-info/METADATA +795 -0
- python_package_folder-1.1.3.dist-info/RECORD +17 -0
- python_package_folder-1.1.3.dist-info/WHEEL +4 -0
- python_package_folder-1.1.3.dist-info/entry_points.txt +2 -0
- python_package_folder-1.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|