python-package-folder 0.1.0__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,388 @@
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 .types import ExternalDependency, ImportInfo
27
+
28
+
29
+ class BuildManager:
30
+ """
31
+ Manages the build process with external dependency handling.
32
+
33
+ This is the main class for using the package. It coordinates finding
34
+ external dependencies, copying them into the source directory, running
35
+ the build, and cleaning up.
36
+
37
+ Attributes:
38
+ project_root: Root directory of the project
39
+ src_dir: Source directory containing the package code
40
+ copied_files: List of file paths that were copied (for cleanup)
41
+ copied_dirs: List of directory paths that were copied (for cleanup)
42
+ """
43
+
44
+ def __init__(self, project_root: Path, src_dir: Path | None = None) -> None:
45
+ """
46
+ Initialize the build manager.
47
+
48
+ Args:
49
+ project_root: Root directory of the project
50
+ src_dir: Source directory (defaults to project_root/src)
51
+
52
+ Raises:
53
+ ValueError: If the source directory does not exist
54
+ """
55
+ self.project_root = project_root.resolve()
56
+ self.src_dir = src_dir or (self.project_root / "src")
57
+ if not self.src_dir.exists():
58
+ raise ValueError(f"Source directory not found: {self.src_dir}")
59
+
60
+ self.copied_files: list[Path] = []
61
+ self.copied_dirs: list[Path] = []
62
+
63
+ def find_src_package_dir(self) -> Path | None:
64
+ """
65
+ Find the main package directory within src/.
66
+
67
+ Looks for directories with __init__.py files. If multiple are found,
68
+ tries to match one with the project name from pyproject.toml.
69
+
70
+ Returns:
71
+ Path to the main package directory, or src_dir if not found
72
+ """
73
+ if not self.src_dir.exists():
74
+ return None
75
+
76
+ # Look for directories with __init__.py
77
+ package_dirs = [
78
+ d for d in self.src_dir.iterdir() if d.is_dir() and (d / "__init__.py").exists()
79
+ ]
80
+
81
+ if len(package_dirs) == 1:
82
+ return package_dirs[0]
83
+
84
+ # If multiple, try to find the one matching the project name
85
+ project_name = self._get_project_name()
86
+ if project_name:
87
+ for pkg_dir in package_dirs:
88
+ if pkg_dir.name.replace("-", "_") == project_name.replace("-", "_"):
89
+ return pkg_dir
90
+
91
+ # Return the first one or src_dir itself
92
+ return package_dirs[0] if package_dirs else self.src_dir
93
+
94
+ def _get_project_name(self) -> str | None:
95
+ """
96
+ Get the project name from pyproject.toml.
97
+
98
+ Uses tomllib (Python 3.11+) or tomli as fallback to parse the file.
99
+ Falls back to simple string parsing if TOML parsing is unavailable.
100
+
101
+ Returns:
102
+ Project name from pyproject.toml, or None if not found
103
+ """
104
+ pyproject_path = self.project_root / "pyproject.toml"
105
+ if not pyproject_path.exists():
106
+ return None
107
+
108
+ try:
109
+ if tomllib:
110
+ content = pyproject_path.read_bytes()
111
+ data = tomllib.loads(content)
112
+ return data.get("project", {}).get("name")
113
+ else:
114
+ # Fallback: simple parsing
115
+ content = pyproject_path.read_text(encoding="utf-8")
116
+ for line in content.split("\n"):
117
+ if line.strip().startswith("name ="):
118
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
119
+ except Exception:
120
+ pass
121
+
122
+ return None
123
+
124
+ def prepare_build(self) -> list[ExternalDependency]:
125
+ """
126
+ Prepare for build by finding and copying external dependencies.
127
+
128
+ This method:
129
+ 1. Finds all Python files in the source directory
130
+ 2. Analyzes them for external dependencies
131
+ 3. Copies external files/directories into the source directory
132
+ 4. Reports any ambiguous imports
133
+
134
+ Returns:
135
+ List of ExternalDependency objects that were copied
136
+ """
137
+ analyzer = ImportAnalyzer(self.project_root)
138
+ finder = ExternalDependencyFinder(self.project_root, self.src_dir)
139
+
140
+ # Find all Python files in src/
141
+ python_files = analyzer.find_all_python_files(self.src_dir)
142
+
143
+ # Find external dependencies
144
+ external_deps = finder.find_external_dependencies(python_files)
145
+
146
+ # Copy external dependencies
147
+ for dep in external_deps:
148
+ self._copy_dependency(dep)
149
+
150
+ # Report ambiguous imports
151
+ self._report_ambiguous_imports(python_files)
152
+
153
+ return external_deps
154
+
155
+ def _copy_dependency(self, dep: ExternalDependency) -> None:
156
+ """
157
+ Copy an external dependency to the target location.
158
+
159
+ Handles both files and directories. Checks for idempotency to avoid
160
+ duplicate copies. Creates parent directories as needed.
161
+
162
+ Args:
163
+ dep: ExternalDependency object with source and target paths
164
+ """
165
+ source = dep.source_path
166
+ target = dep.target_path
167
+
168
+ if not source.exists():
169
+ print(f"Warning: External dependency not found: {source}", file=sys.stderr)
170
+ return
171
+
172
+ # Create target directory if needed
173
+ target.parent.mkdir(parents=True, exist_ok=True)
174
+
175
+ # Check if already copied (idempotency)
176
+ if target.exists():
177
+ # Check if it's the same file
178
+ if source.is_file() and target.is_file():
179
+ try:
180
+ if source.samefile(target):
181
+ return # Already in place, skip
182
+ except OSError:
183
+ # Files are different, proceed with copy
184
+ pass
185
+ elif source.is_dir() and target.is_dir():
186
+ # For directories, check if they have the same structure
187
+ # Simple check: if target has __init__.py and source does too, assume copied
188
+ # Also check if target has any Python files
189
+ source_has_init = (source / "__init__.py").exists()
190
+ target_has_init = (target / "__init__.py").exists()
191
+ if source_has_init == target_has_init:
192
+ # Check if target has at least some files from source
193
+ source_files = {f.name for f in source.rglob("*.py")}
194
+ target_files = {f.name for f in target.rglob("*.py")}
195
+ if source_files and target_files and source_files.issubset(target_files):
196
+ # Assume already copied if structure matches
197
+ return
198
+
199
+ try:
200
+ if source.is_file():
201
+ shutil.copy2(source, target)
202
+ self.copied_files.append(target)
203
+ print(f"Copied external file: {source} -> {target}")
204
+ elif source.is_dir():
205
+ if target.exists():
206
+ shutil.rmtree(target)
207
+ shutil.copytree(source, target)
208
+ self.copied_dirs.append(target)
209
+ print(f"Copied external directory: {source} -> {target}")
210
+ except Exception as e:
211
+ print(f"Error copying {source} to {target}: {e}", file=sys.stderr)
212
+
213
+ def _report_ambiguous_imports(self, python_files: list[Path]) -> None:
214
+ """
215
+ Report any ambiguous imports that couldn't be resolved.
216
+
217
+ Prints warnings to stderr for imports that couldn't be classified
218
+ as stdlib, third-party, local, or external.
219
+
220
+ Args:
221
+ python_files: List of Python files to check for ambiguous imports
222
+ """
223
+ analyzer = ImportAnalyzer(self.project_root)
224
+ ambiguous: list[ImportInfo] = []
225
+
226
+ for file_path in python_files:
227
+ imports = analyzer.extract_imports(file_path)
228
+ for imp in imports:
229
+ analyzer.classify_import(imp, self.src_dir)
230
+ if imp.classification == "ambiguous":
231
+ ambiguous.append(imp)
232
+
233
+ if ambiguous:
234
+ print("\nWarning: Found ambiguous imports that could not be resolved:", file=sys.stderr)
235
+ for imp in ambiguous:
236
+ print(
237
+ f" {imp.module_name} (line {imp.line_number} in {imp.file_path})",
238
+ file=sys.stderr,
239
+ )
240
+
241
+ def cleanup(self) -> None:
242
+ """
243
+ Remove all copied files and directories.
244
+
245
+ This method removes all files and directories that were copied
246
+ during prepare_build(). It handles errors gracefully and clears
247
+ the internal tracking lists.
248
+ """
249
+ # Remove copied directories first (they may contain files)
250
+ for dir_path in reversed(self.copied_dirs):
251
+ if dir_path.exists():
252
+ try:
253
+ shutil.rmtree(dir_path)
254
+ print(f"Removed copied directory: {dir_path}")
255
+ except Exception as e:
256
+ print(f"Error removing {dir_path}: {e}", file=sys.stderr)
257
+
258
+ # Remove copied files
259
+ for file_path in self.copied_files:
260
+ if file_path.exists():
261
+ try:
262
+ file_path.unlink()
263
+ print(f"Removed copied file: {file_path}")
264
+ except Exception as e:
265
+ print(f"Error removing {file_path}: {e}", file=sys.stderr)
266
+
267
+ self.copied_files.clear()
268
+ self.copied_dirs.clear()
269
+
270
+ def run_build(self, build_command: Callable[[], None]) -> None:
271
+ """
272
+ Run the build process with dependency management.
273
+
274
+ This is a convenience method that:
275
+ 1. Calls prepare_build() to find and copy dependencies
276
+ 2. Executes the provided build_command
277
+ 3. Always calls cleanup() afterward, even if build fails
278
+
279
+ Args:
280
+ build_command: Callable that executes the build process
281
+
282
+ Example:
283
+ ```python
284
+ def build():
285
+ subprocess.run(["uv", "build"], check=True)
286
+
287
+ manager.run_build(build)
288
+ ```
289
+ """
290
+ try:
291
+ print("Analyzing project for external dependencies...")
292
+ external_deps = self.prepare_build()
293
+
294
+ if external_deps:
295
+ print(f"\nFound {len(external_deps)} external dependencies")
296
+ print("Copied external dependencies into source directory\n")
297
+ else:
298
+ print("No external dependencies found\n")
299
+
300
+ print("Running build...")
301
+ build_command()
302
+
303
+ finally:
304
+ print("\nCleaning up copied files...")
305
+ self.cleanup()
306
+
307
+ def build_and_publish(
308
+ self,
309
+ build_command: Callable[[], None],
310
+ repository: str | None = None,
311
+ repository_url: str | None = None,
312
+ username: str | None = None,
313
+ password: str | None = None,
314
+ skip_existing: bool = False,
315
+ version: str | None = None,
316
+ restore_versioning: bool = True,
317
+ ) -> None:
318
+ """
319
+ Build and publish the package in one operation.
320
+
321
+ This method combines build and publish operations:
322
+ 1. Sets version if specified
323
+ 2. Prepares external dependencies
324
+ 3. Runs the build command
325
+ 4. Publishes to the specified repository
326
+ 5. Cleans up copied files
327
+ 6. Restores versioning configuration if requested
328
+
329
+ Args:
330
+ build_command: Callable that executes the build process
331
+ repository: Target repository ('pypi', 'testpypi', or 'azure')
332
+ repository_url: Custom repository URL (required for Azure)
333
+ username: Username for publishing (will prompt if not provided)
334
+ password: Password/token for publishing (will prompt if not provided)
335
+ skip_existing: If True, skip files that already exist on the repository
336
+ version: Manual version to set before building (PEP 440 format)
337
+ restore_versioning: If True, restore dynamic versioning after build
338
+
339
+ Example:
340
+ ```python
341
+ def build():
342
+ subprocess.run(["uv", "build"], check=True)
343
+
344
+ manager.build_and_publish(build, repository="pypi", version="1.2.3")
345
+ ```
346
+ """
347
+ from .publisher import Publisher, Repository
348
+ from .version import VersionManager
349
+
350
+ version_manager = None
351
+ original_version = None
352
+
353
+ try:
354
+ # Set version if specified
355
+ if version:
356
+ version_manager = VersionManager(self.project_root)
357
+ original_version = version_manager.get_current_version()
358
+ print(f"Setting version to {version}...")
359
+ version_manager.set_version(version)
360
+
361
+ # Build the package
362
+ self.run_build(build_command)
363
+
364
+ # Publish if repository is specified
365
+ if repository:
366
+ publisher = Publisher(
367
+ repository=repository,
368
+ dist_dir=self.project_root / "dist",
369
+ repository_url=repository_url,
370
+ username=username,
371
+ password=password,
372
+ )
373
+ publisher.publish(skip_existing=skip_existing)
374
+ finally:
375
+ # Restore versioning if needed
376
+ if version_manager and restore_versioning:
377
+ try:
378
+ if original_version:
379
+ version_manager.set_version(original_version)
380
+ else:
381
+ version_manager.restore_dynamic_versioning()
382
+ print("Restored versioning configuration")
383
+ except Exception as e:
384
+ print(f"Warning: Could not restore versioning: {e}", file=sys.stderr)
385
+
386
+ # Cleanup is already handled by run_build, but ensure it's done
387
+ if self.copied_files or self.copied_dirs:
388
+ self.cleanup()
@@ -0,0 +1,246 @@
1
+ """
2
+ Package publishing functionality.
3
+
4
+ This module provides functionality to publish built packages to various
5
+ repositories including PyPI, PyPI Test, and Azure Artifacts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import getpass
11
+ import subprocess
12
+ import sys
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ try:
18
+ import keyring
19
+ except ImportError:
20
+ keyring = None
21
+
22
+
23
+ class Repository(Enum):
24
+ """
25
+ Supported package repositories.
26
+
27
+ Attributes:
28
+ PYPI: Official Python Package Index (https://pypi.org)
29
+ PYPI_TEST: Test PyPI for testing package uploads (https://test.pypi.org)
30
+ AZURE: Azure Artifacts feed (requires custom repository_url)
31
+ """
32
+
33
+ PYPI = "pypi"
34
+ PYPI_TEST = "testpypi"
35
+ AZURE = "azure"
36
+
37
+
38
+ class Publisher:
39
+ """
40
+ Handles publishing Python packages to various repositories.
41
+
42
+ This class manages the publishing process, including credential handling
43
+ and repository configuration. It uses twine under the hood for actual publishing.
44
+
45
+ Attributes:
46
+ repository: Target repository for publishing
47
+ dist_dir: Directory containing built distribution files
48
+ repository_url: Custom repository URL (for Azure or custom PyPI servers)
49
+ username: Username for authentication (optional, can be prompted)
50
+ password: Password/token for authentication (optional, can be prompted)
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ repository: Repository | str,
56
+ dist_dir: Path | None = None,
57
+ repository_url: str | None = None,
58
+ username: str | None = None,
59
+ password: str | None = None,
60
+ ) -> None:
61
+ """
62
+ Initialize the publisher.
63
+
64
+ Args:
65
+ repository: Target repository (Repository enum or string)
66
+ dist_dir: Directory containing distribution files (default: dist/)
67
+ repository_url: Custom repository URL (required for Azure)
68
+ username: Username for authentication (will prompt if not provided)
69
+ password: Password/token for authentication (will prompt if not provided)
70
+ """
71
+ if isinstance(repository, str):
72
+ try:
73
+ self.repository = Repository(repository.lower())
74
+ except ValueError:
75
+ valid_repos = ", ".join(r.value for r in Repository)
76
+ raise ValueError(f"Invalid repository: {repository}. Must be one of: {valid_repos}")
77
+ else:
78
+ self.repository = repository
79
+
80
+ self.dist_dir = dist_dir or Path("dist")
81
+ self.repository_url = repository_url
82
+ self.username = username
83
+ self.password = password
84
+
85
+ def _get_repository_url(self) -> str:
86
+ """Get the repository URL based on the selected repository."""
87
+ if self.repository_url:
88
+ return self.repository_url
89
+
90
+ if self.repository == Repository.PYPI:
91
+ return "https://upload.pypi.org/legacy/"
92
+ elif self.repository == Repository.PYPI_TEST:
93
+ return "https://test.pypi.org/legacy/"
94
+ elif self.repository == Repository.AZURE:
95
+ if not self.repository_url:
96
+ raise ValueError("repository_url is required for Azure Artifacts")
97
+ return self.repository_url
98
+
99
+ raise ValueError(f"Unknown repository: {self.repository}")
100
+
101
+ def _get_credentials(self) -> tuple[str, str]:
102
+ """
103
+ Get credentials for publishing.
104
+
105
+ Prompts for username and password/token if not already provided.
106
+ Uses keyring if available to store/retrieve credentials securely.
107
+
108
+ Returns:
109
+ Tuple of (username, password/token)
110
+ """
111
+ username = self.username
112
+ password = self.password
113
+
114
+ # Try to get from keyring if available
115
+ if keyring and not username:
116
+ try:
117
+ username = keyring.get_password(f"python-package-folder-{self.repository.value}", "username")
118
+ except Exception:
119
+ pass
120
+
121
+ if keyring and not password:
122
+ try:
123
+ password = keyring.get_password(f"python-package-folder-{self.repository.value}", username or "token")
124
+ except Exception:
125
+ pass
126
+
127
+ # Prompt if still not available
128
+ if not username:
129
+ username = input(f"Enter username for {self.repository.value}: ").strip()
130
+ if not username:
131
+ raise ValueError("Username is required")
132
+
133
+ if not password:
134
+ if self.repository == Repository.AZURE:
135
+ prompt = f"Enter Azure Artifacts token for {username}: "
136
+ else:
137
+ prompt = f"Enter PyPI token for {username} (or __token__ for API token): "
138
+ password = getpass.getpass(prompt)
139
+ if not password:
140
+ raise ValueError("Password/token is required")
141
+
142
+ # Store in keyring if available
143
+ if keyring:
144
+ try:
145
+ keyring.set_password(f"python-package-folder-{self.repository.value}", "username", username)
146
+ keyring.set_password(f"python-package-folder-{self.repository.value}", username, password)
147
+ except Exception:
148
+ # Keyring storage is optional, continue if it fails
149
+ pass
150
+
151
+ return username, password
152
+
153
+ def _check_twine_installed(self) -> bool:
154
+ """Check if twine is installed."""
155
+ try:
156
+ subprocess.run(["twine", "--version"], capture_output=True, check=True)
157
+ return True
158
+ except (subprocess.CalledProcessError, FileNotFoundError):
159
+ return False
160
+
161
+ def publish(self, skip_existing: bool = False) -> None:
162
+ """
163
+ Publish the package to the selected repository.
164
+
165
+ Args:
166
+ skip_existing: If True, skip files that already exist on the repository
167
+
168
+ Raises:
169
+ ValueError: If twine is not installed or credentials are invalid
170
+ subprocess.CalledProcessError: If publishing fails
171
+ """
172
+ if not self._check_twine_installed():
173
+ raise ValueError(
174
+ "twine is required for publishing. Install it with: pip install twine"
175
+ )
176
+
177
+ if not self.dist_dir.exists():
178
+ raise ValueError(f"Distribution directory not found: {self.dist_dir}")
179
+
180
+ dist_files = list(self.dist_dir.glob("*.whl")) + list(self.dist_dir.glob("*.tar.gz"))
181
+ if not dist_files:
182
+ raise ValueError(f"No distribution files found in {self.dist_dir}")
183
+
184
+ username, password = self._get_credentials()
185
+ repo_url = self._get_repository_url()
186
+
187
+ # Build twine command
188
+ cmd = ["twine", "upload"]
189
+ if skip_existing:
190
+ cmd.append("--skip-existing")
191
+ cmd.extend(["--repository-url", repo_url])
192
+ cmd.extend(["--username", username])
193
+ cmd.extend(["--password", password])
194
+ cmd.extend([str(f) for f in dist_files])
195
+
196
+ print(f"\nPublishing to {self.repository.value} at {repo_url}")
197
+ print(f"Files to upload: {len(dist_files)}")
198
+
199
+ try:
200
+ result = subprocess.run(cmd, check=True, text=True)
201
+ print(f"\n✓ Successfully published to {self.repository.value}")
202
+ except subprocess.CalledProcessError as e:
203
+ print(f"\n✗ Failed to publish to {self.repository.value}", file=sys.stderr)
204
+ print(f"Error: {e}", file=sys.stderr)
205
+ raise
206
+
207
+ def publish_interactive(self, skip_existing: bool = False) -> None:
208
+ """
209
+ Publish with interactive credential prompts.
210
+
211
+ This is a convenience method that ensures credentials are prompted
212
+ even if they were provided during initialization.
213
+
214
+ Args:
215
+ skip_existing: If True, skip files that already exist on the repository
216
+ """
217
+ # Clear cached credentials to force prompt
218
+ self.username = None
219
+ self.password = None
220
+ self.publish(skip_existing=skip_existing)
221
+
222
+
223
+ def get_repository_help() -> str:
224
+ """
225
+ Get help text for repository configuration.
226
+
227
+ Returns:
228
+ Helpful text about repository options and configuration
229
+ """
230
+ return """
231
+ Repository Options:
232
+ - pypi: Official Python Package Index (https://pypi.org)
233
+ - testpypi: Test PyPI for testing package uploads (https://test.pypi.org)
234
+ - azure: Azure Artifacts feed (requires repository_url)
235
+
236
+ For PyPI/TestPyPI:
237
+ - Username: Your PyPI username or '__token__' for API tokens
238
+ - Password: Your PyPI password or API token
239
+
240
+ For Azure Artifacts:
241
+ - Username: Your Azure username or feed name
242
+ - Password: Personal Access Token (PAT) with packaging permissions
243
+ - Repository URL: Your Azure Artifacts feed URL
244
+ Example: https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/pypi/upload
245
+ """
246
+
File without changes