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/__init__.py +3 -0
- pkg_deploy/build.py +238 -0
- pkg_deploy/deploy.py +410 -0
- pkg_deploy/upload.py +81 -0
- pkg_deploy/utils.py +227 -0
- pkg_deploy/version_managment.py +147 -0
- pkg_deploy-1.0.12.dist-info/METADATA +382 -0
- pkg_deploy-1.0.12.dist-info/RECORD +11 -0
- pkg_deploy-1.0.12.dist-info/WHEEL +5 -0
- pkg_deploy-1.0.12.dist-info/entry_points.txt +2 -0
- pkg_deploy-1.0.12.dist-info/top_level.txt +1 -0
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}")
|