pkg-deploy 1.0.12__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.
pkg_deploy/upload.py ADDED
@@ -0,0 +1,81 @@
1
+ import sys
2
+ import logging
3
+ import subprocess
4
+ from pathlib import Path
5
+ from abc import ABC, abstractmethod
6
+
7
+ from .build import DeployConfig
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Upload(ABC):
14
+ """Deploy Base class"""
15
+
16
+ @abstractmethod
17
+ def upload(self, config: DeployConfig, dist_dir: Path) -> bool:
18
+ pass
19
+
20
+
21
+ class NexusUpload(Upload):
22
+ """Nexus Deploy"""
23
+
24
+ @staticmethod
25
+ def get_wheel_files(config: DeployConfig):
26
+ wheel_files = []
27
+ dist_dir = config.project_dir / 'dist'
28
+ for binary in dist_dir.iterdir():
29
+ if config.package_name.replace("-", "_") in binary.name and binary.suffix == '.whl':
30
+ wheel_files.append(binary.name)
31
+
32
+ if len(wheel_files) < 1:
33
+ raise ValueError(f"No wheel files found under {config.project_dir / 'dist'}")
34
+ else:
35
+ logger.info(f"Built {len(wheel_files)} wheel files: {wheel_files}")
36
+ for file in wheel_files:
37
+ file_path = dist_dir / file
38
+ size_bytes = file_path.stat().st_size
39
+ size_mb = size_bytes / (1024 * 1024)
40
+ logger.info(f"Found valid package wheel: {file} ({size_mb:.2f} MB)")
41
+ return wheel_files
42
+
43
+ def upload(self, config: DeployConfig, dist_dir: Path) -> bool:
44
+ try:
45
+ cmd = [sys.executable, "-m", "twine", "upload",
46
+ "--disable-progress-bar",
47
+ "--verbose"
48
+ ]
49
+
50
+ wheel_files = self.get_wheel_files(config)
51
+ wheel_paths = [str(dist_dir / wheel_file) for wheel_file in wheel_files]
52
+ cmd.extend(wheel_paths)
53
+
54
+ if config.repository_name != "pypi":
55
+ cmd.extend(["--repository-url", config.repository_url])
56
+
57
+ cmd.extend(["--username", config.username])
58
+ cmd.extend(["--password", config.password])
59
+
60
+ # Create masked command for logging
61
+ masked_cmd = []
62
+ for i, arg in enumerate(cmd):
63
+ if i > 0 and cmd[i - 1] == "--password":
64
+ masked_cmd.append("******")
65
+ else:
66
+ masked_cmd.append(arg)
67
+
68
+ logger.info(f"Running: {' '.join(masked_cmd)}")
69
+
70
+ if config.dry_run:
71
+ logger.info(f"DRY RUN: wheel files from dist directory: {wheel_files}")
72
+ logger.info(f"DRY RUN: cmd: {cmd}")
73
+ else:
74
+ result = subprocess.run(cmd, capture_output=True, text=True)
75
+ if result.returncode != 0:
76
+ raise ValueError(f"Upload failed, \nstdout: {result.stdout}\nstderr: {result.stderr}")
77
+ logger.info(f"Package uploaded to {config.repository_url} successfully")
78
+ return True
79
+ except Exception as e:
80
+ logger.error(f"Package upload error: {e}")
81
+ return False
pkg_deploy/utils.py ADDED
@@ -0,0 +1,227 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ import shutil
5
+ import tomlkit
6
+ import logging
7
+ import getpass
8
+ import argparse
9
+ import subprocess
10
+ import configparser
11
+ from pathlib import Path
12
+ from typing import Optional, Tuple
13
+ from tomlkit.toml_document import TOMLDocument
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def is_uv_venv() -> bool:
20
+ """Detect if the current virtual environment was created by uv (supports versioned marker)."""
21
+ if not hasattr(sys, 'prefix') or not sys.prefix:
22
+ return False
23
+ pyvenv_cfg = Path(sys.prefix) / "pyvenv.cfg"
24
+ if not pyvenv_cfg.exists():
25
+ return False
26
+ try:
27
+ with open(pyvenv_cfg, "r", encoding="utf-8") as f:
28
+ for line in f:
29
+ line = line.strip()
30
+ if line.lower().startswith("uv ="):
31
+ logger.info("Detected uv-managed Python.")
32
+ return True
33
+ except Exception:
34
+ pass
35
+ return False
36
+
37
+ def setup_uv_compatibility():
38
+ if is_uv_venv():
39
+ logger.info("Setting PIP_USE_VIRTUALENV=1 for build compatibility.")
40
+ os.environ["PIP_USE_VIRTUALENV"] = "1"
41
+ return True
42
+ else:
43
+ return False
44
+
45
+
46
+ def get_credentials(username: Optional[str] = None,
47
+ password: Optional[str] = None,
48
+ url: str = "",
49
+ is_pypi: bool = False
50
+ ) -> Tuple[Optional[str], Optional[str]]:
51
+ is_pypi = "upload.pypi.org" in url or is_pypi
52
+ if not username:
53
+ if is_pypi:
54
+ logger.info("For PyPI API tokens, use '__token__' as username")
55
+ username = "__token__"
56
+ else:
57
+ username = input(f"Please enter username for {url}:").strip()
58
+
59
+ if not username:
60
+ raise ValueError("Username cannot be empty")
61
+
62
+ logger.info(f"Using username: {username}")
63
+
64
+ if not password:
65
+ if is_pypi:
66
+ password = getpass.getpass("PyPI API token: ")
67
+ else:
68
+ password = getpass.getpass(f"Please enter password for {url}:")
69
+
70
+ if not password:
71
+ raise ValueError("Password/Token cannot be empty")
72
+
73
+ return username, password
74
+
75
+
76
+ def parse_prerelease(version: str):
77
+ # Match the pattern: numbers.numbers.numbers + optional prerelease type + optional version
78
+ pattern = r'^(\d+)\.(\d+)\.(\d+)([abc]|rc)?(\d*)$'
79
+ match = re.match(pattern, version)
80
+
81
+ if match:
82
+ major = int(match.group(1))
83
+ minor = int(match.group(2))
84
+ patch = int(match.group(3))
85
+ prerelease_type = match.group(4) # None if no prerelease
86
+
87
+ if prerelease_type and match.group(5):
88
+ prerelease_version = int(match.group(5))
89
+ elif prerelease_type:
90
+ prerelease_version = 1
91
+ else:
92
+ prerelease_version = None
93
+
94
+ return {
95
+ 'major': major,
96
+ 'minor': minor,
97
+ 'patch': patch,
98
+ 'prerelease_type': prerelease_type,
99
+ 'prerelease_version': prerelease_version,
100
+ 'has_prerelease': prerelease_type is not None
101
+ }
102
+ else:
103
+ raise ValueError(f"Invalid version format: {version}")
104
+
105
+
106
+ def get_pypirc_info():
107
+ """
108
+ Read and parse .pypirc file from user's home directory.
109
+ Returns a dictionary with repository configurations.
110
+ """
111
+ # Get the path to .pypirc file
112
+ home_dir = Path.home()
113
+ pypirc_path = home_dir / '.pypirc'
114
+
115
+ if not pypirc_path.exists():
116
+ raise FileNotFoundError(f"No .pypirc file found at {pypirc_path}")
117
+
118
+ # Parse the configuration file
119
+ config = configparser.ConfigParser()
120
+
121
+ try:
122
+ config.read(pypirc_path)
123
+
124
+ # Extract information
125
+ pypirc_info = {}
126
+
127
+ # Get index servers if available
128
+ if config.has_section('distutils') and config.has_option('distutils', 'index-servers'):
129
+ index_servers = config.get('distutils', 'index-servers').split()
130
+ pypirc_info['index_servers'] = index_servers
131
+
132
+ # Get repository configurations
133
+ repositories = {}
134
+ for section_name in config.sections():
135
+ if section_name != 'distutils':
136
+ repo_config = {}
137
+ for option in config.options(section_name):
138
+ repo_config[option] = config.get(section_name, option)
139
+ repositories[section_name] = repo_config
140
+ if not repositories:
141
+ raise ValueError(f"No repositories configuration found in {pypirc_path}")
142
+
143
+ pypirc_info['repositories'] = repositories
144
+ return pypirc_info
145
+ except Exception as e:
146
+ logger.error(f"Error reading .pypirc file: {e}")
147
+ return None
148
+
149
+ def ensure_uv_installed():
150
+ """Check if 'uv' is available, and install it if not."""
151
+ if shutil.which("uv"):
152
+ logger.info("uv is already installed and available in PATH.")
153
+ return
154
+
155
+ try:
156
+ result = subprocess.run(
157
+ [sys.executable, "-m", "uv", "--version"],
158
+ capture_output=True,
159
+ text=True,
160
+ check=True
161
+ )
162
+ logger.info(f"uv is installed as a module: {result.stdout.strip()}")
163
+ return
164
+ except (subprocess.CalledProcessError, FileNotFoundError):
165
+ pass # uv not available as module either
166
+
167
+ logger.info("uv not found. Installing uv via pip...")
168
+ try:
169
+ subprocess.run(
170
+ [sys.executable, "-m", "pip", "install", "uv"],
171
+ check=True,
172
+ capture_output=True,
173
+ text=True
174
+ )
175
+ logger.info("uv installed successfully.")
176
+ except subprocess.CalledProcessError as e:
177
+ logger.error("Failed to install uv:", e.stderr)
178
+ raise RuntimeError(
179
+ "Failed to install uv automatically. Please install it manually:\n"
180
+ " pip install uv\n"
181
+ "Or download from: https://github.com/astral-sh/uv/releases"
182
+ ) from e
183
+
184
+ try:
185
+ result = subprocess.run(
186
+ [sys.executable, "-m", "uv", "--version"],
187
+ capture_output=True,
188
+ text=True,
189
+ check=True
190
+ )
191
+ logger.info(f"uv version: {result.stdout.strip()}")
192
+ except subprocess.CalledProcessError as e:
193
+ raise RuntimeError("uv installed but failed to run.") from e
194
+
195
+
196
+ def load_config(pyproject_path: Path) -> TOMLDocument:
197
+ """load pyproject.toml"""
198
+ if not pyproject_path.exists():
199
+ raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
200
+
201
+ with open(pyproject_path, "r", encoding="utf-8") as f:
202
+ content = f.read()
203
+ config = tomlkit.parse(content)
204
+ return config
205
+
206
+
207
+ def save_config(config, pyproject_path: Path):
208
+ with open(pyproject_path, 'w', encoding='utf-8') as f:
209
+ f.write(tomlkit.dumps(config))
210
+
211
+
212
+ def validate_version_arg(value: str) -> str:
213
+ """
214
+ Custom argparse validator that checks if a version string conforms to SemVer.
215
+ Returns the value if valid, raises an error otherwise.
216
+ """
217
+ version_pattern = re.compile(r"^\d+\.\d+\.\d+(a\d+|b\d+|rc\d+)?$")
218
+
219
+ if not version_pattern.match(value):
220
+ raise argparse.ArgumentTypeError(
221
+ f"Invalid version format: '{value}'. Valid examples:\n"
222
+ " Final release: 1.2.0\n"
223
+ " Alpha release: 1.2.0a1\n"
224
+ " Beta release: 1.2.0b1\n"
225
+ " Release candidate: 1.2.0rc1"
226
+ )
227
+ return value
@@ -0,0 +1,147 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from tomlkit import TOMLDocument
6
+
7
+ from .utils import parse_prerelease, load_config, save_config
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class VersionManager:
14
+ """Version Manager"""
15
+
16
+ def __init__(self, pyproject_path: Path, toml_config: TOMLDocument):
17
+ self.pyproject_path = pyproject_path
18
+ self.toml_config = toml_config
19
+
20
+ def get_current_version(self) -> str:
21
+ return str(self.toml_config['project']['version'])
22
+
23
+ @staticmethod
24
+ def resolve_new_version(current_version: str, version_type: str) -> str:
25
+ version_info = parse_prerelease(current_version)
26
+ major = version_info['major']
27
+ minor = version_info['minor']
28
+ patch = version_info['patch']
29
+ prerelease_type = version_info['prerelease_type']
30
+ prerelease_version = version_info['prerelease_version']
31
+ has_prerelease = version_info['has_prerelease']
32
+
33
+ # Handle version bumping
34
+ if version_type == 'patch':
35
+ prerelease_type = None
36
+ patch += 1
37
+ elif version_type == 'minor':
38
+ minor += 1
39
+ patch = 0
40
+ prerelease_type = None
41
+ elif version_type == 'major':
42
+ major += 1
43
+ minor = 0
44
+ patch = 0
45
+ prerelease_type = None
46
+ elif version_type == 'alpha':
47
+ if has_prerelease:
48
+ if prerelease_type == 'a':
49
+ prerelease_version += 1
50
+ else:
51
+ prerelease_type = 'a'
52
+ prerelease_version = 1
53
+ else:
54
+ prerelease_type = 'a'
55
+ prerelease_version = 1
56
+ elif version_type == 'beta':
57
+ if has_prerelease:
58
+ if prerelease_type == 'a':
59
+ prerelease_type = 'b'
60
+ prerelease_version = 1
61
+ elif prerelease_type == 'b':
62
+ prerelease_version += 1
63
+ else:
64
+ prerelease_type = 'b'
65
+ prerelease_version = 1
66
+ else:
67
+ prerelease_type = 'b'
68
+ prerelease_version = 1
69
+ elif version_type == 'rc':
70
+ if has_prerelease:
71
+ if prerelease_type in ['a', 'b']:
72
+ prerelease_type = 'rc'
73
+ prerelease_version = 1
74
+ elif prerelease_type == 'rc':
75
+ prerelease_version += 1
76
+ else:
77
+ prerelease_type = 'rc'
78
+ prerelease_version = 1
79
+ else:
80
+ raise ValueError(f"Invalid version type: {version_type}")
81
+
82
+ if prerelease_type:
83
+ new_version = f"{major}.{minor}.{patch}{prerelease_type}{prerelease_version}"
84
+ else:
85
+ new_version = f"{major}.{minor}.{patch}"
86
+ return new_version
87
+
88
+ def bump_version(self, version_type: str, new_version: Optional[str]=None, dry_run: bool = False) -> str:
89
+ current_version = self.get_current_version()
90
+ if new_version is None:
91
+ new_version = self.resolve_new_version(current_version, version_type)
92
+
93
+ if not dry_run:
94
+ # Update pyproject.toml
95
+ self.toml_config['project']['version'] = new_version
96
+ save_config(self.toml_config, self.pyproject_path)
97
+ # Update files configured under [tool.bumpversion.file]
98
+ self.update_bumpversion_files(current_version, new_version)
99
+
100
+ logger.info(f"Version bumped from {current_version} to {new_version}")
101
+ return new_version
102
+
103
+ def update_bumpversion_files(self, current_version: str, new_version: str) -> None:
104
+ """Update files configured in [tool.bumpversion.file] section."""
105
+ bumpversion_config = self.toml_config.get('tool', {}).get('bumpversion', {})
106
+ files = bumpversion_config.get('file', [])
107
+
108
+ # If 'file' is a single dict, make it a list
109
+ if isinstance(files, dict):
110
+ files = [files]
111
+
112
+ for file_config in files:
113
+ filename = file_config.get('filename')
114
+ if filename == "pyproject.toml":
115
+ continue
116
+ search = file_config.get('search', '{current_version}')
117
+ replace = file_config.get('replace', '{new_version}')
118
+
119
+ if not filename:
120
+ logger.warning("Skipping bumpversion file entry with no 'filename'")
121
+ continue
122
+
123
+ file_path = Path(filename)
124
+ if not file_path.exists():
125
+ logger.warning(f"File {filename} not found, skipping.")
126
+ continue
127
+
128
+ # Read file content
129
+ content = file_path.read_text(encoding='utf-8')
130
+
131
+ # Replace placeholders
132
+ old_str = search.format(current_version=current_version)
133
+ new_str = replace.format(new_version=new_version)
134
+
135
+ # Escape for literal string replacement (not regex)
136
+ if old_str not in content:
137
+ logger.warning(f"Search pattern '{old_str}' not found in {filename}, skipping.")
138
+ continue
139
+
140
+ new_content = content.replace(old_str, new_str)
141
+
142
+ # Write back only if changed
143
+ if new_content != content:
144
+ file_path.write_text(new_content, encoding='utf-8')
145
+ logger.info(f"Updated {filename}: '{old_str}' → '{new_str}'")
146
+ else:
147
+ logger.debug(f"No changes needed in {filename}")