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.
- python_package_folder/__init__.py +19 -0
- python_package_folder/__main__.py +13 -0
- python_package_folder/analyzer.py +313 -0
- python_package_folder/finder.py +188 -0
- python_package_folder/manager.py +388 -0
- python_package_folder/publisher.py +246 -0
- python_package_folder/py.typed +0 -0
- python_package_folder/python_package_folder.py +151 -0
- python_package_folder/types.py +66 -0
- python_package_folder/version.py +255 -0
- python_package_folder-0.1.0.dist-info/METADATA +544 -0
- python_package_folder-0.1.0.dist-info/RECORD +15 -0
- python_package_folder-0.1.0.dist-info/WHEEL +4 -0
- python_package_folder-0.1.0.dist-info/entry_points.txt +2 -0
- python_package_folder-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|