arthexis 0.1.9__py3-none-any.whl → 0.1.26__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- arthexis-0.1.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
core/release.py
CHANGED
|
@@ -1,346 +1,940 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
""
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Sequence
|
|
13
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
14
|
+
|
|
15
|
+
try: # pragma: no cover - optional dependency
|
|
16
|
+
import toml # type: ignore
|
|
17
|
+
except Exception: # pragma: no cover - fallback when missing
|
|
18
|
+
toml = None # type: ignore
|
|
19
|
+
|
|
20
|
+
try: # pragma: no cover - optional dependency
|
|
21
|
+
import requests # type: ignore
|
|
22
|
+
except Exception: # pragma: no cover - fallback when missing
|
|
23
|
+
requests = None # type: ignore
|
|
24
|
+
|
|
25
|
+
from config.offline import requires_network, network_available
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_PACKAGE_MODULES = [
|
|
29
|
+
"core",
|
|
30
|
+
"config",
|
|
31
|
+
"nodes",
|
|
32
|
+
"ocpp",
|
|
33
|
+
"pages",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Package:
|
|
39
|
+
"""Metadata for building a distributable package."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
description: str
|
|
43
|
+
author: str
|
|
44
|
+
email: str
|
|
45
|
+
python_requires: str
|
|
46
|
+
license: str
|
|
47
|
+
repository_url: str = "https://github.com/arthexis/arthexis"
|
|
48
|
+
homepage_url: str = "https://arthexis.com"
|
|
49
|
+
packages: Sequence[str] = tuple(DEFAULT_PACKAGE_MODULES)
|
|
50
|
+
version_path: Optional[Path | str] = None
|
|
51
|
+
dependencies_path: Optional[Path | str] = None
|
|
52
|
+
test_command: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Credentials:
|
|
57
|
+
"""Credentials for uploading to PyPI."""
|
|
58
|
+
|
|
59
|
+
token: Optional[str] = None
|
|
60
|
+
username: Optional[str] = None
|
|
61
|
+
password: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
def has_auth(self) -> bool:
|
|
64
|
+
return bool(self.token) or bool(self.username and self.password)
|
|
65
|
+
|
|
66
|
+
def twine_args(self) -> list[str]:
|
|
67
|
+
if self.token:
|
|
68
|
+
return ["--username", "__token__", "--password", self.token]
|
|
69
|
+
if self.username and self.password:
|
|
70
|
+
return ["--username", self.username, "--password", self.password]
|
|
71
|
+
raise ValueError("Missing PyPI credentials")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class GitCredentials:
|
|
76
|
+
"""Credentials used for Git operations such as pushing tags."""
|
|
77
|
+
|
|
78
|
+
username: Optional[str] = None
|
|
79
|
+
password: Optional[str] = None
|
|
80
|
+
|
|
81
|
+
def has_auth(self) -> bool:
|
|
82
|
+
return bool((self.username or "").strip() and (self.password or "").strip())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class RepositoryTarget:
|
|
87
|
+
"""Configuration for uploading a distribution to a repository."""
|
|
88
|
+
|
|
89
|
+
name: str
|
|
90
|
+
repository_url: Optional[str] = None
|
|
91
|
+
credentials: Optional[Credentials] = None
|
|
92
|
+
verify_availability: bool = False
|
|
93
|
+
extra_args: Sequence[str] = ()
|
|
94
|
+
|
|
95
|
+
def build_command(self, files: Sequence[str]) -> list[str]:
|
|
96
|
+
cmd = [sys.executable, "-m", "twine", "upload", *self.extra_args]
|
|
97
|
+
if self.repository_url:
|
|
98
|
+
cmd += ["--repository-url", self.repository_url]
|
|
99
|
+
cmd += list(files)
|
|
100
|
+
return cmd
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
DEFAULT_PACKAGE = Package(
|
|
104
|
+
name="arthexis",
|
|
105
|
+
description="Power & Energy Infrastructure",
|
|
106
|
+
author="Rafael J. Guillén-Osorio",
|
|
107
|
+
email="tecnologia@gelectriic.com",
|
|
108
|
+
python_requires=">=3.10",
|
|
109
|
+
license="GPL-3.0-only",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ReleaseError(Exception):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PostPublishWarning(ReleaseError):
|
|
118
|
+
"""Raised when distribution uploads succeed but post-publish tasks need attention."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
message: str,
|
|
123
|
+
*,
|
|
124
|
+
uploaded: Sequence[str],
|
|
125
|
+
followups: Optional[Sequence[str]] = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
super().__init__(message)
|
|
128
|
+
self.uploaded = list(uploaded)
|
|
129
|
+
self.followups = list(followups or [])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestsFailed(ReleaseError):
|
|
133
|
+
"""Raised when the test suite fails.
|
|
134
|
+
|
|
135
|
+
Attributes:
|
|
136
|
+
log_path: Location of the saved test log.
|
|
137
|
+
output: Combined stdout/stderr from the test run.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, log_path: Path, output: str):
|
|
141
|
+
super().__init__("Tests failed")
|
|
142
|
+
self.log_path = log_path
|
|
143
|
+
self.output = output
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _run(
|
|
147
|
+
cmd: list[str],
|
|
148
|
+
check: bool = True,
|
|
149
|
+
*,
|
|
150
|
+
cwd: Path | str | None = None,
|
|
151
|
+
) -> subprocess.CompletedProcess:
|
|
152
|
+
return subprocess.run(cmd, check=check, cwd=cwd)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _export_tracked_files(base_dir: Path, destination: Path) -> None:
|
|
156
|
+
"""Copy tracked files into ``destination`` preserving modifications."""
|
|
157
|
+
|
|
158
|
+
proc = subprocess.run(
|
|
159
|
+
["git", "ls-files", "-z"],
|
|
160
|
+
capture_output=True,
|
|
161
|
+
check=True,
|
|
162
|
+
cwd=base_dir,
|
|
163
|
+
)
|
|
164
|
+
for entry in proc.stdout.split(b"\0"):
|
|
165
|
+
if not entry:
|
|
166
|
+
continue
|
|
167
|
+
relative = Path(entry.decode("utf-8"))
|
|
168
|
+
source_path = base_dir / relative
|
|
169
|
+
if not source_path.exists():
|
|
170
|
+
continue
|
|
171
|
+
target_path = destination / relative
|
|
172
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
shutil.copy2(source_path, target_path)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _build_in_sanitized_tree(base_dir: Path) -> None:
|
|
177
|
+
"""Run ``python -m build`` from a staging tree containing tracked files."""
|
|
178
|
+
|
|
179
|
+
with tempfile.TemporaryDirectory(prefix="arthexis-build-") as temp_dir:
|
|
180
|
+
staging_root = Path(temp_dir)
|
|
181
|
+
_export_tracked_files(base_dir, staging_root)
|
|
182
|
+
_run([sys.executable, "-m", "build"], cwd=staging_root)
|
|
183
|
+
built_dist = staging_root / "dist"
|
|
184
|
+
if not built_dist.exists():
|
|
185
|
+
raise ReleaseError("dist directory not created")
|
|
186
|
+
destination_dist = base_dir / "dist"
|
|
187
|
+
if destination_dist.exists():
|
|
188
|
+
shutil.rmtree(destination_dist)
|
|
189
|
+
shutil.copytree(built_dist, destination_dist)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_RETRYABLE_TWINE_ERRORS = (
|
|
193
|
+
"connectionreseterror",
|
|
194
|
+
"connection aborted",
|
|
195
|
+
"protocolerror",
|
|
196
|
+
"forcibly closed by the remote host",
|
|
197
|
+
"remote host closed the connection",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _is_retryable_twine_error(output: str) -> bool:
|
|
202
|
+
normalized = output.lower()
|
|
203
|
+
return any(marker in normalized for marker in _RETRYABLE_TWINE_ERRORS)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _upload_with_retries(
|
|
207
|
+
cmd: list[str],
|
|
208
|
+
*,
|
|
209
|
+
repository: str,
|
|
210
|
+
retries: int = 3,
|
|
211
|
+
cooldown: float = 3.0,
|
|
212
|
+
) -> None:
|
|
213
|
+
last_output = ""
|
|
214
|
+
for attempt in range(1, retries + 1):
|
|
215
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
216
|
+
stdout = proc.stdout or ""
|
|
217
|
+
stderr = proc.stderr or ""
|
|
218
|
+
if stdout:
|
|
219
|
+
sys.stdout.write(stdout)
|
|
220
|
+
if stderr:
|
|
221
|
+
sys.stderr.write(stderr)
|
|
222
|
+
if proc.returncode == 0:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
combined = (stdout + stderr).strip()
|
|
226
|
+
last_output = combined or f"Twine exited with code {proc.returncode}"
|
|
227
|
+
|
|
228
|
+
if attempt < retries and _is_retryable_twine_error(combined):
|
|
229
|
+
time.sleep(cooldown)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if _is_retryable_twine_error(combined):
|
|
233
|
+
raise ReleaseError(
|
|
234
|
+
"Twine upload to {repo} failed after {attempts} attempts due to a network interruption. "
|
|
235
|
+
"Check your internet connection, wait a moment, then rerun the release command. "
|
|
236
|
+
"If uploads continue to fail, manually run `python -m twine upload dist/*` once the network "
|
|
237
|
+
"stabilizes or contact the release manager for assistance.\n\nLast error:\n{error}".format(
|
|
238
|
+
repo=repository, attempts=attempt, error=last_output
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
raise ReleaseError(last_output)
|
|
243
|
+
|
|
244
|
+
raise ReleaseError(last_output)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _git_clean() -> bool:
|
|
248
|
+
proc = subprocess.run(
|
|
249
|
+
["git", "status", "--porcelain"], capture_output=True, text=True
|
|
250
|
+
)
|
|
251
|
+
return not proc.stdout.strip()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _git_has_staged_changes() -> bool:
|
|
255
|
+
"""Return True if there are staged changes ready to commit."""
|
|
256
|
+
proc = subprocess.run(["git", "diff", "--cached", "--quiet"])
|
|
257
|
+
return proc.returncode != 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _manager_credentials() -> Optional[Credentials]:
|
|
261
|
+
"""Return credentials from the Package's release manager if available."""
|
|
262
|
+
try: # pragma: no cover - optional dependency
|
|
263
|
+
from core.models import Package as PackageModel
|
|
264
|
+
|
|
265
|
+
package_obj = PackageModel.objects.select_related("release_manager").first()
|
|
266
|
+
if package_obj and package_obj.release_manager:
|
|
267
|
+
return package_obj.release_manager.to_credentials()
|
|
268
|
+
except Exception:
|
|
269
|
+
return None
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _manager_git_credentials(package: Optional[Package] = None) -> Optional[GitCredentials]:
|
|
274
|
+
"""Return Git credentials from the Package's release manager if available."""
|
|
275
|
+
|
|
276
|
+
try: # pragma: no cover - optional dependency
|
|
277
|
+
from core.models import Package as PackageModel
|
|
278
|
+
|
|
279
|
+
queryset = PackageModel.objects.select_related("release_manager")
|
|
280
|
+
if package is not None:
|
|
281
|
+
queryset = queryset.filter(name=package.name)
|
|
282
|
+
package_obj = queryset.first()
|
|
283
|
+
if package_obj and package_obj.release_manager:
|
|
284
|
+
creds = package_obj.release_manager.to_git_credentials()
|
|
285
|
+
if creds and creds.has_auth():
|
|
286
|
+
return creds
|
|
287
|
+
except Exception:
|
|
288
|
+
return None
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _git_authentication_missing(exc: subprocess.CalledProcessError) -> bool:
|
|
293
|
+
message = (exc.stderr or exc.stdout or "").strip().lower()
|
|
294
|
+
if not message:
|
|
295
|
+
return False
|
|
296
|
+
auth_markers = [
|
|
297
|
+
"could not read username",
|
|
298
|
+
"authentication failed",
|
|
299
|
+
"fatal: authentication failed",
|
|
300
|
+
"terminal prompts disabled",
|
|
301
|
+
]
|
|
302
|
+
return any(marker in message for marker in auth_markers)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _format_subprocess_error(exc: subprocess.CalledProcessError) -> str:
|
|
306
|
+
return (exc.stderr or exc.stdout or str(exc)).strip() or str(exc)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _git_remote_url(remote: str = "origin") -> Optional[str]:
|
|
310
|
+
proc = subprocess.run(
|
|
311
|
+
["git", "remote", "get-url", remote],
|
|
312
|
+
capture_output=True,
|
|
313
|
+
text=True,
|
|
314
|
+
check=False,
|
|
315
|
+
)
|
|
316
|
+
if proc.returncode != 0:
|
|
317
|
+
return None
|
|
318
|
+
return (proc.stdout or "").strip() or None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _git_tag_commit(tag_name: str) -> Optional[str]:
|
|
322
|
+
"""Return the commit referenced by ``tag_name`` in the local repository."""
|
|
323
|
+
|
|
324
|
+
for ref in (f"{tag_name}^{{}}", tag_name):
|
|
325
|
+
proc = subprocess.run(
|
|
326
|
+
["git", "rev-parse", ref],
|
|
327
|
+
capture_output=True,
|
|
328
|
+
text=True,
|
|
329
|
+
check=False,
|
|
330
|
+
)
|
|
331
|
+
if proc.returncode == 0:
|
|
332
|
+
commit = (proc.stdout or "").strip()
|
|
333
|
+
if commit:
|
|
334
|
+
return commit
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _git_remote_tag_commit(remote: str, tag_name: str) -> Optional[str]:
|
|
339
|
+
"""Return the commit referenced by ``tag_name`` on ``remote`` if it exists."""
|
|
340
|
+
|
|
341
|
+
proc = subprocess.run(
|
|
342
|
+
["git", "ls-remote", "--tags", remote, tag_name],
|
|
343
|
+
capture_output=True,
|
|
344
|
+
text=True,
|
|
345
|
+
check=False,
|
|
346
|
+
)
|
|
347
|
+
if proc.returncode != 0:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
commit = None
|
|
351
|
+
for line in (proc.stdout or "").splitlines():
|
|
352
|
+
parts = line.strip().split()
|
|
353
|
+
if len(parts) != 2:
|
|
354
|
+
continue
|
|
355
|
+
sha, ref = parts
|
|
356
|
+
commit = sha
|
|
357
|
+
if ref.endswith("^{}"):
|
|
358
|
+
return sha
|
|
359
|
+
return commit
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _remote_with_credentials(url: str, creds: GitCredentials) -> Optional[str]:
|
|
363
|
+
if not creds.has_auth():
|
|
364
|
+
return None
|
|
365
|
+
parsed = urlsplit(url)
|
|
366
|
+
if parsed.scheme not in {"http", "https"}:
|
|
367
|
+
return None
|
|
368
|
+
host = parsed.netloc.split("@", 1)[-1]
|
|
369
|
+
username = quote((creds.username or "").strip(), safe="")
|
|
370
|
+
password = quote((creds.password or "").strip(), safe="")
|
|
371
|
+
netloc = f"{username}:{password}@{host}"
|
|
372
|
+
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _raise_git_authentication_error(tag_name: str, exc: subprocess.CalledProcessError) -> None:
|
|
376
|
+
details = _format_subprocess_error(exc)
|
|
377
|
+
message = (
|
|
378
|
+
"Git authentication failed while pushing tag {tag}. "
|
|
379
|
+
"Configure Git credentials in the release manager profile or authenticate "
|
|
380
|
+
"locally, then rerun the publish step or push the tag manually with `git push "
|
|
381
|
+
"origin {tag}`."
|
|
382
|
+
).format(tag=tag_name)
|
|
383
|
+
if details:
|
|
384
|
+
message = f"{message} Git reported: {details}"
|
|
385
|
+
raise ReleaseError(message) from exc
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _push_tag(tag_name: str, package: Package) -> None:
|
|
389
|
+
auth_error: subprocess.CalledProcessError | None = None
|
|
390
|
+
try:
|
|
391
|
+
_run(["git", "push", "origin", tag_name])
|
|
392
|
+
return
|
|
393
|
+
except subprocess.CalledProcessError as exc:
|
|
394
|
+
remote_commit = _git_remote_tag_commit("origin", tag_name)
|
|
395
|
+
local_commit = _git_tag_commit(tag_name)
|
|
396
|
+
if remote_commit:
|
|
397
|
+
if local_commit and remote_commit == local_commit:
|
|
398
|
+
# Another process already pushed the tag; treat as success.
|
|
399
|
+
return
|
|
400
|
+
message = (
|
|
401
|
+
"Git rejected tag {tag} because it already exists on the remote. "
|
|
402
|
+
"Delete the remote tag or choose a new version before retrying."
|
|
403
|
+
).format(tag=tag_name)
|
|
404
|
+
raise ReleaseError(message) from exc
|
|
405
|
+
if not _git_authentication_missing(exc):
|
|
406
|
+
raise
|
|
407
|
+
auth_error = exc
|
|
408
|
+
|
|
409
|
+
creds = _manager_git_credentials(package)
|
|
410
|
+
if creds and creds.has_auth():
|
|
411
|
+
remote_url = _git_remote_url("origin")
|
|
412
|
+
if remote_url:
|
|
413
|
+
authed_url = _remote_with_credentials(remote_url, creds)
|
|
414
|
+
if authed_url:
|
|
415
|
+
try:
|
|
416
|
+
_run(["git", "push", authed_url, tag_name])
|
|
417
|
+
return
|
|
418
|
+
except subprocess.CalledProcessError as push_exc:
|
|
419
|
+
if not _git_authentication_missing(push_exc):
|
|
420
|
+
raise
|
|
421
|
+
auth_error = push_exc
|
|
422
|
+
# If we reach this point, the original exception is an auth error
|
|
423
|
+
if auth_error is not None:
|
|
424
|
+
_raise_git_authentication_error(tag_name, auth_error)
|
|
425
|
+
raise ReleaseError(
|
|
426
|
+
"Git authentication failed while pushing tag {tag}. Configure Git credentials and try again.".format(
|
|
427
|
+
tag=tag_name
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def run_tests(
|
|
433
|
+
log_path: Optional[Path] = None,
|
|
434
|
+
command: Optional[Sequence[str]] = None,
|
|
435
|
+
) -> subprocess.CompletedProcess:
|
|
436
|
+
"""Run the project's test suite and write output to ``log_path``.
|
|
437
|
+
|
|
438
|
+
The log file is stored separately from regular application logs to avoid
|
|
439
|
+
mixing test output with runtime logging.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
log_path = log_path or Path("logs/test.log")
|
|
443
|
+
cmd = list(command) if command is not None else [sys.executable, "manage.py", "test"]
|
|
444
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
445
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
log_path.write_text(proc.stdout + proc.stderr, encoding="utf-8")
|
|
447
|
+
return proc
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _write_pyproject(package: Package, version: str, requirements: list[str]) -> None:
|
|
451
|
+
content = {
|
|
452
|
+
"build-system": {
|
|
453
|
+
"requires": ["setuptools", "wheel"],
|
|
454
|
+
"build-backend": "setuptools.build_meta",
|
|
455
|
+
},
|
|
456
|
+
"project": {
|
|
457
|
+
"name": package.name,
|
|
458
|
+
"version": version,
|
|
459
|
+
"description": package.description,
|
|
460
|
+
"readme": {"file": "README.md", "content-type": "text/markdown"},
|
|
461
|
+
"requires-python": package.python_requires,
|
|
462
|
+
"license": package.license,
|
|
463
|
+
"authors": [{"name": package.author, "email": package.email}],
|
|
464
|
+
"classifiers": [
|
|
465
|
+
"Programming Language :: Python :: 3",
|
|
466
|
+
"Framework :: Django",
|
|
467
|
+
],
|
|
468
|
+
"dependencies": requirements,
|
|
469
|
+
"urls": {
|
|
470
|
+
"Repository": package.repository_url,
|
|
471
|
+
"Homepage": package.homepage_url,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
"tool": {
|
|
475
|
+
"setuptools": {"packages": list(package.packages)}
|
|
476
|
+
},
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
def _dump_toml(data: dict) -> str:
|
|
480
|
+
if toml is not None and hasattr(toml, "dumps"):
|
|
481
|
+
return toml.dumps(data)
|
|
482
|
+
import json
|
|
483
|
+
|
|
484
|
+
return json.dumps(data)
|
|
485
|
+
|
|
486
|
+
Path("pyproject.toml").write_text(_dump_toml(content), encoding="utf-8")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@requires_network
|
|
490
|
+
def build(
|
|
491
|
+
*,
|
|
492
|
+
version: Optional[str] = None,
|
|
493
|
+
bump: bool = False,
|
|
494
|
+
tests: bool = False,
|
|
495
|
+
dist: bool = False,
|
|
496
|
+
twine: bool = False,
|
|
497
|
+
git: bool = False,
|
|
498
|
+
tag: bool = False,
|
|
499
|
+
all: bool = False,
|
|
500
|
+
force: bool = False,
|
|
501
|
+
package: Package = DEFAULT_PACKAGE,
|
|
502
|
+
creds: Optional[Credentials] = None,
|
|
503
|
+
stash: bool = False,
|
|
504
|
+
) -> None:
|
|
505
|
+
if all:
|
|
506
|
+
bump = dist = twine = git = tag = True
|
|
507
|
+
|
|
508
|
+
stashed = False
|
|
509
|
+
if not _git_clean():
|
|
510
|
+
if stash:
|
|
511
|
+
_run(["git", "stash", "--include-untracked"])
|
|
512
|
+
stashed = True
|
|
513
|
+
else:
|
|
514
|
+
raise ReleaseError(
|
|
515
|
+
"Git repository is not clean. Commit, stash, or enable auto stash before building."
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
version_path = Path(package.version_path) if package.version_path else Path("VERSION")
|
|
519
|
+
if version is None:
|
|
520
|
+
if not version_path.exists():
|
|
521
|
+
raise ReleaseError("VERSION file not found")
|
|
522
|
+
version = version_path.read_text().strip()
|
|
523
|
+
else:
|
|
524
|
+
# Ensure the VERSION file reflects the provided release version
|
|
525
|
+
if version_path.parent != Path("."):
|
|
526
|
+
version_path.parent.mkdir(parents=True, exist_ok=True)
|
|
527
|
+
version_path.write_text(version + "\n")
|
|
528
|
+
|
|
529
|
+
requirements_path = (
|
|
530
|
+
Path(package.dependencies_path)
|
|
531
|
+
if package.dependencies_path
|
|
532
|
+
else Path("requirements.txt")
|
|
533
|
+
)
|
|
534
|
+
requirements = [
|
|
535
|
+
line.strip()
|
|
536
|
+
for line in requirements_path.read_text().splitlines()
|
|
537
|
+
if line.strip() and not line.startswith("#")
|
|
538
|
+
]
|
|
539
|
+
|
|
540
|
+
if tests:
|
|
541
|
+
log_path = Path("logs/test.log")
|
|
542
|
+
test_command = (
|
|
543
|
+
shlex.split(package.test_command)
|
|
544
|
+
if package.test_command
|
|
545
|
+
else None
|
|
546
|
+
)
|
|
547
|
+
proc = run_tests(log_path=log_path, command=test_command)
|
|
548
|
+
if proc.returncode != 0:
|
|
549
|
+
raise TestsFailed(log_path, proc.stdout + proc.stderr)
|
|
550
|
+
|
|
551
|
+
_write_pyproject(package, version, requirements)
|
|
552
|
+
if dist:
|
|
553
|
+
if Path("dist").exists():
|
|
554
|
+
shutil.rmtree("dist")
|
|
555
|
+
build_dir = Path("build")
|
|
556
|
+
if build_dir.exists():
|
|
557
|
+
shutil.rmtree(build_dir)
|
|
558
|
+
sys.modules.pop("build", None)
|
|
559
|
+
try:
|
|
560
|
+
import build # type: ignore
|
|
561
|
+
except Exception:
|
|
562
|
+
_run([sys.executable, "-m", "pip", "install", "build"])
|
|
563
|
+
else:
|
|
564
|
+
module_path = Path(getattr(build, "__file__", "") or "").resolve()
|
|
565
|
+
try:
|
|
566
|
+
module_path.relative_to(Path.cwd().resolve())
|
|
567
|
+
except ValueError:
|
|
568
|
+
pass
|
|
569
|
+
else:
|
|
570
|
+
# A local ``build`` package shadows the build backend; reinstall it.
|
|
571
|
+
sys.modules.pop("build", None)
|
|
572
|
+
_run([sys.executable, "-m", "pip", "install", "build"])
|
|
573
|
+
_build_in_sanitized_tree(Path.cwd())
|
|
574
|
+
|
|
575
|
+
if git:
|
|
576
|
+
files = ["VERSION", "pyproject.toml"]
|
|
577
|
+
_run(["git", "add"] + files)
|
|
578
|
+
msg = f"PyPI Release v{version}" if twine else f"Release v{version}"
|
|
579
|
+
if _git_has_staged_changes():
|
|
580
|
+
_run(["git", "commit", "-m", msg])
|
|
581
|
+
_run(["git", "push"])
|
|
582
|
+
|
|
583
|
+
if tag:
|
|
584
|
+
tag_name = f"v{version}"
|
|
585
|
+
_run(["git", "tag", tag_name])
|
|
586
|
+
_run(["git", "push", "origin", tag_name])
|
|
587
|
+
|
|
588
|
+
if dist and twine:
|
|
589
|
+
if not force:
|
|
590
|
+
try: # pragma: no cover - requests optional
|
|
591
|
+
import requests # type: ignore
|
|
592
|
+
except Exception:
|
|
593
|
+
requests = None # type: ignore
|
|
594
|
+
if requests is not None:
|
|
595
|
+
resp = requests.get(f"https://pypi.org/pypi/{package.name}/json")
|
|
596
|
+
if resp.ok:
|
|
597
|
+
releases = resp.json().get("releases", {})
|
|
598
|
+
if version in releases:
|
|
599
|
+
raise ReleaseError(f"Version {version} already on PyPI")
|
|
600
|
+
creds = (
|
|
601
|
+
creds
|
|
602
|
+
or _manager_credentials()
|
|
603
|
+
or Credentials(
|
|
604
|
+
token=os.environ.get("PYPI_API_TOKEN"),
|
|
605
|
+
username=os.environ.get("PYPI_USERNAME"),
|
|
606
|
+
password=os.environ.get("PYPI_PASSWORD"),
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
files = sorted(str(p) for p in Path("dist").glob("*"))
|
|
610
|
+
if not files:
|
|
611
|
+
raise ReleaseError("dist directory is empty")
|
|
612
|
+
cmd = [sys.executable, "-m", "twine", "upload", *files]
|
|
613
|
+
try:
|
|
614
|
+
cmd += creds.twine_args()
|
|
615
|
+
except ValueError:
|
|
616
|
+
raise ReleaseError("Missing PyPI credentials")
|
|
617
|
+
_upload_with_retries(cmd, repository="PyPI")
|
|
618
|
+
|
|
619
|
+
if stashed:
|
|
620
|
+
_run(["git", "stash", "pop"], check=False)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def promote(
|
|
624
|
+
*,
|
|
625
|
+
package: Package = DEFAULT_PACKAGE,
|
|
626
|
+
version: str,
|
|
627
|
+
creds: Optional[Credentials] = None,
|
|
628
|
+
) -> None:
|
|
629
|
+
"""Build the package and commit the release on the current branch."""
|
|
630
|
+
if not _git_clean():
|
|
631
|
+
raise ReleaseError("Git repository is not clean")
|
|
632
|
+
build(
|
|
633
|
+
package=package,
|
|
634
|
+
version=version,
|
|
635
|
+
creds=creds,
|
|
636
|
+
tests=False,
|
|
637
|
+
dist=True,
|
|
638
|
+
git=False,
|
|
639
|
+
tag=False,
|
|
640
|
+
stash=False,
|
|
641
|
+
)
|
|
642
|
+
_run(["git", "add", "."]) # add all changes
|
|
643
|
+
if _git_has_staged_changes():
|
|
644
|
+
_run(["git", "commit", "-m", f"Release v{version}"])
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def publish(
|
|
648
|
+
*,
|
|
649
|
+
package: Package = DEFAULT_PACKAGE,
|
|
650
|
+
version: str,
|
|
651
|
+
creds: Optional[Credentials] = None,
|
|
652
|
+
repositories: Optional[Sequence[RepositoryTarget]] = None,
|
|
653
|
+
) -> list[str]:
|
|
654
|
+
"""Upload the existing distribution to one or more repositories."""
|
|
655
|
+
|
|
656
|
+
def _resolve_primary_credentials(target: RepositoryTarget) -> Credentials:
|
|
657
|
+
if target.credentials is not None:
|
|
658
|
+
try:
|
|
659
|
+
target.credentials.twine_args()
|
|
660
|
+
except ValueError as exc:
|
|
661
|
+
raise ReleaseError(f"Missing credentials for {target.name}") from exc
|
|
662
|
+
return target.credentials
|
|
663
|
+
|
|
664
|
+
candidate = (
|
|
665
|
+
creds
|
|
666
|
+
or _manager_credentials()
|
|
667
|
+
or Credentials(
|
|
668
|
+
token=os.environ.get("PYPI_API_TOKEN"),
|
|
669
|
+
username=os.environ.get("PYPI_USERNAME"),
|
|
670
|
+
password=os.environ.get("PYPI_PASSWORD"),
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
if candidate is None or not candidate.has_auth():
|
|
674
|
+
raise ReleaseError("Missing PyPI credentials")
|
|
675
|
+
try:
|
|
676
|
+
candidate.twine_args()
|
|
677
|
+
except ValueError as exc: # pragma: no cover - validated above
|
|
678
|
+
raise ReleaseError("Missing PyPI credentials") from exc
|
|
679
|
+
target.credentials = candidate
|
|
680
|
+
return candidate
|
|
681
|
+
|
|
682
|
+
repository_targets: list[RepositoryTarget]
|
|
683
|
+
if repositories is None:
|
|
684
|
+
primary = RepositoryTarget(name="PyPI", verify_availability=True)
|
|
685
|
+
repository_targets = [primary]
|
|
686
|
+
else:
|
|
687
|
+
repository_targets = list(repositories)
|
|
688
|
+
if not repository_targets:
|
|
689
|
+
raise ReleaseError("No repositories configured")
|
|
690
|
+
|
|
691
|
+
primary = repository_targets[0]
|
|
692
|
+
|
|
693
|
+
if network_available() and primary.verify_availability and requests is not None:
|
|
694
|
+
resp = requests.get(f"https://pypi.org/pypi/{package.name}/json")
|
|
695
|
+
if resp.ok and version in resp.json().get("releases", {}):
|
|
696
|
+
raise ReleaseError(f"Version {version} already on PyPI")
|
|
697
|
+
|
|
698
|
+
if not Path("dist").exists():
|
|
699
|
+
raise ReleaseError("dist directory not found")
|
|
700
|
+
files = sorted(str(p) for p in Path("dist").glob("*"))
|
|
701
|
+
if not files:
|
|
702
|
+
raise ReleaseError("dist directory is empty")
|
|
703
|
+
|
|
704
|
+
primary_credentials = _resolve_primary_credentials(primary)
|
|
705
|
+
|
|
706
|
+
uploaded: list[str] = []
|
|
707
|
+
for index, target in enumerate(repository_targets):
|
|
708
|
+
creds_obj = target.credentials
|
|
709
|
+
if creds_obj is None:
|
|
710
|
+
if index == 0:
|
|
711
|
+
creds_obj = primary_credentials
|
|
712
|
+
else:
|
|
713
|
+
raise ReleaseError(f"Missing credentials for {target.name}")
|
|
714
|
+
try:
|
|
715
|
+
auth_args = creds_obj.twine_args()
|
|
716
|
+
except ValueError as exc:
|
|
717
|
+
label = "PyPI" if index == 0 else target.name
|
|
718
|
+
raise ReleaseError(f"Missing credentials for {label}") from exc
|
|
719
|
+
cmd = target.build_command(files) + auth_args
|
|
720
|
+
_upload_with_retries(cmd, repository=target.name)
|
|
721
|
+
uploaded.append(target.name)
|
|
722
|
+
|
|
723
|
+
tag_name = f"v{version}"
|
|
724
|
+
try:
|
|
725
|
+
_run(["git", "tag", tag_name])
|
|
726
|
+
except subprocess.CalledProcessError as exc:
|
|
727
|
+
details = _format_subprocess_error(exc)
|
|
728
|
+
if uploaded:
|
|
729
|
+
uploads = ", ".join(uploaded)
|
|
730
|
+
if details:
|
|
731
|
+
message = (
|
|
732
|
+
f"Upload to {uploads} completed, but creating git tag {tag_name} failed: {details}"
|
|
733
|
+
)
|
|
734
|
+
else:
|
|
735
|
+
message = (
|
|
736
|
+
f"Upload to {uploads} completed, but creating git tag {tag_name} failed."
|
|
737
|
+
)
|
|
738
|
+
followups = [f"Create and push git tag {tag_name} manually once the repository is ready."]
|
|
739
|
+
raise PostPublishWarning(
|
|
740
|
+
message,
|
|
741
|
+
uploaded=uploaded,
|
|
742
|
+
followups=followups,
|
|
743
|
+
) from exc
|
|
744
|
+
raise ReleaseError(
|
|
745
|
+
f"Failed to create git tag {tag_name}: {details or exc}"
|
|
746
|
+
) from exc
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
_push_tag(tag_name, package)
|
|
750
|
+
except ReleaseError as exc:
|
|
751
|
+
if uploaded:
|
|
752
|
+
uploads = ", ".join(uploaded)
|
|
753
|
+
message = f"Upload to {uploads} completed, but {exc}"
|
|
754
|
+
followups = [
|
|
755
|
+
f"Push git tag {tag_name} to origin after resolving the reported issue."
|
|
756
|
+
]
|
|
757
|
+
warning = PostPublishWarning(
|
|
758
|
+
message,
|
|
759
|
+
uploaded=uploaded,
|
|
760
|
+
followups=followups,
|
|
761
|
+
)
|
|
762
|
+
raise warning from exc
|
|
763
|
+
raise
|
|
764
|
+
return uploaded
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
@dataclass
|
|
768
|
+
class PyPICheckResult:
|
|
769
|
+
ok: bool
|
|
770
|
+
messages: list[tuple[str, str]]
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def check_pypi_readiness(
|
|
774
|
+
*,
|
|
775
|
+
release: Optional["PackageRelease"] = None,
|
|
776
|
+
package: Optional[Package] = None,
|
|
777
|
+
creds: Optional[Credentials] = None,
|
|
778
|
+
repositories: Optional[Sequence[RepositoryTarget]] = None,
|
|
779
|
+
) -> PyPICheckResult:
|
|
780
|
+
"""Validate connectivity and credentials required for PyPI uploads."""
|
|
781
|
+
|
|
782
|
+
messages: list[tuple[str, str]] = []
|
|
783
|
+
has_error = False
|
|
784
|
+
|
|
785
|
+
def add(level: str, message: str) -> None:
|
|
786
|
+
nonlocal has_error
|
|
787
|
+
messages.append((level, message))
|
|
788
|
+
if level == "error":
|
|
789
|
+
has_error = True
|
|
790
|
+
|
|
791
|
+
release_manager = None
|
|
792
|
+
if release is not None:
|
|
793
|
+
package = release.to_package()
|
|
794
|
+
repositories = release.build_publish_targets()
|
|
795
|
+
creds = release.to_credentials()
|
|
796
|
+
release_manager = release.release_manager or release.package.release_manager
|
|
797
|
+
add("success", f"Checking PyPI configuration for {release}")
|
|
798
|
+
|
|
799
|
+
if package is None:
|
|
800
|
+
package = DEFAULT_PACKAGE
|
|
801
|
+
|
|
802
|
+
if repositories is None:
|
|
803
|
+
repositories = [RepositoryTarget(name="PyPI", verify_availability=True)]
|
|
804
|
+
else:
|
|
805
|
+
repositories = list(repositories)
|
|
806
|
+
|
|
807
|
+
if not repositories:
|
|
808
|
+
add("error", "No repositories configured for upload")
|
|
809
|
+
return PyPICheckResult(ok=False, messages=messages)
|
|
810
|
+
|
|
811
|
+
if release_manager is not None:
|
|
812
|
+
if release_manager.pypi_token or (
|
|
813
|
+
release_manager.pypi_username and release_manager.pypi_password
|
|
814
|
+
):
|
|
815
|
+
add(
|
|
816
|
+
"success",
|
|
817
|
+
f"Release manager '{release_manager}' has PyPI credentials configured",
|
|
818
|
+
)
|
|
819
|
+
else:
|
|
820
|
+
add(
|
|
821
|
+
"warning",
|
|
822
|
+
f"Release manager '{release_manager}' is missing PyPI credentials",
|
|
823
|
+
)
|
|
824
|
+
else:
|
|
825
|
+
add(
|
|
826
|
+
"warning",
|
|
827
|
+
"No release manager configured for PyPI uploads; falling back to environment",
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
env_creds = Credentials(
|
|
831
|
+
token=os.environ.get("PYPI_API_TOKEN"),
|
|
832
|
+
username=os.environ.get("PYPI_USERNAME"),
|
|
833
|
+
password=os.environ.get("PYPI_PASSWORD"),
|
|
834
|
+
)
|
|
835
|
+
if not env_creds.has_auth():
|
|
836
|
+
env_creds = None
|
|
837
|
+
|
|
838
|
+
primary = repositories[0]
|
|
839
|
+
candidate = primary.credentials
|
|
840
|
+
credential_source = "repository"
|
|
841
|
+
if candidate is None and creds is not None and creds.has_auth():
|
|
842
|
+
candidate = creds
|
|
843
|
+
credential_source = "release manager"
|
|
844
|
+
if candidate is None and env_creds is not None:
|
|
845
|
+
candidate = env_creds
|
|
846
|
+
credential_source = "environment"
|
|
847
|
+
|
|
848
|
+
if candidate is None:
|
|
849
|
+
add(
|
|
850
|
+
"error",
|
|
851
|
+
"Missing PyPI credentials. Configure a token or username/password for the release manager or environment.",
|
|
852
|
+
)
|
|
853
|
+
else:
|
|
854
|
+
try:
|
|
855
|
+
candidate.twine_args()
|
|
856
|
+
except ValueError as exc:
|
|
857
|
+
add("error", f"Invalid PyPI credentials: {exc}")
|
|
858
|
+
else:
|
|
859
|
+
auth_kind = "API token" if candidate.token else "username/password"
|
|
860
|
+
if credential_source == "release manager":
|
|
861
|
+
add("success", f"Using {auth_kind} provided by the release manager")
|
|
862
|
+
elif credential_source == "environment":
|
|
863
|
+
add("success", f"Using {auth_kind} from environment variables")
|
|
864
|
+
elif credential_source == "repository":
|
|
865
|
+
add("success", f"Using {auth_kind} supplied by repository target configuration")
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
proc = subprocess.run(
|
|
869
|
+
[sys.executable, "-m", "twine", "--version"],
|
|
870
|
+
capture_output=True,
|
|
871
|
+
text=True,
|
|
872
|
+
check=True,
|
|
873
|
+
)
|
|
874
|
+
except FileNotFoundError:
|
|
875
|
+
add("error", "Twine is not installed. Install it with `pip install twine`.")
|
|
876
|
+
except subprocess.CalledProcessError as exc:
|
|
877
|
+
output = (exc.stdout or "") + (exc.stderr or "")
|
|
878
|
+
add(
|
|
879
|
+
"error",
|
|
880
|
+
f"Twine version check failed: {output.strip() or exc.returncode}",
|
|
881
|
+
)
|
|
882
|
+
else:
|
|
883
|
+
version_info = (proc.stdout or proc.stderr or "").strip()
|
|
884
|
+
if version_info:
|
|
885
|
+
add("success", f"Twine available: {version_info}")
|
|
886
|
+
else:
|
|
887
|
+
add("success", "Twine version check succeeded")
|
|
888
|
+
|
|
889
|
+
if not network_available():
|
|
890
|
+
add(
|
|
891
|
+
"warning",
|
|
892
|
+
"Offline mode enabled; skipping network connectivity checks",
|
|
893
|
+
)
|
|
894
|
+
return PyPICheckResult(ok=not has_error, messages=messages)
|
|
895
|
+
|
|
896
|
+
if requests is None:
|
|
897
|
+
add("warning", "requests library unavailable; skipping network checks")
|
|
898
|
+
return PyPICheckResult(ok=not has_error, messages=messages)
|
|
899
|
+
|
|
900
|
+
try:
|
|
901
|
+
resp = requests.get(
|
|
902
|
+
f"https://pypi.org/pypi/{package.name}/json", timeout=10
|
|
903
|
+
)
|
|
904
|
+
except Exception as exc: # pragma: no cover - network failure
|
|
905
|
+
add("error", f"Failed to reach PyPI JSON API: {exc}")
|
|
906
|
+
else:
|
|
907
|
+
if resp.ok:
|
|
908
|
+
add(
|
|
909
|
+
"success",
|
|
910
|
+
f"PyPI JSON API reachable for project '{package.name}'",
|
|
911
|
+
)
|
|
912
|
+
else:
|
|
913
|
+
add(
|
|
914
|
+
"error",
|
|
915
|
+
f"PyPI JSON API returned status {resp.status_code} for '{package.name}'",
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
checked_urls: set[str] = set()
|
|
919
|
+
for target in repositories:
|
|
920
|
+
url = target.repository_url or "https://upload.pypi.org/legacy/"
|
|
921
|
+
if url in checked_urls:
|
|
922
|
+
continue
|
|
923
|
+
checked_urls.add(url)
|
|
924
|
+
try:
|
|
925
|
+
resp = requests.get(url, timeout=10)
|
|
926
|
+
except Exception as exc: # pragma: no cover - network failure
|
|
927
|
+
add("error", f"Failed to reach upload endpoint {url}: {exc}")
|
|
928
|
+
continue
|
|
929
|
+
if resp.ok:
|
|
930
|
+
add(
|
|
931
|
+
"success",
|
|
932
|
+
f"Upload endpoint {url} responded with status {resp.status_code}",
|
|
933
|
+
)
|
|
934
|
+
else:
|
|
935
|
+
add(
|
|
936
|
+
"error",
|
|
937
|
+
f"Upload endpoint {url} returned status {resp.status_code}",
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
return PyPICheckResult(ok=not has_error, messages=messages)
|