pkg-deploy 1.0.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.
pkg_deploy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __version__ = "1.0.0"
2
+
3
+ from .deploy import PackageDeploy
pkg_deploy/build.py ADDED
@@ -0,0 +1,179 @@
1
+ import os
2
+ import sys
3
+ import copy
4
+ import logging
5
+ import textwrap
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Optional, Dict
9
+ from dataclasses import dataclass
10
+ from abc import ABC, abstractmethod
11
+ from tomlkit.toml_document import TOMLDocument
12
+
13
+ from .utils import save_config, is_uv_venv, ensure_uv_installed
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class DeployConfig:
21
+ package_name: str
22
+ project_dir: Path
23
+ pyproject_path: Path
24
+ version_type: str
25
+ new_version: str
26
+ use_cython: bool
27
+ is_uv_venv: bool
28
+ repository_name: str
29
+ repository_url: Optional[str] = None
30
+ username: Optional[str] = None
31
+ password: Optional[str] = None
32
+ dry_run: bool = False
33
+
34
+
35
+ class BuildStrategy(ABC):
36
+
37
+ @staticmethod
38
+ def build_cmd():
39
+ if is_uv_venv():
40
+ ensure_uv_installed()
41
+ cmd = ["uv", "build", "--wheel"]
42
+ else:
43
+ cmd = [sys.executable, "-m", "build", "--wheel"]
44
+ return cmd
45
+
46
+ @abstractmethod
47
+ def build(self, config: DeployConfig, toml_config: TOMLDocument) -> bool:
48
+ pass
49
+
50
+
51
+ class StandardBuildStrategy(BuildStrategy):
52
+
53
+ def build(self, config: DeployConfig, toml_config: TOMLDocument) -> bool:
54
+ cmd = self.build_cmd()
55
+ logger.info(f"Running: {' '.join(cmd)}")
56
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd=config.project_dir)
57
+ if result.returncode != 0:
58
+ raise ValueError(f"Build failed, \nstdout: {result.stdout}\nstderr: {result.stderr}")
59
+ logger.info("Standard build completed successfully")
60
+ return True
61
+
62
+ class CythonBuildStrategy(BuildStrategy):
63
+
64
+ def build(self, config: DeployConfig, toml_config: TOMLDocument) -> bool:
65
+ try:
66
+ self.prepare_pyproject_for_cython_build(config.project_dir, toml_config)
67
+ self.create_setup_py_for_cython(config.project_dir, toml_config)
68
+ cmd = self.build_cmd()
69
+ logger.info(f"Running Cython build: {' '.join(cmd)}")
70
+ env = os.environ.copy()
71
+ env['CYTHONIZE'] = '1'
72
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=config.project_dir)
73
+ if result.returncode != 0:
74
+ raise ValueError(f"Cython build failed, \nstdout: {result.stdout}\nstderr: {result.stderr}")
75
+ logger.info("Cython build completed successfully")
76
+ return True
77
+ except Exception as e:
78
+ logger.error(f"Cython build error: {e}")
79
+ return False
80
+ finally:
81
+ self.restore_pyproject_toml(project_dir=config.project_dir, original_toml_config=toml_config)
82
+
83
+ @staticmethod
84
+ def prepare_pyproject_for_cython_build(project_dir: Path, toml_config: TOMLDocument):
85
+ pyproject_path = project_dir / "pyproject.toml"
86
+ if pyproject_path.exists():
87
+ new_config = copy.deepcopy(toml_config)
88
+
89
+ # Add Cython build dependency
90
+ if 'build-system' not in new_config:
91
+ new_config['build-system'] = {}
92
+
93
+ if 'requires' not in new_config['build-system']:
94
+ new_config['build-system']['requires'] = []
95
+
96
+ cython_deps = ['setuptools', 'Cython']
97
+ requires = new_config['build-system']['requires']
98
+ for dep in cython_deps:
99
+ if not any(req.startswith(dep) for req in requires):
100
+ new_config['build-system']['requires'].append(dep)
101
+
102
+ if 'build-backend' not in new_config['build-system'] or new_config['build-system'][
103
+ 'build-backend'] != 'setuptools.build_meta':
104
+ new_config['build-system']['build-backend'] = 'setuptools.build_meta'
105
+ save_config(new_config, pyproject_path)
106
+
107
+ @staticmethod
108
+ def restore_pyproject_toml(project_dir: Path, original_toml_config: TOMLDocument):
109
+ pyproject_path = project_dir / "pyproject.toml"
110
+ if original_toml_config:
111
+ save_config(original_toml_config, pyproject_path)
112
+ logger.info("Restored original pyproject.toml")
113
+
114
+ @staticmethod
115
+ def create_setup_py_for_cython(project_dir: Path, toml_config: TOMLDocument):
116
+ if "authors" in toml_config["project"]:
117
+ author_names = ", ".join(p["name"] for p in toml_config["project"]["authors"] if "name" in p)
118
+ author_emails = ", ".join(p["email"] for p in toml_config["project"]["authors"] if "email" in p)
119
+ else:
120
+ author_names = ""
121
+ author_emails = ""
122
+ if "scripts" in toml_config["project"]:
123
+ entry_points = [f"{k}={v}" for k, v in toml_config["project"]["scripts"].items()]
124
+ else:
125
+ entry_points = []
126
+ setup_py_content = textwrap.dedent(f'''
127
+ import glob
128
+ from Cython.Build import cythonize
129
+ from setuptools import setup, find_packages
130
+ from setuptools.dist import Distribution
131
+ from setuptools.command.build_py import build_py as _build_py
132
+
133
+ py_files = glob.glob("src/**/**/*.py", recursive=True)
134
+ py_files = [f for f in py_files if not f.endswith("__init__.py")]
135
+
136
+ class build_py(_build_py):
137
+ def find_package_modules(self, package, package_dir):
138
+ modules = super().find_package_modules(package, package_dir)
139
+ if self.distribution.ext_modules:
140
+ # Get the list of compiled module names
141
+ compiled_modules = {{ext.name for ext in self.distribution.ext_modules}}
142
+ # Filter out the modules that are compiled
143
+ modules = [
144
+ (pkg, mod, file) for (pkg, mod, file) in modules
145
+ if f"{{pkg}}.{{mod}}" not in compiled_modules
146
+ ]
147
+ return modules
148
+
149
+ class BinaryDistribution(Distribution):
150
+ def has_ext_modules(self):
151
+ return True
152
+
153
+ setup(
154
+ name="{toml_config["project"]["name"]}",
155
+ version="{toml_config["project"]["version"]}",
156
+ {f"author='{author_names}'," if author_names else ""}
157
+ {f"author_email='{author_emails}'," if author_emails else ""}
158
+ {f"description='{toml_config["project"]["description"]}'," if toml_config["project"].get("description", "") else ""}
159
+ {f"python_requires='{toml_config["project"]["requires-python"]}'," if toml_config["project"].get("requires-python") else ""}
160
+ {f"install_requires={toml_config["project"]["dependencies"]}," if toml_config["project"].get("dependencies") and len(toml_config["project"]["dependencies"]) > 0 else ""}
161
+ entry_points={{
162
+ 'console_scripts': {entry_points}
163
+ }},
164
+ packages=find_packages(where="src"),
165
+ package_dir={{"": "src"}},
166
+ include_package_data=True,
167
+ ext_modules=cythonize(
168
+ py_files,
169
+ compiler_directives={{'language_level': "3"}},
170
+ ),
171
+ distclass=BinaryDistribution,
172
+ setup_requires=["cython>=3.1"],
173
+ cmdclass={{'build_py': build_py}},
174
+ zip_safe=False
175
+ )
176
+ ''').strip()
177
+
178
+ with open(project_dir / "setup.py", 'w') as f:
179
+ f.write(setup_py_content)
pkg_deploy/deploy.py ADDED
@@ -0,0 +1,276 @@
1
+ import os
2
+ import sys
3
+ import glob
4
+ import shutil
5
+ import logging
6
+ import argparse
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from .upload import Upload, NexusUpload
11
+ from .version_managment import VersionManager
12
+ from .build import DeployConfig, CythonBuildStrategy, StandardBuildStrategy
13
+ from .utils import get_pypirc_info, get_credentials, is_uv_venv, validate_version_arg
14
+
15
+
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s [%(levelname)-5.5s] [%(name)-30.30s] [%(lineno)-4.4s] [%(processName)-12.12s]: %(message)s"
19
+ )
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def parse_args(args):
24
+ parser = argparse.ArgumentParser(
25
+ description="Modern Python Package Deployment Tool",
26
+ formatter_class=argparse.RawDescriptionHelpFormatter,
27
+ epilog="""
28
+ Examples:
29
+ # Deploy to PyPI, patch version
30
+ python deploy.py --package-name my-package --version-type patch
31
+
32
+ # Deploy to private Nexus, using cython
33
+ python deploy.py --package-name my-package --version-type minor
34
+ --repository-url https://nexus.example.com/repository/pypi-internal/
35
+ --username admin
36
+ --password secret
37
+
38
+ # Dry run
39
+ python deploy.py --package-name my-package --version-type patch --dry-run
40
+ """)
41
+
42
+ parser.add_argument(
43
+ "--project-dir",
44
+ type=Path,
45
+ default=Path.cwd(),
46
+ help="Project directory (default: current directory)"
47
+ )
48
+
49
+ parser.add_argument(
50
+ "--version-type", "-vt",
51
+ default="patch",
52
+ help="Version bump type (default: patch)"
53
+ )
54
+
55
+ parser.add_argument(
56
+ "--new-version", "-v",
57
+ type=validate_version_arg,
58
+ help="New version number, if not specified, a new version will be resolved by version-type"
59
+ )
60
+
61
+ parser.add_argument(
62
+ "--cython", "-c",
63
+ action="store_true",
64
+ help="Use Cython for compilation"
65
+ )
66
+
67
+ parser.add_argument(
68
+ "--repository-name", "-rn",
69
+ help="Repository name (.pypirc)"
70
+ )
71
+
72
+ parser.add_argument(
73
+ "--repository-url", "-rl",
74
+ help="Repository URL"
75
+ )
76
+
77
+ parser.add_argument(
78
+ "--username", "-u",
79
+ help="Username for authentication"
80
+ )
81
+
82
+ parser.add_argument(
83
+ "--password", "-p",
84
+ help="Password for authentication"
85
+ )
86
+
87
+ parser.add_argument(
88
+ "--no-git-push",
89
+ action="store_true",
90
+ help="Push local changes to Git repository after build"
91
+ )
92
+
93
+ parser.add_argument(
94
+ "--interactive", "-i",
95
+ action="store_true",
96
+ help="Force interactive credential input (useful for Nexus)"
97
+ )
98
+
99
+ parser.add_argument(
100
+ "--dry-run",
101
+ action="store_true",
102
+ help="Perform a dry run without actual deployment"
103
+ )
104
+
105
+ parser.add_argument(
106
+ "--verbose", "-V",
107
+ action="store_true",
108
+ help="Enable verbose logging"
109
+ )
110
+
111
+ args = parser.parse_args(args)
112
+ if not args.repository_url and not args.repository_name:
113
+ parser.error("Either --repository-url or --repository-name must be provided.")
114
+ return args
115
+
116
+
117
+ class PackageDeploy:
118
+ def __init__(self):
119
+ args = sys.argv[1:]
120
+ self.args = parse_args(args)
121
+ if not (self.args.project_dir / "pyproject.toml").exists():
122
+ raise ValueError("pyproject.toml not found")
123
+
124
+ if self.args.verbose:
125
+ logging.getLogger().setLevel(logging.DEBUG)
126
+
127
+ self.check_require_package(self.args.cython)
128
+
129
+ pypirc_info = get_pypirc_info()
130
+ repos = pypirc_info["repositories"]
131
+ if self.args.repository_name and self.args.repository_name in repos:
132
+ repository_info = repos[self.args.repository_name]
133
+ url = repository_info["repository"]
134
+ username = repository_info["username"]
135
+ password = repository_info["password"]
136
+ elif self.args.repository_name:
137
+ raise ValueError("Repository name is provided but not found in .pypirc")
138
+ else:
139
+ url = self.args.repository_url
140
+ username, password = get_credentials(self.args)
141
+
142
+ pyproject_path = self.args.project_dir / "pyproject.toml"
143
+
144
+ self.version_manager = VersionManager(pyproject_path)
145
+ self.config = DeployConfig(
146
+ package_name=self.version_manager.toml_config["project"]["name"],
147
+ project_dir=self.args.project_dir,
148
+ pyproject_path=pyproject_path,
149
+ version_type=self.args.version_type,
150
+ new_version=self.args.new_version,
151
+ use_cython=self.args.cython,
152
+ is_uv_venv=is_uv_venv(),
153
+ repository_name=self.args.repository_name,
154
+ repository_url=url,
155
+ username=username,
156
+ password=password,
157
+ dry_run=self.args.dry_run
158
+ )
159
+ self.setup_file_exist = (self.config.project_dir / "setup.py").exists()
160
+
161
+ def deploy(self):
162
+ logger.info("=== Deployment Configuration ===")
163
+ for arg_name, arg_value in vars(self.args).items():
164
+ display_value = arg_value
165
+ if arg_name in ('username', 'password') and arg_value is not None:
166
+ display_value = '***MASKED***'
167
+ logger.info(f"{arg_name}: {display_value}")
168
+ logger.info("=================================")
169
+ logger.info(f"Starting deployment")
170
+ try:
171
+ self.check_git_status()
172
+ new_version = self.version_manager.bump_version(version_type=self.config.version_type,
173
+ new_version=self.config.new_version)
174
+ logger.info(f"New version: {new_version}")
175
+
176
+ if self.config.use_cython:
177
+ build_strategy = CythonBuildStrategy()
178
+ else:
179
+ build_strategy = StandardBuildStrategy()
180
+
181
+ uploaded = False
182
+ if build_strategy.build(self.config, self.version_manager.toml_config):
183
+ upload_strategy = self.get_upload_strategy(self.config)
184
+ dist_dir = self.config.project_dir / "dist"
185
+ uploaded = upload_strategy.upload(self.config, dist_dir)
186
+
187
+ self.cleanup_build()
188
+ if uploaded and not self.args.no_git_push:
189
+ self.git_push(new_version=new_version)
190
+ else:
191
+ self.git_roll_back()
192
+
193
+ logger.info('Deploy completed')
194
+ except Exception as e:
195
+ logger.error(f"Deployment failed: {e}", exc_info=True)
196
+ return False
197
+
198
+ @staticmethod
199
+ def check_require_package(cython: bool):
200
+ required_packages = ["build", "twine", "toml", "tomlkit"]
201
+ if cython:
202
+ required_packages.append("Cython")
203
+
204
+ missing_packages = []
205
+ for package in required_packages:
206
+ try:
207
+ __import__(package)
208
+ except ImportError:
209
+ missing_packages.append(package)
210
+
211
+ if missing_packages:
212
+ logger.error(f"Missing required packages: {', '.join(missing_packages)}")
213
+ logger.error(f"Install them with: pip install {' '.join(missing_packages)}")
214
+ raise ValueError("Missing required packages")
215
+
216
+ def cleanup_build(self):
217
+ logger.info('Deleting build, dist and egg-info files after deployment')
218
+ shutil.rmtree('dist', ignore_errors=True)
219
+ shutil.rmtree('build', ignore_errors=True)
220
+ shutil.rmtree(f'src/{self.config.package_name}.egg-info', ignore_errors=True)
221
+ egg_info_name = self.config.package_name.replace("-", "_")
222
+ shutil.rmtree(f'src/{egg_info_name}.egg-info', ignore_errors=True)
223
+ launcher_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
224
+ directory = os.path.join(launcher_dir, 'src', self.config.package_name.replace("-", "_"))
225
+ c_files = glob.glob(os.path.join(directory, '**', '*.c'), recursive=True)
226
+ if not self.setup_file_exist:
227
+ Path("setup.py").unlink(missing_ok=True)
228
+ for file_path in c_files:
229
+ Path(file_path).unlink(missing_ok=True)
230
+
231
+ def check_git_status(self):
232
+ logger.info("Checking git status, --porcelain to make sure git repo is clean")
233
+ result = subprocess.run(
234
+ ["git", "status", "--porcelain"],
235
+ cwd=self.config.project_dir,
236
+ capture_output=True,
237
+ text=True
238
+ )
239
+ if result.returncode != 0:
240
+ raise IOError(f"Git command failed: {result.stderr.strip()}")
241
+ if result.stdout.strip():
242
+ raise IOError(f"Git repo is NOT clean: \n{result.stdout}")
243
+
244
+ @staticmethod
245
+ def git_push(new_version: str):
246
+ try:
247
+ subprocess.check_output(['git', 'add', '.'], stderr=subprocess.STDOUT)
248
+ subprocess.check_output(['git', 'commit', '-m', f'Bump version to {new_version}'], stderr=subprocess.STDOUT)
249
+ tag_name = f"v{new_version}"
250
+ subprocess.check_output(['git', 'tag', '-a', tag_name, '-m', f'Release {tag_name}'], stderr=subprocess.STDOUT)
251
+ logger.info(f"Created Git tag: {tag_name}")
252
+ subprocess.check_output(['git', 'push', '--follow-tags'], stderr=subprocess.STDOUT)
253
+ logger.info('Pushing to github')
254
+ except subprocess.CalledProcessError as ex:
255
+ logger.error(f"Git command failed: {ex.output.decode()}")
256
+ logger.warning('Failed to push bump version commit. Please push manually.')
257
+ except Exception as ex:
258
+ logger.error(f"Unexpected error: {ex}")
259
+ logger.warning('Failed to push bump version commit. Please push manually.')
260
+
261
+ @staticmethod
262
+ def git_roll_back():
263
+ try:
264
+ subprocess.check_output(['git', 'restore', '.'], stderr=subprocess.STDOUT)
265
+ logger.info('Restored changes')
266
+ except subprocess.CalledProcessError as ex:
267
+ logger.error(f"Git command failed: {ex.output.decode()}")
268
+ except Exception as ex:
269
+ logger.error(f"Unexpected error: {ex}")
270
+ logger.warning('Failed to roll back changes. Please roll back manually.')
271
+
272
+ @staticmethod
273
+ def get_upload_strategy(config) -> Upload:
274
+ return NexusUpload()
275
+
276
+
pkg_deploy/upload.py ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ import sys
3
+ import logging
4
+ import subprocess
5
+ from pathlib import Path
6
+ from abc import ABC, abstractmethod
7
+
8
+ from .build import DeployConfig
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Upload(ABC):
15
+ """Deploy Base class"""
16
+
17
+ @abstractmethod
18
+ def upload(self, config: DeployConfig, dist_dir: Path) -> bool:
19
+ pass
20
+
21
+
22
+ class NexusUpload(Upload):
23
+ """Nexus Deploy"""
24
+
25
+ @staticmethod
26
+ def get_wheel_files(config: DeployConfig):
27
+ wheel_files = []
28
+ for binary in (config.project_dir / 'dist').iterdir():
29
+ if config.package_name.replace("-", "_") in binary.name and binary.suffix == '.whl':
30
+ wheel_files.append(binary.name)
31
+ if len(wheel_files) != 1:
32
+ raise ValueError(f"Unable to determine wheel, candidates are: {wheel_files}")
33
+ wheel_file = wheel_files[0]
34
+ logger.info(f"Built {wheel_file}")
35
+ return wheel_file
36
+
37
+ def upload(self, config: DeployConfig, dist_dir: Path) -> bool:
38
+ try:
39
+ if not config.repository_url:
40
+ raise ValueError("Repository URL is required for Nexus deployment")
41
+
42
+ wheel_file = self.get_wheel_files(config)
43
+
44
+ if config.dry_run:
45
+ cmd = [sys.executable, "-m", "twine", "check",
46
+ f"dist/{wheel_file}"
47
+ ]
48
+ else:
49
+ cmd = [sys.executable, "-m", "twine", "upload",
50
+ "--repository-url", config.repository_url,
51
+ f"dist/{wheel_file}",
52
+ "--disable-progress-bar"
53
+ ]
54
+ if config.username:
55
+ cmd.extend(["--username", config.username])
56
+ if config.password:
57
+ cmd.extend(["--password", config.password])
58
+
59
+ logger.info(f"Running: {' '.join(cmd)}")
60
+ result = subprocess.run(cmd, capture_output=True, text=True)
61
+
62
+ if result.returncode != 0:
63
+ raise ValueError(f"Nexus build failed, \nstdout: {result.stdout}\nstderr: {result.stderr}")
64
+
65
+ logger.info("Package deployed to Nexus successfully")
66
+ return True
67
+
68
+ except Exception as e:
69
+ logger.error(f"Nexus deploy error: {e}")
70
+ return False
pkg_deploy/utils.py ADDED
@@ -0,0 +1,238 @@
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
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(args) -> tuple[Optional[str], Optional[str]]:
47
+ username = args.username
48
+ password = args.password
49
+ is_pypi = args.repository_name and "pypi" in args.repository_name.lower()
50
+ force_interactive = args.interactive
51
+
52
+ if is_pypi:
53
+ logger.info(" Detected PyPI repository - API tokens are recommended over passwords")
54
+
55
+ if not args.dry_run and (force_interactive or (not username and not password)):
56
+ logger.info(f"\n Repository Authentication Required")
57
+ if is_pypi:
58
+ logger.info(f"PyPI Repository: https://pypi.org/")
59
+ logger.info(" Tip: Use API tokens instead of passwords for better security")
60
+ else:
61
+ logger.info(f"Repository: {args.repository_url}")
62
+ logger.info("-" * 60)
63
+
64
+ if is_pypi:
65
+ logger.info("For PyPI API tokens, use '__token__' as username")
66
+ username_input = input("Username (default: __token__): ").strip()
67
+ username = username_input if username_input else "__token__"
68
+ else:
69
+ username_input = input("Username (default: admin): ").strip()
70
+ username = username_input if username_input else "admin"
71
+
72
+ if username:
73
+ logger.info(f"Using username: {username}")
74
+
75
+ if username == "__token__":
76
+ password = getpass.getpass("API Token (pypi-...): ")
77
+ else:
78
+ password = getpass.getpass("Password: ")
79
+
80
+ if not password:
81
+ logger.info(f"Password/Token cannot be empty")
82
+ raise ValueError("Password/Token cannot be empty")
83
+
84
+ return username, password
85
+
86
+
87
+ def parse_prerelease(version: str):
88
+ # Match the pattern: numbers.numbers.numbers + optional prerelease type + optional version
89
+ pattern = r'^(\d+)\.(\d+)\.(\d+)([abc]|rc)?(\d*)$'
90
+ match = re.match(pattern, version)
91
+
92
+ if match:
93
+ major = int(match.group(1))
94
+ minor = int(match.group(2))
95
+ patch = int(match.group(3))
96
+ prerelease_type = match.group(4) # None if no prerelease
97
+
98
+ if prerelease_type and match.group(5):
99
+ prerelease_version = int(match.group(5))
100
+ elif prerelease_type:
101
+ prerelease_version = 1
102
+ else:
103
+ prerelease_version = None
104
+
105
+ return {
106
+ 'major': major,
107
+ 'minor': minor,
108
+ 'patch': patch,
109
+ 'prerelease_type': prerelease_type,
110
+ 'prerelease_version': prerelease_version,
111
+ 'has_prerelease': prerelease_type is not None
112
+ }
113
+ else:
114
+ raise ValueError(f"Invalid version format: {version}")
115
+
116
+
117
+ def get_pypirc_info():
118
+ """
119
+ Read and parse .pypirc file from user's home directory.
120
+ Returns a dictionary with repository configurations.
121
+ """
122
+ # Get the path to .pypirc file
123
+ home_dir = Path.home()
124
+ pypirc_path = home_dir / '.pypirc'
125
+
126
+ if not pypirc_path.exists():
127
+ raise FileNotFoundError(f"No .pypirc file found at {pypirc_path}")
128
+
129
+ # Parse the configuration file
130
+ config = configparser.ConfigParser()
131
+
132
+ try:
133
+ config.read(pypirc_path)
134
+
135
+ # Extract information
136
+ pypirc_info = {}
137
+
138
+ # Get index servers if available
139
+ if config.has_section('distutils') and config.has_option('distutils', 'index-servers'):
140
+ index_servers = config.get('distutils', 'index-servers').split()
141
+ pypirc_info['index_servers'] = index_servers
142
+
143
+ # Get repository configurations
144
+ repositories = {}
145
+ for section_name in config.sections():
146
+ if section_name != 'distutils':
147
+ repo_config = {}
148
+ for option in config.options(section_name):
149
+ repo_config[option] = config.get(section_name, option)
150
+ repositories[section_name] = repo_config
151
+ if not repositories:
152
+ raise ValueError(f"No repositories configuration found in {pypirc_path}")
153
+
154
+ pypirc_info['repositories'] = repositories
155
+ return pypirc_info
156
+ except Exception as e:
157
+ logger.error(f"Error reading .pypirc file: {e}")
158
+ return None
159
+
160
+ def ensure_uv_installed():
161
+ """Check if 'uv' is available, and install it if not."""
162
+ if shutil.which("uv"):
163
+ logger.info("uv is already installed and available in PATH.")
164
+ return
165
+
166
+ try:
167
+ result = subprocess.run(
168
+ [sys.executable, "-m", "uv", "--version"],
169
+ capture_output=True,
170
+ text=True,
171
+ check=True
172
+ )
173
+ logger.info(f"uv is installed as a module: {result.stdout.strip()}")
174
+ return
175
+ except (subprocess.CalledProcessError, FileNotFoundError):
176
+ pass # uv not available as module either
177
+
178
+ logger.info("uv not found. Installing uv via pip...")
179
+ try:
180
+ subprocess.run(
181
+ [sys.executable, "-m", "pip", "install", "uv"],
182
+ check=True,
183
+ capture_output=True,
184
+ text=True
185
+ )
186
+ logger.info("uv installed successfully.")
187
+ except subprocess.CalledProcessError as e:
188
+ logger.error("Failed to install uv:", e.stderr)
189
+ raise RuntimeError(
190
+ "Failed to install uv automatically. Please install it manually:\n"
191
+ " pip install uv\n"
192
+ "Or download from: https://github.com/astral-sh/uv/releases"
193
+ ) from e
194
+
195
+ try:
196
+ result = subprocess.run(
197
+ [sys.executable, "-m", "uv", "--version"],
198
+ capture_output=True,
199
+ text=True,
200
+ check=True
201
+ )
202
+ logger.info(f"uv version: {result.stdout.strip()}")
203
+ except subprocess.CalledProcessError as e:
204
+ raise RuntimeError("uv installed but failed to run.") from e
205
+
206
+
207
+ def load_config(pyproject_path: Path) -> TOMLDocument:
208
+ """load pyproject.toml"""
209
+ if not pyproject_path.exists():
210
+ raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
211
+
212
+ with open(pyproject_path, "r", encoding="utf-8") as f:
213
+ content = f.read()
214
+ config = tomlkit.parse(content)
215
+ return config
216
+
217
+
218
+ def save_config(config, pyproject_path: Path):
219
+ with open(pyproject_path, 'w', encoding='utf-8') as f:
220
+ f.write(tomlkit.dumps(config))
221
+
222
+
223
+ def validate_version_arg(value: str) -> str:
224
+ """
225
+ Custom argparse validator that checks if a version string conforms to SemVer.
226
+ Returns the value if valid, raises an error otherwise.
227
+ """
228
+ version_pattern = re.compile(r"^\d+\.\d+\.\d+(a\d+|b\d+|rc\d+)?$")
229
+
230
+ if not version_pattern.match(value):
231
+ raise argparse.ArgumentTypeError(
232
+ f"Invalid version format: '{value}'. Valid examples:\n"
233
+ " Final release: 1.2.0\n"
234
+ " Alpha release: 1.2.0a1\n"
235
+ " Beta release: 1.2.0b1\n"
236
+ " Release candidate: 1.2.0rc1"
237
+ )
238
+ return value
@@ -0,0 +1,143 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from .utils import parse_prerelease, load_config, save_config
6
+
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class VersionManager:
12
+ """Version Manager"""
13
+
14
+ def __init__(self, pyproject_path: Path):
15
+ self.pyproject_path = pyproject_path
16
+ self.toml_config = load_config(pyproject_path)
17
+
18
+ def get_current_version(self) -> str:
19
+ return str(self.toml_config['project']['version'])
20
+
21
+ @staticmethod
22
+ def resolve_new_version(current_version: str, version_type: str) -> str:
23
+ version_info = parse_prerelease(current_version)
24
+ major = version_info['major']
25
+ minor = version_info['minor']
26
+ patch = version_info['patch']
27
+ prerelease_type = version_info['prerelease_type']
28
+ prerelease_version = version_info['prerelease_version']
29
+ has_prerelease = version_info['has_prerelease']
30
+
31
+ # Handle version bumping
32
+ if version_type == 'patch':
33
+ prerelease_type = None
34
+ patch += 1
35
+ elif version_type == 'minor':
36
+ minor += 1
37
+ patch = 0
38
+ prerelease_type = None
39
+ elif version_type == 'major':
40
+ major += 1
41
+ minor = 0
42
+ patch = 0
43
+ prerelease_type = None
44
+ elif version_type == 'alpha':
45
+ if has_prerelease:
46
+ if prerelease_type == 'a':
47
+ prerelease_version += 1
48
+ else:
49
+ prerelease_type = 'a'
50
+ prerelease_version = 1
51
+ else:
52
+ prerelease_type = 'a'
53
+ prerelease_version = 1
54
+ elif version_type == 'beta':
55
+ if has_prerelease:
56
+ if prerelease_type == 'a':
57
+ prerelease_type = 'b'
58
+ prerelease_version = 1
59
+ elif prerelease_type == 'b':
60
+ prerelease_version += 1
61
+ else:
62
+ prerelease_type = 'b'
63
+ prerelease_version = 1
64
+ else:
65
+ prerelease_type = 'b'
66
+ prerelease_version = 1
67
+ elif version_type == 'rc':
68
+ if has_prerelease:
69
+ if prerelease_type in ['a', 'b']:
70
+ prerelease_type = 'rc'
71
+ prerelease_version = 1
72
+ elif prerelease_type == 'rc':
73
+ prerelease_version += 1
74
+ else:
75
+ prerelease_type = 'rc'
76
+ prerelease_version = 1
77
+ else:
78
+ raise ValueError(f"Invalid version type: {version_type}")
79
+
80
+ if prerelease_type:
81
+ new_version = f"{major}.{minor}.{patch}{prerelease_type}{prerelease_version}"
82
+ else:
83
+ new_version = f"{major}.{minor}.{patch}"
84
+ return new_version
85
+
86
+ def bump_version(self, version_type: str, new_version: Optional[str]=None) -> str:
87
+ current_version = self.get_current_version()
88
+ if new_version is None:
89
+ new_version = self.resolve_new_version(current_version, version_type)
90
+ # Update pyproject.toml
91
+ self.toml_config['project']['version'] = new_version
92
+ save_config(self.toml_config, self.pyproject_path)
93
+ # Update files configured under [tool.bumpversion.file]
94
+ self.update_bumpversion_files(current_version, new_version)
95
+
96
+ logger.info(f"Version bumped from {current_version} to {new_version}")
97
+ return new_version
98
+
99
+ def update_bumpversion_files(self, current_version: str, new_version: str) -> None:
100
+ """Update files configured in [tool.bumpversion.file] section."""
101
+ bumpversion_config = self.toml_config.get('tool', {}).get('bumpversion', {})
102
+ files = bumpversion_config.get('file', [])
103
+
104
+ # If 'file' is a single dict, make it a list
105
+ if isinstance(files, dict):
106
+ files = [files]
107
+
108
+ for file_config in files:
109
+ filename = file_config.get('filename')
110
+ if filename == "pyproject.toml":
111
+ continue
112
+ search = file_config.get('search', '{current_version}')
113
+ replace = file_config.get('replace', '{new_version}')
114
+
115
+ if not filename:
116
+ logger.warning("Skipping bumpversion file entry with no 'filename'")
117
+ continue
118
+
119
+ file_path = Path(filename)
120
+ if not file_path.exists():
121
+ logger.warning(f"File {filename} not found, skipping.")
122
+ continue
123
+
124
+ # Read file content
125
+ content = file_path.read_text(encoding='utf-8')
126
+
127
+ # Replace placeholders
128
+ old_str = search.format(current_version=current_version)
129
+ new_str = replace.format(new_version=new_version)
130
+
131
+ # Escape for literal string replacement (not regex)
132
+ if old_str not in content:
133
+ logger.warning(f"Search pattern '{old_str}' not found in {filename}, skipping.")
134
+ continue
135
+
136
+ new_content = content.replace(old_str, new_str)
137
+
138
+ # Write back only if changed
139
+ if new_content != content:
140
+ file_path.write_text(new_content, encoding='utf-8')
141
+ logger.info(f"Updated {filename}: '{old_str}' → '{new_str}'")
142
+ else:
143
+ logger.debug(f"No changes needed in {filename}")
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: pkg-deploy
3
+ Version: 1.0.0
4
+ Summary: Modern Python Package Deployment Tool
5
+ Author: cw
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: toml
9
+ Requires-Dist: tomlkit
10
+ Requires-Dist: build
11
+ Requires-Dist: twine
12
+ Requires-Dist: Cython
13
+ Requires-Dist: setuptools>=70
14
+
15
+ # Description
@@ -0,0 +1,10 @@
1
+ pkg_deploy/__init__.py,sha256=AMFvImM9PajO30Tl53kK6lhL-tZCIq5xTCkWZPrbwUw,58
2
+ pkg_deploy/build.py,sha256=zRg_Hfz_7XRaVvp17N1WEs7dhC77Fao2cvnuh_H5PbA,7450
3
+ pkg_deploy/deploy.py,sha256=lmABig0rerySTPklcK-koBZLXhaYS5UAZ22fGgaXHns,10438
4
+ pkg_deploy/upload.py,sha256=XcB-lArBvNsijoJmrgIHkXodJh4ziUNP5xMJ2wbW7O4,2370
5
+ pkg_deploy/utils.py,sha256=pYfbeii4nvX9Adpkm_XCStKhn1fHQR5LKiMacCRAo2o,8094
6
+ pkg_deploy/version_managment.py,sha256=qLqeNlOTvwP7shnAJYwE5hzTXaCYiyUvtVJ8xsI_ivk,5499
7
+ pkg_deploy-1.0.0.dist-info/METADATA,sha256=VudheNT551T2RyLiu5KfamaWgRsdHx-eL6lp_13nXoo,343
8
+ pkg_deploy-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ pkg_deploy-1.0.0.dist-info/top_level.txt,sha256=-yi8c2TJ5e9J3QATUf7_dRfmJp2gzHR_VvnEc7QgFM0,11
10
+ pkg_deploy-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pkg_deploy