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,539 @@
1
+ """
2
+ Build management functionality.
3
+
4
+ This module provides the BuildManager class which orchestrates the entire
5
+ build process: finding external dependencies, copying them temporarily,
6
+ running the build command, and cleaning up afterward.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import sys
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+
16
+ try:
17
+ import tomllib
18
+ except ImportError:
19
+ try:
20
+ import tomli as tomllib
21
+ except ImportError:
22
+ tomllib = None
23
+
24
+ from .analyzer import ImportAnalyzer
25
+ from .finder import ExternalDependencyFinder
26
+ from .subfolder_build import SubfolderBuildConfig
27
+ from .types import ExternalDependency, ImportInfo
28
+
29
+
30
+ class BuildManager:
31
+ """
32
+ Manages the build process with external dependency handling.
33
+
34
+ This is the main class for using the package. It coordinates finding
35
+ external dependencies, copying them into the source directory, running
36
+ the build, and cleaning up.
37
+
38
+ Attributes:
39
+ project_root: Root directory of the project
40
+ src_dir: Source directory containing the package code
41
+ copied_files: List of file paths that were copied (for cleanup)
42
+ copied_dirs: List of directory paths that were copied (for cleanup)
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ project_root: Path,
48
+ src_dir: Path | None = None,
49
+ exclude_patterns: list[str] | None = None,
50
+ ) -> None:
51
+ """
52
+ Initialize the build manager.
53
+
54
+ Args:
55
+ project_root: Root directory of the project
56
+ src_dir: Source directory (defaults to project_root/src, or current dir if it has Python files)
57
+ exclude_patterns: Additional patterns to exclude from copying (e.g., ['_SS', '__sandbox'])
58
+
59
+ Raises:
60
+ ValueError: If the source directory does not exist or is invalid
61
+ """
62
+ from .utils import find_source_directory
63
+
64
+ self.project_root = project_root.resolve()
65
+
66
+ # If src_dir not provided, try to find it intelligently
67
+ if src_dir is None:
68
+ src_dir = find_source_directory(self.project_root)
69
+ if src_dir is None:
70
+ # Fallback to standard src/ directory
71
+ src_dir = self.project_root / "src"
72
+
73
+ self.src_dir = Path(src_dir).resolve()
74
+
75
+ # Validate source directory
76
+ if not self.src_dir.exists():
77
+ raise ValueError(f"Source directory not found: {self.src_dir}")
78
+
79
+ if not self.src_dir.is_dir():
80
+ raise ValueError(f"Source path is not a directory: {self.src_dir}")
81
+
82
+ self.copied_files: list[Path] = []
83
+ self.copied_dirs: list[Path] = []
84
+ self.exclude_patterns = exclude_patterns or []
85
+ self.finder = ExternalDependencyFinder(
86
+ self.project_root, self.src_dir, exclude_patterns=exclude_patterns
87
+ )
88
+
89
+ # Check if it's a valid Python package directory
90
+ if not any(self.src_dir.glob("*.py")) and not (self.src_dir / "__init__.py").exists():
91
+ # Allow empty directories for now, but warn
92
+ pass
93
+
94
+ self.copied_files: list[Path] = []
95
+ self.copied_dirs: list[Path] = []
96
+
97
+ def find_src_package_dir(self) -> Path | None:
98
+ """
99
+ Find the main package directory within src/.
100
+
101
+ Looks for directories with __init__.py files. If multiple are found,
102
+ tries to match one with the project name from pyproject.toml.
103
+
104
+ Returns:
105
+ Path to the main package directory, or src_dir if not found
106
+ """
107
+ if not self.src_dir.exists():
108
+ return None
109
+
110
+ # Look for directories with __init__.py
111
+ package_dirs = [
112
+ d for d in self.src_dir.iterdir() if d.is_dir() and (d / "__init__.py").exists()
113
+ ]
114
+
115
+ if len(package_dirs) == 1:
116
+ return package_dirs[0]
117
+
118
+ # If multiple, try to find the one matching the project name
119
+ project_name = self._get_project_name()
120
+ if project_name:
121
+ for pkg_dir in package_dirs:
122
+ if pkg_dir.name.replace("-", "_") == project_name.replace("-", "_"):
123
+ return pkg_dir
124
+
125
+ # Return the first one or src_dir itself
126
+ return package_dirs[0] if package_dirs else self.src_dir
127
+
128
+ def _get_project_name(self) -> str | None:
129
+ """
130
+ Get the project name from pyproject.toml.
131
+
132
+ Uses tomllib (Python 3.11+) or tomli as fallback to parse the file.
133
+ Falls back to simple string parsing if TOML parsing is unavailable.
134
+
135
+ Returns:
136
+ Project name from pyproject.toml, or None if not found
137
+ """
138
+ pyproject_path = self.project_root / "pyproject.toml"
139
+ if not pyproject_path.exists():
140
+ return None
141
+
142
+ try:
143
+ if tomllib:
144
+ content = pyproject_path.read_bytes()
145
+ data = tomllib.loads(content)
146
+ return data.get("project", {}).get("name")
147
+ else:
148
+ # Fallback: simple parsing
149
+ content = pyproject_path.read_text(encoding="utf-8")
150
+ for line in content.split("\n"):
151
+ if line.strip().startswith("name ="):
152
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
153
+ except Exception:
154
+ pass
155
+
156
+ return None
157
+
158
+ def prepare_build(self) -> list[ExternalDependency]:
159
+ """
160
+ Prepare for build by finding and copying external dependencies.
161
+
162
+ This method:
163
+ 1. Finds all Python files in the source directory
164
+ 2. Analyzes them for external dependencies
165
+ 3. Copies external files/directories into the source directory
166
+ 4. Reports any ambiguous imports
167
+
168
+ Returns:
169
+ List of ExternalDependency objects that were copied
170
+ """
171
+ analyzer = ImportAnalyzer(self.project_root)
172
+
173
+ # Find all Python files in src/
174
+ python_files = analyzer.find_all_python_files(self.src_dir)
175
+
176
+ # Find external dependencies using the configured finder
177
+ external_deps = self.finder.find_external_dependencies(python_files)
178
+
179
+ # Copy external dependencies
180
+ for dep in external_deps:
181
+ self._copy_dependency(dep)
182
+
183
+ # Report ambiguous imports
184
+ self._report_ambiguous_imports(python_files)
185
+
186
+ return external_deps
187
+
188
+ def _copy_dependency(self, dep: ExternalDependency) -> None:
189
+ """
190
+ Copy an external dependency to the target location.
191
+
192
+ Handles both files and directories. Checks for idempotency to avoid
193
+ duplicate copies. Creates parent directories as needed.
194
+
195
+ Args:
196
+ dep: ExternalDependency object with source and target paths
197
+ """
198
+ source = dep.source_path
199
+ target = dep.target_path
200
+
201
+ if not source.exists():
202
+ print(f"Warning: External dependency not found: {source}", file=sys.stderr)
203
+ return
204
+
205
+ # Create target directory if needed
206
+ target.parent.mkdir(parents=True, exist_ok=True)
207
+
208
+ # Check if already copied (idempotency)
209
+ if target.exists():
210
+ # Check if it's the same file
211
+ if source.is_file() and target.is_file():
212
+ try:
213
+ if source.samefile(target):
214
+ return # Already in place, skip
215
+ except OSError:
216
+ # Files are different, proceed with copy
217
+ pass
218
+ elif source.is_dir() and target.is_dir():
219
+ # For directories, check if they have the same structure
220
+ # Simple check: if target has __init__.py and source does too, assume copied
221
+ # Also check if target has any Python files
222
+ source_has_init = (source / "__init__.py").exists()
223
+ target_has_init = (target / "__init__.py").exists()
224
+ if source_has_init == target_has_init:
225
+ # Check if target has at least some files from source
226
+ source_files = {f.name for f in source.rglob("*.py")}
227
+ target_files = {f.name for f in target.rglob("*.py")}
228
+ if source_files and target_files and source_files.issubset(target_files):
229
+ # Assume already copied if structure matches
230
+ return
231
+
232
+ try:
233
+ if source.is_file():
234
+ shutil.copy2(source, target)
235
+ self.copied_files.append(target)
236
+ print(f"Copied external file: {source} -> {target}")
237
+ elif source.is_dir():
238
+ if target.exists():
239
+ shutil.rmtree(target)
240
+ # Use custom copy function that excludes certain patterns
241
+ self._copytree_excluding(source, target)
242
+ self.copied_dirs.append(target)
243
+ print(f"Copied external directory: {source} -> {target}")
244
+ except Exception as e:
245
+ print(f"Error copying {source} to {target}: {e}", file=sys.stderr)
246
+
247
+ def _copytree_excluding(self, src: Path, dst: Path) -> None:
248
+ """
249
+ Copy a directory tree, excluding certain patterns.
250
+
251
+ Excludes directories matching patterns like _SS, __SS, _sandbox, etc.
252
+
253
+ Args:
254
+ src: Source directory
255
+ dst: Destination directory
256
+ """
257
+ default_patterns = [
258
+ "_SS",
259
+ "__SS",
260
+ "_sandbox",
261
+ "__sandbox",
262
+ "_skip",
263
+ "__skip",
264
+ "_test",
265
+ "__test__",
266
+ ]
267
+ exclude_patterns = default_patterns + self.exclude_patterns
268
+
269
+ def should_exclude(path: Path) -> bool:
270
+ """Check if a path should be excluded."""
271
+ # Check each component of the path
272
+ for part in path.parts:
273
+ # Check if any part matches an exclusion pattern
274
+ for pattern in exclude_patterns:
275
+ # Match if part equals pattern or starts with pattern
276
+ if part == pattern or part.startswith(pattern):
277
+ return True
278
+ return False
279
+
280
+ # Create destination directory
281
+ dst.mkdir(parents=True, exist_ok=True)
282
+
283
+ # Copy files and subdirectories, excluding patterns
284
+ for item in src.iterdir():
285
+ if should_exclude(item):
286
+ continue
287
+
288
+ src_item = src / item.name
289
+ dst_item = dst / item.name
290
+
291
+ if src_item.is_file():
292
+ shutil.copy2(src_item, dst_item)
293
+ elif src_item.is_dir():
294
+ self._copytree_excluding(src_item, dst_item)
295
+
296
+ def _report_ambiguous_imports(self, python_files: list[Path]) -> None:
297
+ """
298
+ Report any ambiguous imports that couldn't be resolved.
299
+
300
+ Prints warnings to stderr for imports that couldn't be classified
301
+ as stdlib, third-party, local, or external.
302
+
303
+ Args:
304
+ python_files: List of Python files to check for ambiguous imports
305
+ """
306
+ analyzer = ImportAnalyzer(self.project_root)
307
+ ambiguous: list[ImportInfo] = []
308
+
309
+ for file_path in python_files:
310
+ imports = analyzer.extract_imports(file_path)
311
+ for imp in imports:
312
+ analyzer.classify_import(imp, self.src_dir)
313
+ if imp.classification == "ambiguous":
314
+ ambiguous.append(imp)
315
+
316
+ if ambiguous:
317
+ print("\nWarning: Found ambiguous imports that could not be resolved:", file=sys.stderr)
318
+ for imp in ambiguous:
319
+ print(
320
+ f" {imp.module_name} (line {imp.line_number} in {imp.file_path})",
321
+ file=sys.stderr,
322
+ )
323
+
324
+ def cleanup(self) -> None:
325
+ """
326
+ Remove all copied files and directories.
327
+
328
+ This method removes all files and directories that were copied
329
+ during prepare_build(). It handles errors gracefully and clears
330
+ the internal tracking lists.
331
+ """
332
+ # Remove copied directories first (they may contain files)
333
+ for dir_path in reversed(self.copied_dirs):
334
+ if dir_path.exists():
335
+ try:
336
+ shutil.rmtree(dir_path)
337
+ print(f"Removed copied directory: {dir_path}")
338
+ except Exception as e:
339
+ print(f"Error removing {dir_path}: {e}", file=sys.stderr)
340
+
341
+ # Remove copied files
342
+ for file_path in self.copied_files:
343
+ if file_path.exists():
344
+ try:
345
+ file_path.unlink()
346
+ print(f"Removed copied file: {file_path}")
347
+ except Exception as e:
348
+ print(f"Error removing {file_path}: {e}", file=sys.stderr)
349
+
350
+ self.copied_files.clear()
351
+ self.copied_dirs.clear()
352
+
353
+ def run_build(self, build_command: Callable[[], None]) -> None:
354
+ """
355
+ Run the build process with dependency management.
356
+
357
+ This is a convenience method that:
358
+ 1. Calls prepare_build() to find and copy dependencies
359
+ 2. Executes the provided build_command
360
+ 3. Always calls cleanup() afterward, even if build fails
361
+
362
+ Args:
363
+ build_command: Callable that executes the build process
364
+
365
+ Example:
366
+ ```python
367
+ def build():
368
+ subprocess.run(["uv", "build"], check=True)
369
+
370
+ manager.run_build(build)
371
+ ```
372
+ """
373
+ try:
374
+ print("Analyzing project for external dependencies...")
375
+ external_deps = self.prepare_build()
376
+
377
+ if external_deps:
378
+ print(f"\nFound {len(external_deps)} external dependencies")
379
+ print("Copied external dependencies into source directory\n")
380
+ else:
381
+ print("No external dependencies found\n")
382
+
383
+ print("Running build...")
384
+ # Build command should run from project root to find pyproject.toml
385
+ import os
386
+
387
+ original_cwd = os.getcwd()
388
+ try:
389
+ os.chdir(self.project_root)
390
+ build_command()
391
+ finally:
392
+ os.chdir(original_cwd)
393
+
394
+ finally:
395
+ print("\nCleaning up copied files...")
396
+ self.cleanup()
397
+
398
+ def build_and_publish(
399
+ self,
400
+ build_command: Callable[[], None],
401
+ repository: str | None = None,
402
+ repository_url: str | None = None,
403
+ username: str | None = None,
404
+ password: str | None = None,
405
+ skip_existing: bool = False,
406
+ version: str | None = None,
407
+ restore_versioning: bool = True,
408
+ package_name: str | None = None,
409
+ dependency_group: str | None = None,
410
+ ) -> None:
411
+ """
412
+ Build and publish the package in one operation.
413
+
414
+ This method combines build and publish operations:
415
+ 1. Sets version if specified
416
+ 2. Prepares external dependencies
417
+ 3. Runs the build command
418
+ 4. Publishes to the specified repository
419
+ 5. Cleans up copied files
420
+ 6. Restores versioning configuration if requested
421
+
422
+ Args:
423
+ build_command: Callable that executes the build process
424
+ repository: Target repository ('pypi', 'testpypi', or 'azure')
425
+ repository_url: Custom repository URL (required for Azure)
426
+ username: Username for publishing (will prompt if not provided)
427
+ password: Password/token for publishing (will prompt if not provided)
428
+ skip_existing: If True, skip files that already exist on the repository
429
+ version: Manual version to set before building (PEP 440 format)
430
+ restore_versioning: If True, restore dynamic versioning after build
431
+ package_name: Package name for subfolder builds (default: derived from src_dir name)
432
+ dependency_group: Name of dependency group to copy from parent pyproject.toml
433
+
434
+ Example:
435
+ ```python
436
+ def build():
437
+ subprocess.run(["uv", "build"], check=True)
438
+
439
+ manager.build_and_publish(build, repository="pypi", version="1.2.3")
440
+ ```
441
+ """
442
+ from .publisher import Publisher
443
+ from .version import VersionManager
444
+
445
+ version_manager = None
446
+ original_version = None
447
+ subfolder_config = None
448
+
449
+ # Check if we're building a subfolder (not the main src/ directory)
450
+ is_subfolder_build = not self.src_dir.is_relative_to(self.project_root / "src") or (
451
+ self.src_dir != self.project_root / "src" and self.src_dir != self.project_root
452
+ )
453
+
454
+ try:
455
+ # For subfolder builds, create a temporary pyproject.toml
456
+ if is_subfolder_build and version:
457
+ if not package_name:
458
+ # Derive package name from subfolder
459
+ package_name = (
460
+ self.src_dir.name.replace("_", "-").replace(" ", "-").lower().strip("-")
461
+ )
462
+ print(f"Building subfolder as package '{package_name}' version '{version}'...")
463
+ subfolder_config = SubfolderBuildConfig(
464
+ project_root=self.project_root,
465
+ src_dir=self.src_dir,
466
+ package_name=package_name,
467
+ version=version,
468
+ dependency_group=dependency_group,
469
+ )
470
+ subfolder_config.create_temp_pyproject()
471
+ elif version:
472
+ # Regular build with version override
473
+ version_manager = VersionManager(self.project_root)
474
+ original_version = version_manager.get_current_version()
475
+ print(f"Setting version to {version}...")
476
+ version_manager.set_version(version)
477
+
478
+ # Build the package
479
+ self.run_build(build_command)
480
+
481
+ # Publish if repository is specified
482
+ if repository:
483
+ # Determine package name and version for filtering
484
+ publish_package_name = None
485
+ publish_version = version
486
+
487
+ if is_subfolder_build and package_name:
488
+ publish_package_name = package_name
489
+ elif not is_subfolder_build:
490
+ # For regular builds, get package name from pyproject.toml
491
+ try:
492
+ import tomllib
493
+ except ImportError:
494
+ try:
495
+ import tomli as tomllib
496
+ except ImportError:
497
+ tomllib = None
498
+
499
+ if tomllib:
500
+ pyproject_path = self.project_root / "pyproject.toml"
501
+ if pyproject_path.exists():
502
+ with pyproject_path.open("rb") as f:
503
+ data = tomllib.load(f)
504
+ if "project" in data and "name" in data["project"]:
505
+ publish_package_name = data["project"]["name"]
506
+
507
+ publisher = Publisher(
508
+ repository=repository,
509
+ dist_dir=self.project_root / "dist",
510
+ repository_url=repository_url,
511
+ username=username,
512
+ password=password,
513
+ package_name=publish_package_name,
514
+ version=publish_version,
515
+ )
516
+ publisher.publish(skip_existing=skip_existing)
517
+ finally:
518
+ # Restore subfolder config if used
519
+ if subfolder_config and restore_versioning:
520
+ try:
521
+ subfolder_config.restore()
522
+ print("Restored original pyproject.toml")
523
+ except Exception as e:
524
+ print(f"Warning: Could not restore pyproject.toml: {e}", file=sys.stderr)
525
+
526
+ # Restore versioning if needed
527
+ if version_manager and restore_versioning:
528
+ try:
529
+ if original_version:
530
+ version_manager.set_version(original_version)
531
+ else:
532
+ version_manager.restore_dynamic_versioning()
533
+ print("Restored versioning configuration")
534
+ except Exception as e:
535
+ print(f"Warning: Could not restore versioning: {e}", file=sys.stderr)
536
+
537
+ # Cleanup is already handled by run_build, but ensure it's done
538
+ if self.copied_files or self.copied_dirs:
539
+ self.cleanup()