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/__init__.py
ADDED
pkg_deploy/build.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
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
|
|
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
|
+
package_dir: Path
|
|
24
|
+
package_entry: str
|
|
25
|
+
pyproject_path: Path
|
|
26
|
+
version_type: str
|
|
27
|
+
new_version: str
|
|
28
|
+
use_cython: bool
|
|
29
|
+
use_cibuildwheel: bool
|
|
30
|
+
is_uv_venv: bool
|
|
31
|
+
repository_name: str
|
|
32
|
+
repository_url: Optional[str] = None
|
|
33
|
+
username: Optional[str] = None
|
|
34
|
+
password: Optional[str] = None
|
|
35
|
+
dry_run: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BuildStrategy(ABC):
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def build_cmd(config: DeployConfig):
|
|
42
|
+
if config.use_cibuildwheel:
|
|
43
|
+
cmd = ['cibuildwheel', '--output-dir', 'dist']
|
|
44
|
+
elif is_uv_venv():
|
|
45
|
+
ensure_uv_installed()
|
|
46
|
+
cmd = ["uv", "build", "--wheel"]
|
|
47
|
+
else:
|
|
48
|
+
cmd = [sys.executable, "-m", "build", "--wheel"]
|
|
49
|
+
return cmd
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def build(self, config: DeployConfig, toml_config: TOMLDocument) -> bool:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class StandardBuildStrategy(BuildStrategy):
|
|
57
|
+
|
|
58
|
+
def build(self, config: DeployConfig, toml_config: TOMLDocument) -> bool:
|
|
59
|
+
cmd = self.build_cmd(config=config)
|
|
60
|
+
logger.info(f"Running: {' '.join(cmd)}")
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
cmd,
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
encoding="utf-8",
|
|
66
|
+
cwd=config.project_dir
|
|
67
|
+
)
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
raise ValueError(f"Build failed, \nstdout: {result.stdout}\nstderr: {result.stderr}")
|
|
70
|
+
logger.info("Standard build completed successfully")
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
class CythonBuildStrategy(BuildStrategy):
|
|
74
|
+
|
|
75
|
+
def build(self, config: DeployConfig, toml_config: TOMLDocument) -> bool:
|
|
76
|
+
try:
|
|
77
|
+
self.prepare_pyproject_for_cython_build(config.project_dir, toml_config)
|
|
78
|
+
self.create_setup_py_for_cython(config, toml_config)
|
|
79
|
+
cmd = self.build_cmd(config=config)
|
|
80
|
+
logger.info(f"Running Cython build: {' '.join(cmd)}")
|
|
81
|
+
env = os.environ.copy()
|
|
82
|
+
env['CYTHONIZE'] = '1'
|
|
83
|
+
env['PYTHONIOENCODING'] = 'utf-8'
|
|
84
|
+
env['PYTHONUTF8'] = '1'
|
|
85
|
+
env["CIBW_BUILD_VERBOSITY"] = "1"
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
env=env,
|
|
92
|
+
cwd=config.project_dir
|
|
93
|
+
)
|
|
94
|
+
if result.returncode != 0:
|
|
95
|
+
raise ValueError(f"Cython build failed, \nstdout: {result.stdout}\nstderr: {result.stderr}")
|
|
96
|
+
logger.info("Cython build completed successfully")
|
|
97
|
+
return True
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"Cython build error: {e}")
|
|
100
|
+
return False
|
|
101
|
+
finally:
|
|
102
|
+
if not config.dry_run:
|
|
103
|
+
self.restore_pyproject_toml(project_dir=config.project_dir, original_toml_config=toml_config)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def prepare_pyproject_for_cython_build(project_dir: Path, toml_config: TOMLDocument):
|
|
107
|
+
pyproject_path = project_dir / "pyproject.toml"
|
|
108
|
+
if pyproject_path.exists():
|
|
109
|
+
new_config = copy.deepcopy(toml_config)
|
|
110
|
+
|
|
111
|
+
# Add Cython build dependency
|
|
112
|
+
if 'build-system' not in new_config:
|
|
113
|
+
new_config['build-system'] = {}
|
|
114
|
+
|
|
115
|
+
if 'requires' not in new_config['build-system']:
|
|
116
|
+
new_config['build-system']['requires'] = []
|
|
117
|
+
|
|
118
|
+
cython_deps = ['setuptools', 'Cython']
|
|
119
|
+
requires = new_config['build-system']['requires']
|
|
120
|
+
for dep in cython_deps:
|
|
121
|
+
if not any(req.startswith(dep) for req in requires):
|
|
122
|
+
new_config['build-system']['requires'].append(dep)
|
|
123
|
+
|
|
124
|
+
if 'build-backend' not in new_config['build-system'] or new_config['build-system'][
|
|
125
|
+
'build-backend'] != 'setuptools.build_meta':
|
|
126
|
+
new_config['build-system']['build-backend'] = 'setuptools.build_meta'
|
|
127
|
+
save_config(new_config, pyproject_path)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def restore_pyproject_toml(project_dir: Path, original_toml_config: TOMLDocument):
|
|
131
|
+
pyproject_path = project_dir / "pyproject.toml"
|
|
132
|
+
if original_toml_config:
|
|
133
|
+
save_config(original_toml_config, pyproject_path)
|
|
134
|
+
logger.info("Restored original pyproject.toml")
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def create_setup_py_for_cython(config: DeployConfig, toml_config: TOMLDocument):
|
|
138
|
+
setup_py_path = config.project_dir / "setup.py"
|
|
139
|
+
if setup_py_path.exists():
|
|
140
|
+
# Check if the existing setup.py already uses cythonize
|
|
141
|
+
with open(setup_py_path, 'r', encoding='utf-8') as f:
|
|
142
|
+
setup_content = f.read()
|
|
143
|
+
|
|
144
|
+
if 'cythonize' in setup_content:
|
|
145
|
+
logger.info(f"Using existing setup.py with cythonize at {setup_py_path}")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# If cythonize is not present, raise an error
|
|
149
|
+
raise FileExistsError(
|
|
150
|
+
f"Cannot build Cython code: setup.py already exists at {setup_py_path}\n"
|
|
151
|
+
f"\n"
|
|
152
|
+
f"In Cython build mode, this tool generates its own setup.py file optimized for "
|
|
153
|
+
f"Cython compilation. An existing setup.py would be overwritten, potentially "
|
|
154
|
+
f"causing build errors or losing your custom configuration.\n"
|
|
155
|
+
f"\n"
|
|
156
|
+
f"To proceed with Cython build:\n"
|
|
157
|
+
f" 1. Migrate your setup.py settings to pyproject.toml:\n"
|
|
158
|
+
f" - Move dependencies to [project.dependencies]\n"
|
|
159
|
+
f" - Move metadata (name, version, description) to [project]\n"
|
|
160
|
+
f" - Move entry points to [project.scripts]\n"
|
|
161
|
+
f" - Move build configuration to [build-system] or [tool] sections\n"
|
|
162
|
+
f" 2. Back up your current setup.py: mv setup.py setup.py.backup\n"
|
|
163
|
+
f" 3. Then retry the Cython build\n"
|
|
164
|
+
f"\n"
|
|
165
|
+
f"Alternative (quick start):\n"
|
|
166
|
+
f" - Just backup setup.py now: mv setup.py setup.py.backup\n"
|
|
167
|
+
f" - Retry the build, then migrate settings later by comparing\n"
|
|
168
|
+
f" setup.py.backup with the generated pyproject.toml\n"
|
|
169
|
+
f"\n"
|
|
170
|
+
f"Note: Modern Python projects use pyproject.toml (PEP 518/621) for "
|
|
171
|
+
f"configuration instead of setup.py. This approach provides better "
|
|
172
|
+
f"tooling integration and is the recommended standard."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if "authors" in toml_config["project"]:
|
|
176
|
+
author_names = ", ".join(p["name"] for p in toml_config["project"]["authors"] if "name" in p)
|
|
177
|
+
author_emails = ", ".join(p["email"] for p in toml_config["project"]["authors"] if "email" in p)
|
|
178
|
+
else:
|
|
179
|
+
author_names = ""
|
|
180
|
+
author_emails = ""
|
|
181
|
+
if "scripts" in toml_config["project"]:
|
|
182
|
+
entry_points = [f"{k}={v}" for k, v in toml_config["project"]["scripts"].items()]
|
|
183
|
+
else:
|
|
184
|
+
entry_points = []
|
|
185
|
+
setup_py_content = textwrap.dedent(f'''
|
|
186
|
+
import glob
|
|
187
|
+
from Cython.Build import cythonize
|
|
188
|
+
from setuptools import setup, find_packages
|
|
189
|
+
from setuptools.dist import Distribution
|
|
190
|
+
from setuptools.command.build_py import build_py as _build_py
|
|
191
|
+
|
|
192
|
+
py_files = glob.glob("{config.package_entry}/**/*.py", recursive=True)
|
|
193
|
+
py_files = [f for f in py_files if not f.endswith("__init__.py")]
|
|
194
|
+
|
|
195
|
+
class build_py(_build_py):
|
|
196
|
+
def find_package_modules(self, package, package_dir):
|
|
197
|
+
modules = super().find_package_modules(package, package_dir)
|
|
198
|
+
if self.distribution.ext_modules:
|
|
199
|
+
# Get the list of compiled module names
|
|
200
|
+
compiled_modules = {{ext.name for ext in self.distribution.ext_modules}}
|
|
201
|
+
# Filter out the modules that are compiled
|
|
202
|
+
modules = [
|
|
203
|
+
(pkg, mod, file) for (pkg, mod, file) in modules
|
|
204
|
+
if f"{{pkg}}.{{mod}}" not in compiled_modules
|
|
205
|
+
]
|
|
206
|
+
return modules
|
|
207
|
+
|
|
208
|
+
class BinaryDistribution(Distribution):
|
|
209
|
+
def has_ext_modules(self):
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
setup(
|
|
213
|
+
name="{toml_config["project"]["name"]}",
|
|
214
|
+
version="{toml_config["project"]["version"]}",
|
|
215
|
+
{f"author='{author_names}'," if author_names else ""}
|
|
216
|
+
{f"author_email='{author_emails}'," if author_emails else ""}
|
|
217
|
+
{f"description='{toml_config['project']['description']}'," if toml_config["project"].get("description", "") else ""}
|
|
218
|
+
{f"python_requires='{toml_config['project']['requires-python']}'," if toml_config["project"].get("requires-python") else ""}
|
|
219
|
+
{f"install_requires={toml_config['project']['dependencies']}," if toml_config["project"].get("dependencies") and len(toml_config["project"]["dependencies"]) > 0 else ""}
|
|
220
|
+
entry_points={{
|
|
221
|
+
'console_scripts': {entry_points}
|
|
222
|
+
}},
|
|
223
|
+
packages=find_packages(where="{config.package_entry}"),
|
|
224
|
+
package_dir={{"": "{config.package_entry}"}},
|
|
225
|
+
include_package_data=True,
|
|
226
|
+
ext_modules=cythonize(
|
|
227
|
+
py_files,
|
|
228
|
+
compiler_directives={{'language_level': "3"}},
|
|
229
|
+
),
|
|
230
|
+
distclass=BinaryDistribution,
|
|
231
|
+
setup_requires=["cython>=3.1"],
|
|
232
|
+
cmdclass={{'build_py': build_py}},
|
|
233
|
+
zip_safe=False
|
|
234
|
+
)
|
|
235
|
+
''').strip()
|
|
236
|
+
|
|
237
|
+
with open(config.project_dir / "setup.py", 'w', encoding='utf-8') as f:
|
|
238
|
+
f.write(setup_py_content)
|
pkg_deploy/deploy.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
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
|
+
from tomlkit import TOMLDocument
|
|
10
|
+
|
|
11
|
+
from .upload import Upload, NexusUpload
|
|
12
|
+
from .version_managment import VersionManager
|
|
13
|
+
from .build import DeployConfig, CythonBuildStrategy, StandardBuildStrategy
|
|
14
|
+
from .utils import get_pypirc_info, get_credentials, is_uv_venv, validate_version_arg, load_config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=logging.INFO,
|
|
19
|
+
format="%(asctime)s [%(levelname)-5.5s] [%(name)-30.30s] [%(lineno)-4.4s] [%(processName)-12.12s]: %(message)s"
|
|
20
|
+
)
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_args(args):
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
description="Modern Python Package Deployment Tool",
|
|
27
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
28
|
+
epilog="""
|
|
29
|
+
Examples:
|
|
30
|
+
# Deploy to PyPI, patch version
|
|
31
|
+
python deploy.py --package-name my-package --version-type patch
|
|
32
|
+
|
|
33
|
+
# Deploy to private Nexus, using cython
|
|
34
|
+
python deploy.py --package-name my-package --version-type minor
|
|
35
|
+
--repository-url https://nexus.example.com/repository/pypi-internal/
|
|
36
|
+
--username admin
|
|
37
|
+
--password secret
|
|
38
|
+
|
|
39
|
+
# Dry run
|
|
40
|
+
python deploy.py --package-name my-package --version-type patch --dry-run
|
|
41
|
+
""")
|
|
42
|
+
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--project-dir",
|
|
45
|
+
type=Path,
|
|
46
|
+
default=Path.cwd(),
|
|
47
|
+
help="Project directory (default: current directory)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--package-dir",
|
|
52
|
+
type=Path,
|
|
53
|
+
default=None,
|
|
54
|
+
help="Package directory (default: current directory)"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--version-type", "-vt",
|
|
59
|
+
default="patch",
|
|
60
|
+
help="Version bump type (default: patch)",
|
|
61
|
+
choices=["major", "minor", "patch", "alpha", "beta", "rc"]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--new-version", "-v",
|
|
66
|
+
type=validate_version_arg,
|
|
67
|
+
help="New version number, if not specified, a new version will be resolved by version-type"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--cython", "-c",
|
|
72
|
+
action="store_true",
|
|
73
|
+
help="Use Cython for compilation"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--cibuildwheel",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="Use cibuildwheel to build cython code"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--repository-name", "-rn",
|
|
84
|
+
help="Repository name (.pypirc)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--repository-url", "-ru",
|
|
89
|
+
help="Repository URL"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--username", "-u",
|
|
94
|
+
help="Username for authentication"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--password", "-p",
|
|
99
|
+
help="Password for authentication"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--skip-git-push",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="Don't push version changes and new tag to Git repository after build"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--skip-git-status-check",
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="Skip git status check before deployment"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--dry-run",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="Perform a dry run without actual deployment"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--verbose", "-V",
|
|
122
|
+
action="store_true",
|
|
123
|
+
help="Enable verbose logging"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
args = parser.parse_args(args)
|
|
127
|
+
if not args.repository_url and not args.repository_name:
|
|
128
|
+
parser.error("Either --repository-url or --repository-name must be provided.")
|
|
129
|
+
return args
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class PackageDeploy:
|
|
133
|
+
def __init__(self):
|
|
134
|
+
args = sys.argv[1:]
|
|
135
|
+
self.args = parse_args(args)
|
|
136
|
+
if not (self.args.project_dir / "pyproject.toml").exists():
|
|
137
|
+
raise ValueError(f"pyproject.toml not found under project directory: {self.args.project_dir}")
|
|
138
|
+
else:
|
|
139
|
+
pyproject_path = self.args.project_dir / "pyproject.toml"
|
|
140
|
+
|
|
141
|
+
if self.args.verbose:
|
|
142
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
143
|
+
|
|
144
|
+
self.check_require_package(self.args.cython)
|
|
145
|
+
|
|
146
|
+
url, username, password = self.get_twine_upload_info()
|
|
147
|
+
toml_config = load_config(pyproject_path)
|
|
148
|
+
package_dir = self.resolve_package_dir(toml_config)
|
|
149
|
+
|
|
150
|
+
self.version_manager = VersionManager(pyproject_path, toml_config)
|
|
151
|
+
self.config = DeployConfig(
|
|
152
|
+
package_name=toml_config["project"]["name"],
|
|
153
|
+
project_dir=self.args.project_dir,
|
|
154
|
+
package_dir=package_dir,
|
|
155
|
+
package_entry=package_dir.name,
|
|
156
|
+
pyproject_path=pyproject_path,
|
|
157
|
+
version_type=self.args.version_type,
|
|
158
|
+
new_version=self.args.new_version,
|
|
159
|
+
use_cython=self.args.cython,
|
|
160
|
+
use_cibuildwheel=self.args.cibuildwheel,
|
|
161
|
+
is_uv_venv=is_uv_venv(),
|
|
162
|
+
repository_name=self.args.repository_name,
|
|
163
|
+
repository_url=url,
|
|
164
|
+
username=username,
|
|
165
|
+
password=password,
|
|
166
|
+
dry_run=self.args.dry_run
|
|
167
|
+
)
|
|
168
|
+
self.setup_file_exist = (self.config.project_dir / "setup.py").exists()
|
|
169
|
+
|
|
170
|
+
def deploy(self):
|
|
171
|
+
logger.info("=== Deployment Configuration ===")
|
|
172
|
+
for field, value in vars(self.config).items():
|
|
173
|
+
if field == "password":
|
|
174
|
+
logger.info(f"{field}: ***MASKED***")
|
|
175
|
+
else:
|
|
176
|
+
logger.info(f"{field}: {value}")
|
|
177
|
+
logger.info("=================================")
|
|
178
|
+
|
|
179
|
+
if self.config.dry_run:
|
|
180
|
+
logger.info("DRY RUN: Starting deployment simulation")
|
|
181
|
+
else:
|
|
182
|
+
logger.info(f"Starting deployment")
|
|
183
|
+
|
|
184
|
+
if not self.args.skip_git_status_check:
|
|
185
|
+
self.check_git_status()
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
new_version = self.version_manager.bump_version(
|
|
189
|
+
version_type=self.config.version_type,
|
|
190
|
+
new_version=self.config.new_version,
|
|
191
|
+
dry_run=self.config.dry_run
|
|
192
|
+
)
|
|
193
|
+
if self.config.dry_run:
|
|
194
|
+
logger.info(f"DRY RUN: Would bump version to: {new_version}")
|
|
195
|
+
else:
|
|
196
|
+
logger.info(f"New version: {new_version}")
|
|
197
|
+
|
|
198
|
+
if self.config.use_cython:
|
|
199
|
+
build_strategy = CythonBuildStrategy()
|
|
200
|
+
else:
|
|
201
|
+
build_strategy = StandardBuildStrategy()
|
|
202
|
+
|
|
203
|
+
uploaded = False
|
|
204
|
+
if build_strategy.build(self.config, self.version_manager.toml_config):
|
|
205
|
+
upload_strategy = self.get_upload_strategy(self.config)
|
|
206
|
+
dist_dir = self.config.project_dir / "dist"
|
|
207
|
+
uploaded = upload_strategy.upload(self.config, dist_dir)
|
|
208
|
+
|
|
209
|
+
self.cleanup_build_files()
|
|
210
|
+
|
|
211
|
+
if uploaded and not self.args.skip_git_push:
|
|
212
|
+
self.git_push(new_version=new_version, dry_run=self.config.dry_run)
|
|
213
|
+
else:
|
|
214
|
+
self.git_roll_back()
|
|
215
|
+
logger.info('Deploy completed')
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Deployment failed, rolling back: {e}", exc_info=True)
|
|
218
|
+
self.git_roll_back()
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def get_twine_upload_info(self):
|
|
222
|
+
pypirc_info = get_pypirc_info()
|
|
223
|
+
repos = pypirc_info["repositories"]
|
|
224
|
+
if self.args.repository_name == "pypi":
|
|
225
|
+
url = None
|
|
226
|
+
username = "__token__"
|
|
227
|
+
password = None
|
|
228
|
+
if "pypi" in repos:
|
|
229
|
+
password = repos["pypi"].get("password")
|
|
230
|
+
if not password:
|
|
231
|
+
_, password = get_credentials(username=username, is_pypi=True)
|
|
232
|
+
elif self.args.repository_name and self.args.repository_name in repos:
|
|
233
|
+
repository_info = repos[self.args.repository_name]
|
|
234
|
+
url = repository_info.get("repository")
|
|
235
|
+
username = repository_info.get("username")
|
|
236
|
+
password = repository_info.get("password")
|
|
237
|
+
if not url:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"Repository '{self.args.repository_name}' must have a 'repository' url section in .pypirc"
|
|
240
|
+
f"Only 'pypi' can omit the repository URL.")
|
|
241
|
+
if not username or not password:
|
|
242
|
+
username, password = get_credentials(
|
|
243
|
+
username=username,
|
|
244
|
+
password=password,
|
|
245
|
+
url=url
|
|
246
|
+
)
|
|
247
|
+
elif self.args.repository_name and not self.args.repository_url:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f"Repository '{self.args.repository_name}' not found in .pypirc. "
|
|
250
|
+
f"Please provide --repository-url or add required info in .pypirc"
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
url = self.args.repository_url
|
|
254
|
+
username, password = get_credentials(username=self.args.username,
|
|
255
|
+
password=self.args.password,
|
|
256
|
+
url=url)
|
|
257
|
+
return url, username, password
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def check_require_package(cython: bool):
|
|
261
|
+
required_packages = ["build", "twine", "toml", "tomlkit"]
|
|
262
|
+
if cython:
|
|
263
|
+
required_packages.append("Cython")
|
|
264
|
+
|
|
265
|
+
missing_packages = []
|
|
266
|
+
for package in required_packages:
|
|
267
|
+
try:
|
|
268
|
+
__import__(package)
|
|
269
|
+
except ImportError:
|
|
270
|
+
missing_packages.append(package)
|
|
271
|
+
|
|
272
|
+
if missing_packages:
|
|
273
|
+
logger.error(f"Missing required packages: {', '.join(missing_packages)}")
|
|
274
|
+
logger.error(f"Install them with: pip install {' '.join(missing_packages)}")
|
|
275
|
+
raise ValueError("Missing required packages")
|
|
276
|
+
|
|
277
|
+
def resolve_package_dir(self, toml_config: TOMLDocument) -> Path:
|
|
278
|
+
package_dir = self.args.project_dir / toml_config["project"]["name"].replace("-", "_")
|
|
279
|
+
if self.args.package_dir is not None:
|
|
280
|
+
package_dir = self.args.package_dir
|
|
281
|
+
else:
|
|
282
|
+
pkg_dir_candidates = []
|
|
283
|
+
|
|
284
|
+
# Safely extract 'packages.find.where'
|
|
285
|
+
where = (
|
|
286
|
+
toml_config
|
|
287
|
+
.get("tool", {})
|
|
288
|
+
.get("setuptools", {})
|
|
289
|
+
.get("packages", {})
|
|
290
|
+
.get("find", {})
|
|
291
|
+
.get("where")
|
|
292
|
+
)
|
|
293
|
+
if where and isinstance(where, list):
|
|
294
|
+
pkg_dir_candidates.append(where[0])
|
|
295
|
+
elif where is not None:
|
|
296
|
+
logger.warning("'tool.setuptools.packages.find.where' is not a list; skipping.")
|
|
297
|
+
|
|
298
|
+
# Safely extract first value from 'package-dir' dict
|
|
299
|
+
package_dir_map = (
|
|
300
|
+
toml_config
|
|
301
|
+
.get("tool", {})
|
|
302
|
+
.get("setuptools", {})
|
|
303
|
+
.get("package-dir")
|
|
304
|
+
)
|
|
305
|
+
if package_dir_map and isinstance(package_dir_map, dict) and package_dir_map:
|
|
306
|
+
first_value = next(iter(package_dir_map.values()))
|
|
307
|
+
if isinstance(first_value, str):
|
|
308
|
+
pkg_dir_candidates.append(first_value)
|
|
309
|
+
else:
|
|
310
|
+
logger.warning("'tool.setuptools.package-dir' values should be strings; skipping.")
|
|
311
|
+
|
|
312
|
+
if len(set(pkg_dir_candidates)) > 1:
|
|
313
|
+
logger.warning(f"Package directory from toml are not the same: {pkg_dir_candidates}, use the default directory: {package_dir}")
|
|
314
|
+
elif len(pkg_dir_candidates) == 0:
|
|
315
|
+
logger.warning(f"No entry point find, use the default directory: {package_dir}")
|
|
316
|
+
else:
|
|
317
|
+
package_dir = self.args.project_dir / pkg_dir_candidates[0]
|
|
318
|
+
|
|
319
|
+
if not package_dir.exists():
|
|
320
|
+
raise FileNotFoundError(f"Failed to resolve package directory, directory not found: {package_dir}")
|
|
321
|
+
return package_dir
|
|
322
|
+
|
|
323
|
+
def cleanup_build_files(self):
|
|
324
|
+
logger.info('Deleting build, dist and egg-info files after deployment')
|
|
325
|
+
shutil.rmtree('dist', ignore_errors=True)
|
|
326
|
+
shutil.rmtree('build', ignore_errors=True)
|
|
327
|
+
shutil.rmtree(f'{self.config.package_dir}/{self.config.package_name}.egg-info', ignore_errors=True)
|
|
328
|
+
egg_info_name = self.config.package_name.replace("-", "_")
|
|
329
|
+
shutil.rmtree(f'{self.config.package_dir}/{egg_info_name}.egg-info', ignore_errors=True)
|
|
330
|
+
directory = self.config.package_dir
|
|
331
|
+
logger.debug(f"The directory is: {directory}")
|
|
332
|
+
c_files = glob.glob(os.path.join(directory, '**', '*.c'), recursive=True)
|
|
333
|
+
if not self.setup_file_exist:
|
|
334
|
+
Path("setup.py").unlink(missing_ok=True)
|
|
335
|
+
if c_files:
|
|
336
|
+
logger.info(f"Cleaning up c files: {c_files}")
|
|
337
|
+
for file_path in c_files:
|
|
338
|
+
Path(file_path).unlink(missing_ok=True)
|
|
339
|
+
|
|
340
|
+
def check_git_status(self):
|
|
341
|
+
logger.info("Checking git status, --porcelain to make sure git repo is clean")
|
|
342
|
+
result = subprocess.run(
|
|
343
|
+
["git", "status", "--porcelain"],
|
|
344
|
+
cwd=self.config.project_dir,
|
|
345
|
+
capture_output=True,
|
|
346
|
+
text=True
|
|
347
|
+
)
|
|
348
|
+
if result.returncode != 0:
|
|
349
|
+
raise IOError(f"Git command failed: {result.stderr.strip()}")
|
|
350
|
+
if result.stdout.strip():
|
|
351
|
+
raise IOError(f"Git repo is NOT clean: \n{result.stdout}")
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def git_push(new_version: str, dry_run: bool = False):
|
|
355
|
+
try:
|
|
356
|
+
if dry_run:
|
|
357
|
+
logger.info("DRY RUN: Would run: git add .")
|
|
358
|
+
logger.info(f"DRY RUN: Would run: git commit -m 'Bump version to {new_version}'")
|
|
359
|
+
tag_name = f"v{new_version}"
|
|
360
|
+
logger.info(f"DRY RUN: Would create Git tag: {tag_name}")
|
|
361
|
+
logger.info("DRY RUN: Would run: git push --follow-tags")
|
|
362
|
+
logger.info('DRY RUN: Git push simulation completed')
|
|
363
|
+
else:
|
|
364
|
+
subprocess.check_output(['git', 'add', '.'], stderr=subprocess.STDOUT)
|
|
365
|
+
subprocess.check_output(['git', 'commit', '-m', f'Bump version to {new_version}'], stderr=subprocess.STDOUT)
|
|
366
|
+
tag_name = f"v{new_version}"
|
|
367
|
+
|
|
368
|
+
# Check if the tag already exists
|
|
369
|
+
result = subprocess.run(['git', 'tag', '-l', tag_name], capture_output=True, text=True)
|
|
370
|
+
if result.stdout.strip():
|
|
371
|
+
logger.warning(f"Warning: Git tag {tag_name} already exists, skipping tag creation")
|
|
372
|
+
else:
|
|
373
|
+
subprocess.check_output(['git', 'tag', '-a', tag_name, '-m', f'Release {tag_name}'], stderr=subprocess.STDOUT)
|
|
374
|
+
logger.info(f"Created Git tag: {tag_name}")
|
|
375
|
+
|
|
376
|
+
subprocess.check_output(['git', 'push', '--follow-tags'], stderr=subprocess.STDOUT)
|
|
377
|
+
logger.info('Pushing to github')
|
|
378
|
+
except subprocess.CalledProcessError as ex:
|
|
379
|
+
logger.error(f"Git command failed: {ex.output.decode()}")
|
|
380
|
+
logger.warning('Failed to push bump version commit. Please push manually.')
|
|
381
|
+
raise
|
|
382
|
+
except Exception as ex:
|
|
383
|
+
logger.error(f"Unexpected error: {ex}")
|
|
384
|
+
logger.warning('Failed to push bump version commit. Please push manually.')
|
|
385
|
+
raise
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def git_roll_back():
|
|
389
|
+
try:
|
|
390
|
+
subprocess.check_output(['git', 'restore', '.'], stderr=subprocess.STDOUT)
|
|
391
|
+
subprocess.check_output(['git', 'restore', '--staged', '.'], stderr=subprocess.STDOUT)
|
|
392
|
+
subprocess.check_output(['git', 'clean', '-fd'], stderr=subprocess.STDOUT)
|
|
393
|
+
logger.info('Restored changes')
|
|
394
|
+
except subprocess.CalledProcessError as ex:
|
|
395
|
+
logger.error(f"Git command failed: {ex.output.decode()}")
|
|
396
|
+
except Exception as ex:
|
|
397
|
+
logger.error(f"Unexpected error: {ex}")
|
|
398
|
+
logger.warning('Failed to roll back changes. Please roll back manually.')
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def get_upload_strategy(config) -> Upload:
|
|
402
|
+
return NexusUpload()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def main():
|
|
406
|
+
PackageDeploy().deploy()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
main()
|