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,310 @@
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
+
16
+ try:
17
+ import keyring
18
+ except ImportError:
19
+ keyring = None
20
+
21
+
22
+ class Repository(Enum):
23
+ """
24
+ Supported package repositories.
25
+
26
+ Attributes:
27
+ PYPI: Official Python Package Index (https://pypi.org)
28
+ PYPI_TEST: Test PyPI for testing package uploads (https://test.pypi.org)
29
+ AZURE: Azure Artifacts feed (requires custom repository_url)
30
+ """
31
+
32
+ PYPI = "pypi"
33
+ PYPI_TEST = "testpypi"
34
+ AZURE = "azure"
35
+
36
+
37
+ class Publisher:
38
+ """
39
+ Handles publishing Python packages to various repositories.
40
+
41
+ This class manages the publishing process, including credential handling
42
+ and repository configuration. It uses twine under the hood for actual publishing.
43
+
44
+ Attributes:
45
+ repository: Target repository for publishing
46
+ dist_dir: Directory containing built distribution files
47
+ repository_url: Custom repository URL (for Azure or custom PyPI servers)
48
+ username: Username for authentication (optional, can be prompted)
49
+ password: Password/token for authentication (optional, can be prompted)
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ repository: Repository | str,
55
+ dist_dir: Path | None = None,
56
+ repository_url: str | None = None,
57
+ username: str | None = None,
58
+ password: str | None = None,
59
+ package_name: str | None = None,
60
+ version: str | None = None,
61
+ ) -> None:
62
+ """
63
+ Initialize the publisher.
64
+
65
+ Args:
66
+ repository: Target repository (Repository enum or string)
67
+ dist_dir: Directory containing distribution files (default: dist/)
68
+ repository_url: Custom repository URL (required for Azure)
69
+ username: Username for authentication (will prompt if not provided)
70
+ password: Password/token for authentication (will prompt if not provided)
71
+ package_name: Package name to filter distribution files (optional)
72
+ version: Package version to filter distribution files (optional)
73
+ """
74
+ if isinstance(repository, str):
75
+ try:
76
+ self.repository = Repository(repository.lower())
77
+ except ValueError as err:
78
+ valid_repos = ", ".join(r.value for r in Repository)
79
+ raise ValueError(
80
+ f"Invalid repository: {repository}. Must be one of: {valid_repos}"
81
+ ) from err
82
+ else:
83
+ self.repository = repository
84
+
85
+ self.dist_dir = dist_dir or Path("dist")
86
+ self.repository_url = repository_url
87
+ self.username = username
88
+ self.password = password
89
+ self.package_name = package_name
90
+ self.version = version
91
+
92
+ def _get_repository_url(self) -> str:
93
+ """Get the repository URL based on the selected repository."""
94
+ if self.repository_url:
95
+ return self.repository_url
96
+
97
+ if self.repository == Repository.PYPI:
98
+ return "https://upload.pypi.org/legacy/"
99
+ elif self.repository == Repository.PYPI_TEST:
100
+ return "https://test.pypi.org/legacy/"
101
+ elif self.repository == Repository.AZURE:
102
+ if not self.repository_url:
103
+ raise ValueError("repository_url is required for Azure Artifacts")
104
+ return self.repository_url
105
+
106
+ raise ValueError(f"Unknown repository: {self.repository}")
107
+
108
+ def _get_credentials(self) -> tuple[str, str]:
109
+ """
110
+ Get credentials for publishing.
111
+
112
+ Prompts for username and password/token if not already provided.
113
+ Uses keyring if available to store/retrieve credentials securely.
114
+
115
+ Returns:
116
+ Tuple of (username, password/token)
117
+ """
118
+ username = self.username
119
+ password = self.password
120
+
121
+ # Try to get from keyring if available
122
+ if keyring and not username:
123
+ try:
124
+ username = keyring.get_password(
125
+ f"python-package-folder-{self.repository.value}", "username"
126
+ )
127
+ except Exception:
128
+ pass
129
+
130
+ if keyring and not password:
131
+ try:
132
+ password = keyring.get_password(
133
+ f"python-package-folder-{self.repository.value}", username or "token"
134
+ )
135
+ except Exception:
136
+ pass
137
+
138
+ # Prompt if still not available
139
+ if not username:
140
+ username = input(f"Enter username for {self.repository.value}: ").strip()
141
+ if not username:
142
+ raise ValueError("Username is required")
143
+
144
+ if not password:
145
+ if self.repository == Repository.AZURE:
146
+ prompt = f"Enter Azure Artifacts token for {username}: "
147
+ else:
148
+ prompt = f"Enter PyPI token for {username} (or __token__ for API token): "
149
+ password = getpass.getpass(prompt)
150
+ if not password:
151
+ raise ValueError("Password/token is required")
152
+
153
+ # Auto-detect if password is an API token and adjust username
154
+ if password.startswith("pypi-") or password.startswith("pypi_Ag"):
155
+ # This is an API token, username should be __token__
156
+ if username != "__token__":
157
+ print(
158
+ f"Note: Detected API token. Using '__token__' as username instead of '{username}'",
159
+ file=sys.stderr,
160
+ )
161
+ username = "__token__"
162
+
163
+ # Store in keyring if available
164
+ if keyring:
165
+ try:
166
+ keyring.set_password(
167
+ f"python-package-folder-{self.repository.value}", "username", username
168
+ )
169
+ keyring.set_password(
170
+ f"python-package-folder-{self.repository.value}", username, password
171
+ )
172
+ except Exception:
173
+ # Keyring storage is optional, continue if it fails
174
+ pass
175
+
176
+ return username, password
177
+
178
+ def _check_twine_installed(self) -> bool:
179
+ """Check if twine is installed."""
180
+ try:
181
+ subprocess.run(["twine", "--version"], capture_output=True, check=True)
182
+ return True
183
+ except (subprocess.CalledProcessError, FileNotFoundError):
184
+ return False
185
+
186
+ def publish(self, skip_existing: bool = False) -> None:
187
+ """
188
+ Publish the package to the selected repository.
189
+
190
+ Args:
191
+ skip_existing: If True, skip files that already exist on the repository
192
+
193
+ Raises:
194
+ ValueError: If twine is not installed or credentials are invalid
195
+ subprocess.CalledProcessError: If publishing fails
196
+ """
197
+ if not self._check_twine_installed():
198
+ raise ValueError("twine is required for publishing. Install it with: pip install twine")
199
+
200
+ if not self.dist_dir.exists():
201
+ raise ValueError(f"Distribution directory not found: {self.dist_dir}")
202
+
203
+ all_dist_files = list(self.dist_dir.glob("*.whl")) + list(self.dist_dir.glob("*.tar.gz"))
204
+
205
+ # Filter files by package name and version if provided
206
+ if self.package_name and self.version:
207
+ # Normalize package name - try both hyphen and underscore variants
208
+ # Wheel names typically use hyphens, but source dists might use underscores
209
+ name_hyphen = self.package_name.replace("_", "-").lower()
210
+ name_underscore = self.package_name.replace("-", "_").lower()
211
+ name_original = self.package_name.lower()
212
+
213
+ # Try all name variants
214
+ name_variants = {name_hyphen, name_underscore, name_original}
215
+ version_str = self.version
216
+
217
+ dist_files = []
218
+ for f in all_dist_files:
219
+ # Get the base filename without extension
220
+ # For wheels: name-version-tag.whl -> name-version-tag
221
+ # For source: name-version.tar.gz -> name-version
222
+ stem = f.stem
223
+ if f.suffix == ".gz" and stem.endswith(".tar"):
224
+ # Handle .tar.gz files
225
+ stem = stem[:-4] # Remove .tar
226
+
227
+ # Check if filename starts with any name variant followed by version
228
+ matches = False
229
+ for name_variant in name_variants:
230
+ # Pattern: {name}-{version} or {name}-{version}-{tag}
231
+ if stem.startswith(f"{name_variant}-{version_str}"):
232
+ matches = True
233
+ break
234
+
235
+ if matches:
236
+ dist_files.append(f)
237
+ else:
238
+ dist_files = all_dist_files
239
+
240
+ if not dist_files:
241
+ if self.package_name and self.version:
242
+ raise ValueError(
243
+ f"No distribution files found matching package '{self.package_name}' "
244
+ f"version '{self.version}' in {self.dist_dir}"
245
+ )
246
+ else:
247
+ raise ValueError(f"No distribution files found in {self.dist_dir}")
248
+
249
+ username, password = self._get_credentials()
250
+ repo_url = self._get_repository_url()
251
+
252
+ # Build twine command
253
+ cmd = ["twine", "upload"]
254
+ if skip_existing:
255
+ cmd.append("--skip-existing")
256
+ cmd.extend(["--repository-url", repo_url])
257
+ cmd.extend(["--username", username])
258
+ cmd.extend(["--password", password])
259
+ cmd.extend([str(f) for f in dist_files])
260
+
261
+ print(f"\nPublishing to {self.repository.value} at {repo_url}")
262
+ print(f"Files to upload: {len(dist_files)}")
263
+
264
+ try:
265
+ subprocess.run(cmd, check=True, text=True)
266
+ print(f"\n✓ Successfully published to {self.repository.value}")
267
+ except subprocess.CalledProcessError as e:
268
+ print(f"\n✗ Failed to publish to {self.repository.value}", file=sys.stderr)
269
+ print(f"Error: {e}", file=sys.stderr)
270
+ raise
271
+
272
+ def publish_interactive(self, skip_existing: bool = False) -> None:
273
+ """
274
+ Publish with interactive credential prompts.
275
+
276
+ This is a convenience method that ensures credentials are prompted
277
+ even if they were provided during initialization.
278
+
279
+ Args:
280
+ skip_existing: If True, skip files that already exist on the repository
281
+ """
282
+ # Clear cached credentials to force prompt
283
+ self.username = None
284
+ self.password = None
285
+ self.publish(skip_existing=skip_existing)
286
+
287
+
288
+ def get_repository_help() -> str:
289
+ """
290
+ Get help text for repository configuration.
291
+
292
+ Returns:
293
+ Helpful text about repository options and configuration
294
+ """
295
+ return """
296
+ Repository Options:
297
+ - pypi: Official Python Package Index (https://pypi.org)
298
+ - testpypi: Test PyPI for testing package uploads (https://test.pypi.org)
299
+ - azure: Azure Artifacts feed (requires repository_url)
300
+
301
+ For PyPI/TestPyPI:
302
+ - Username: Your PyPI username or '__token__' for API tokens
303
+ - Password: Your PyPI password or API token
304
+
305
+ For Azure Artifacts:
306
+ - Username: Your Azure username or feed name
307
+ - Password: Personal Access Token (PAT) with packaging permissions
308
+ - Repository URL: Your Azure Artifacts feed URL
309
+ Example: https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/pypi/upload
310
+ """
File without changes
@@ -0,0 +1,239 @@
1
+ """
2
+ Main entry point for the python-package-folder package.
3
+
4
+ This module provides the command-line interface for the package.
5
+ It can be invoked via:
6
+ - The `python-package-folder` command (after installation)
7
+ - `python -m python_package_folder`
8
+ - Direct import and call to main()
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from .manager import BuildManager
18
+ from .utils import find_project_root, find_source_directory
19
+
20
+
21
+ def main() -> int:
22
+ """
23
+ Main entry point for the build script.
24
+
25
+ Parses command-line arguments and runs the build process with
26
+ external dependency management.
27
+
28
+ Returns:
29
+ Exit code (0 for success, non-zero for errors)
30
+ """
31
+ import argparse
32
+
33
+ parser = argparse.ArgumentParser(
34
+ description="Build Python package with external dependency management"
35
+ )
36
+ parser.add_argument(
37
+ "--project-root",
38
+ type=Path,
39
+ help="Root directory of the project (auto-detected from pyproject.toml if not specified)",
40
+ )
41
+ parser.add_argument(
42
+ "--src-dir",
43
+ type=Path,
44
+ help="Source directory to build (default: auto-detected from current directory or project_root/src)",
45
+ )
46
+ parser.add_argument(
47
+ "--analyze-only",
48
+ action="store_true",
49
+ help="Only analyze imports, don't run build",
50
+ )
51
+ parser.add_argument(
52
+ "--build-command",
53
+ default="uv build",
54
+ help="Command to run for building (default: 'uv build')",
55
+ )
56
+ parser.add_argument(
57
+ "--publish",
58
+ choices=["pypi", "testpypi", "azure"],
59
+ help="Publish to repository after building (pypi, testpypi, or azure)",
60
+ )
61
+ parser.add_argument(
62
+ "--repository-url",
63
+ help="Custom repository URL (required for Azure Artifacts)",
64
+ )
65
+ parser.add_argument(
66
+ "--username",
67
+ help="Username for publishing (will prompt if not provided)",
68
+ )
69
+ parser.add_argument(
70
+ "--password",
71
+ help="Password/token for publishing (will prompt if not provided)",
72
+ )
73
+ parser.add_argument(
74
+ "--skip-existing",
75
+ action="store_true",
76
+ help="Skip files that already exist on the repository",
77
+ )
78
+ parser.add_argument(
79
+ "--version",
80
+ help="Set a specific version before building (PEP 440 format, e.g., '1.2.3'). Required for subfolder builds.",
81
+ )
82
+ parser.add_argument(
83
+ "--package-name",
84
+ help="Package name for subfolder builds (default: derived from source directory name)",
85
+ )
86
+ parser.add_argument(
87
+ "--dependency-group",
88
+ dest="dependency_group",
89
+ help="Dependency group name from parent pyproject.toml to include in subfolder build",
90
+ )
91
+ parser.add_argument(
92
+ "--no-restore-versioning",
93
+ action="store_true",
94
+ help="Don't restore dynamic versioning after build (keeps static version)",
95
+ )
96
+ parser.add_argument(
97
+ "--exclude-pattern",
98
+ action="append",
99
+ dest="exclude_patterns",
100
+ help="Additional directory/file patterns to exclude from copying (e.g., '_SS', '__sandbox'). Can be specified multiple times.",
101
+ )
102
+
103
+ args = parser.parse_args()
104
+
105
+ try:
106
+ # Auto-detect project root if not specified
107
+ if args.project_root:
108
+ project_root = Path(args.project_root).resolve()
109
+ else:
110
+ project_root = find_project_root()
111
+ if project_root is None:
112
+ print(
113
+ "Error: Could not find project root (pyproject.toml not found).\n"
114
+ "Please run from a directory with pyproject.toml or specify --project-root",
115
+ file=sys.stderr,
116
+ )
117
+ return 1
118
+ print(f"Auto-detected project root: {project_root}")
119
+
120
+ # Determine source directory
121
+ if args.src_dir:
122
+ src_dir = Path(args.src_dir).resolve()
123
+ else:
124
+ # Auto-detect: use current directory if it has Python files, otherwise use project_root/src
125
+ src_dir = find_source_directory(project_root)
126
+ if src_dir:
127
+ print(f"Auto-detected source directory: {src_dir}")
128
+ else:
129
+ src_dir = project_root / "src"
130
+
131
+ manager = BuildManager(project_root, src_dir, exclude_patterns=args.exclude_patterns)
132
+
133
+ if args.analyze_only:
134
+ external_deps = manager.prepare_build()
135
+ print(f"\nFound {len(external_deps)} external dependencies:")
136
+ for dep in external_deps:
137
+ print(f" {dep.import_name}: {dep.source_path} -> {dep.target_path}")
138
+ manager.cleanup()
139
+ return 0
140
+
141
+ def build_cmd() -> None:
142
+ # Run build command from project root to ensure pyproject.toml is found
143
+ result = subprocess.run(
144
+ args.build_command,
145
+ shell=True,
146
+ check=False,
147
+ cwd=project_root,
148
+ )
149
+ if result.returncode != 0:
150
+ sys.exit(result.returncode)
151
+
152
+ # Check if building a subfolder (not the main src/)
153
+ is_subfolder = not src_dir.is_relative_to(project_root / "src") or (
154
+ src_dir != project_root / "src" and src_dir != project_root
155
+ )
156
+
157
+ # For subfolder builds, version is required
158
+ if is_subfolder and not args.version and (not args.analyze_only):
159
+ print(
160
+ "Error: --version is required when building from a subfolder.\n"
161
+ "Subfolders must be built as separate packages with their own version.",
162
+ file=sys.stderr,
163
+ )
164
+ return 1
165
+
166
+ if args.publish:
167
+ manager.build_and_publish(
168
+ build_cmd,
169
+ repository=args.publish,
170
+ repository_url=args.repository_url,
171
+ username=args.username,
172
+ password=args.password,
173
+ skip_existing=args.skip_existing,
174
+ version=args.version,
175
+ restore_versioning=not args.no_restore_versioning,
176
+ package_name=args.package_name,
177
+ dependency_group=args.dependency_group,
178
+ )
179
+ else:
180
+ # Handle version setting even without publishing
181
+ if args.version:
182
+ # Check if subfolder build
183
+ if is_subfolder:
184
+ from .subfolder_build import SubfolderBuildConfig
185
+
186
+ package_name = args.package_name or src_dir.name.replace("_", "-").replace(
187
+ " ", "-"
188
+ ).lower().strip("-")
189
+ subfolder_config = SubfolderBuildConfig(
190
+ project_root=project_root,
191
+ src_dir=src_dir,
192
+ package_name=package_name,
193
+ version=args.version,
194
+ dependency_group=args.dependency_group,
195
+ )
196
+ try:
197
+ subfolder_config.create_temp_pyproject()
198
+ manager.run_build(build_cmd)
199
+ if not args.no_restore_versioning:
200
+ subfolder_config.restore()
201
+ print("Restored original pyproject.toml")
202
+ except Exception as e:
203
+ print(f"Error managing subfolder build: {e}", file=sys.stderr)
204
+ if subfolder_config:
205
+ subfolder_config.restore()
206
+ raise
207
+ else:
208
+ from .version import VersionManager
209
+
210
+ version_manager = VersionManager(project_root)
211
+ original_version = version_manager.get_current_version()
212
+ try:
213
+ print(f"Setting version to {args.version}...")
214
+ version_manager.set_version(args.version)
215
+ manager.run_build(build_cmd)
216
+ if not args.no_restore_versioning:
217
+ if original_version:
218
+ version_manager.set_version(original_version)
219
+ else:
220
+ version_manager.restore_dynamic_versioning()
221
+ print("Restored versioning configuration")
222
+ except Exception as e:
223
+ print(f"Error managing version: {e}", file=sys.stderr)
224
+ raise
225
+ else:
226
+ manager.run_build(build_cmd)
227
+
228
+ return 0
229
+
230
+ except Exception as e:
231
+ print(f"Error: {e}", file=sys.stderr)
232
+ import traceback
233
+
234
+ traceback.print_exc()
235
+ return 1
236
+
237
+
238
+ if __name__ == "__main__":
239
+ sys.exit(main())