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.

Files changed (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {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 subprocess
5
- import sys
6
- import shutil
7
- from dataclasses import dataclass
8
- from pathlib import Path
9
- from typing import Optional
10
-
11
- try: # pragma: no cover - optional dependency
12
- import toml # type: ignore
13
- except Exception: # pragma: no cover - fallback when missing
14
- toml = None # type: ignore
15
-
16
- from config.offline import requires_network, network_available
17
-
18
-
19
- @dataclass
20
- class Package:
21
- """Metadata for building a distributable package."""
22
-
23
- name: str
24
- description: str
25
- author: str
26
- email: str
27
- python_requires: str
28
- license: str
29
- repository_url: str = "https://github.com/arthexis/arthexis"
30
- homepage_url: str = "https://arthexis.com"
31
-
32
-
33
- @dataclass
34
- class Credentials:
35
- """Credentials for uploading to PyPI."""
36
-
37
- token: Optional[str] = None
38
- username: Optional[str] = None
39
- password: Optional[str] = None
40
-
41
- def twine_args(self) -> list[str]:
42
- if self.token:
43
- return ["--username", "__token__", "--password", self.token]
44
- if self.username and self.password:
45
- return ["--username", self.username, "--password", self.password]
46
- raise ValueError("Missing PyPI credentials")
47
-
48
-
49
- DEFAULT_PACKAGE = Package(
50
- name="arthexis",
51
- description="Django-based MESH system",
52
- author="Rafael J. Guillén-Osorio",
53
- email="tecnologia@gelectriic.com",
54
- python_requires=">=3.10",
55
- license="GPL-3.0-only",
56
- )
57
-
58
-
59
- class ReleaseError(Exception):
60
- pass
61
-
62
-
63
- class TestsFailed(ReleaseError):
64
- """Raised when the test suite fails.
65
-
66
- Attributes:
67
- log_path: Location of the saved test log.
68
- output: Combined stdout/stderr from the test run.
69
- """
70
-
71
- def __init__(self, log_path: Path, output: str):
72
- super().__init__("Tests failed")
73
- self.log_path = log_path
74
- self.output = output
75
-
76
-
77
- def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
78
- return subprocess.run(cmd, check=check)
79
-
80
-
81
- def _git_clean() -> bool:
82
- proc = subprocess.run(
83
- ["git", "status", "--porcelain"], capture_output=True, text=True
84
- )
85
- return not proc.stdout.strip()
86
-
87
-
88
- def _git_has_staged_changes() -> bool:
89
- """Return True if there are staged changes ready to commit."""
90
- proc = subprocess.run(["git", "diff", "--cached", "--quiet"])
91
- return proc.returncode != 0
92
-
93
-
94
- def _manager_credentials() -> Optional[Credentials]:
95
- """Return credentials from the Package's release manager if available."""
96
- try: # pragma: no cover - optional dependency
97
- from core.models import Package as PackageModel
98
-
99
- package_obj = PackageModel.objects.select_related("release_manager").first()
100
- if package_obj and package_obj.release_manager:
101
- return package_obj.release_manager.to_credentials()
102
- except Exception:
103
- return None
104
- return None
105
-
106
-
107
- def run_tests(log_path: Optional[Path] = None) -> subprocess.CompletedProcess:
108
- """Run the project's test suite and write output to ``log_path``.
109
-
110
- The log file is stored separately from regular application logs to avoid
111
- mixing test output with runtime logging.
112
- """
113
-
114
- log_path = log_path or Path("logs/test.log")
115
- proc = subprocess.run(
116
- [sys.executable, "manage.py", "test"],
117
- capture_output=True,
118
- text=True,
119
- )
120
- log_path.parent.mkdir(parents=True, exist_ok=True)
121
- log_path.write_text(proc.stdout + proc.stderr, encoding="utf-8")
122
- return proc
123
-
124
-
125
- def _write_pyproject(package: Package, version: str, requirements: list[str]) -> None:
126
- content = {
127
- "build-system": {
128
- "requires": ["setuptools", "wheel"],
129
- "build-backend": "setuptools.build_meta",
130
- },
131
- "project": {
132
- "name": package.name,
133
- "version": version,
134
- "description": package.description,
135
- "readme": {"file": "README.md", "content-type": "text/markdown"},
136
- "requires-python": package.python_requires,
137
- "license": package.license,
138
- "authors": [{"name": package.author, "email": package.email}],
139
- "classifiers": [
140
- "Programming Language :: Python :: 3",
141
- "Framework :: Django",
142
- ],
143
- "dependencies": requirements,
144
- "urls": {
145
- "Repository": package.repository_url,
146
- "Homepage": package.homepage_url,
147
- },
148
- },
149
- "tool": {
150
- "setuptools": {
151
- "packages": [
152
- "core",
153
- "config",
154
- "nodes",
155
- "ocpp",
156
- "pages",
157
- ]
158
- }
159
- },
160
- }
161
-
162
- def _dump_toml(data: dict) -> str:
163
- if toml is not None and hasattr(toml, "dumps"):
164
- return toml.dumps(data)
165
- import json
166
-
167
- return json.dumps(data)
168
-
169
- Path("pyproject.toml").write_text(_dump_toml(content), encoding="utf-8")
170
-
171
-
172
- @requires_network
173
- def build(
174
- *,
175
- version: Optional[str] = None,
176
- bump: bool = False,
177
- tests: bool = False,
178
- dist: bool = False,
179
- twine: bool = False,
180
- git: bool = False,
181
- tag: bool = False,
182
- all: bool = False,
183
- force: bool = False,
184
- package: Package = DEFAULT_PACKAGE,
185
- creds: Optional[Credentials] = None,
186
- stash: bool = False,
187
- ) -> None:
188
- if all:
189
- bump = dist = twine = git = tag = True
190
-
191
- stashed = False
192
- if not _git_clean():
193
- if stash:
194
- _run(["git", "stash", "--include-untracked"])
195
- stashed = True
196
- else:
197
- raise ReleaseError(
198
- "Git repository is not clean. Commit, stash, or enable auto stash before building."
199
- )
200
-
201
- version_path = Path("VERSION")
202
- if version is None:
203
- if not version_path.exists():
204
- raise ReleaseError("VERSION file not found")
205
- version = version_path.read_text().strip()
206
- if bump:
207
- major, minor, patch = map(int, version.split("."))
208
- patch += 1
209
- version = f"{major}.{minor}.{patch}"
210
- version_path.write_text(version + "\n")
211
- else:
212
- # Ensure the VERSION file reflects the provided release version
213
- version_path.write_text(version + "\n")
214
-
215
- requirements = [
216
- line.strip()
217
- for line in Path("requirements.txt").read_text().splitlines()
218
- if line.strip() and not line.startswith("#")
219
- ]
220
-
221
- if tests:
222
- log_path = Path("logs/test.log")
223
- proc = run_tests(log_path=log_path)
224
- if proc.returncode != 0:
225
- raise TestsFailed(log_path, proc.stdout + proc.stderr)
226
-
227
- _write_pyproject(package, version, requirements)
228
- if dist:
229
- if Path("dist").exists():
230
- shutil.rmtree("dist")
231
- try:
232
- import build # type: ignore
233
- except Exception:
234
- _run([sys.executable, "-m", "pip", "install", "build"])
235
- _run([sys.executable, "-m", "build"])
236
-
237
- if git:
238
- files = ["VERSION", "pyproject.toml"]
239
- _run(["git", "add"] + files)
240
- msg = f"PyPI Release v{version}" if twine else f"Release v{version}"
241
- if _git_has_staged_changes():
242
- _run(["git", "commit", "-m", msg])
243
- _run(["git", "push"])
244
-
245
- if tag:
246
- tag_name = f"v{version}"
247
- _run(["git", "tag", tag_name])
248
- _run(["git", "push", "origin", tag_name])
249
-
250
- if dist and twine:
251
- if not force:
252
- try: # pragma: no cover - requests optional
253
- import requests # type: ignore
254
- except Exception:
255
- requests = None # type: ignore
256
- if requests is not None:
257
- resp = requests.get(f"https://pypi.org/pypi/{package.name}/json")
258
- if resp.ok:
259
- releases = resp.json().get("releases", {})
260
- if version in releases:
261
- raise ReleaseError(f"Version {version} already on PyPI")
262
- creds = (
263
- creds
264
- or _manager_credentials()
265
- or Credentials(
266
- token=os.environ.get("PYPI_API_TOKEN"),
267
- username=os.environ.get("PYPI_USERNAME"),
268
- password=os.environ.get("PYPI_PASSWORD"),
269
- )
270
- )
271
- files = sorted(str(p) for p in Path("dist").glob("*"))
272
- if not files:
273
- raise ReleaseError("dist directory is empty")
274
- cmd = [sys.executable, "-m", "twine", "upload", *files]
275
- try:
276
- cmd += creds.twine_args()
277
- except ValueError:
278
- raise ReleaseError("Missing PyPI credentials")
279
- _run(cmd)
280
-
281
- if stashed:
282
- _run(["git", "stash", "pop"], check=False)
283
-
284
-
285
- def promote(
286
- *,
287
- package: Package = DEFAULT_PACKAGE,
288
- version: str,
289
- creds: Optional[Credentials] = None,
290
- ) -> None:
291
- """Build the package and commit the release on the current branch."""
292
- if not _git_clean():
293
- raise ReleaseError("Git repository is not clean")
294
- build(
295
- package=package,
296
- version=version,
297
- creds=creds,
298
- tests=False,
299
- dist=True,
300
- git=False,
301
- tag=False,
302
- stash=False,
303
- )
304
- _run(["git", "add", "."]) # add all changes
305
- if _git_has_staged_changes():
306
- _run(["git", "commit", "-m", f"Release v{version}"])
307
-
308
-
309
- def publish(
310
- *,
311
- package: Package = DEFAULT_PACKAGE,
312
- version: str,
313
- creds: Optional[Credentials] = None,
314
- ) -> None:
315
- """Upload the existing distribution to PyPI."""
316
- if network_available():
317
- try: # pragma: no cover - requests optional
318
- import requests # type: ignore
319
- except Exception:
320
- requests = None # type: ignore
321
- if requests is not None:
322
- resp = requests.get(f"https://pypi.org/pypi/{package.name}/json")
323
- if resp.ok and version in resp.json().get("releases", {}):
324
- raise ReleaseError(f"Version {version} already on PyPI")
325
- if not Path("dist").exists():
326
- raise ReleaseError("dist directory not found")
327
- creds = (
328
- creds
329
- or _manager_credentials()
330
- or Credentials(
331
- token=os.environ.get("PYPI_API_TOKEN"),
332
- username=os.environ.get("PYPI_USERNAME"),
333
- password=os.environ.get("PYPI_PASSWORD"),
334
- )
335
- )
336
- files = sorted(str(p) for p in Path("dist").glob("*"))
337
- if not files:
338
- raise ReleaseError("dist directory is empty")
339
- cmd = [sys.executable, "-m", "twine", "upload", *files]
340
- try:
341
- cmd += creds.twine_args()
342
- except ValueError:
343
- raise ReleaseError("Missing PyPI credentials")
344
- proc = subprocess.run(cmd, capture_output=True, text=True)
345
- if proc.returncode != 0:
346
- raise ReleaseError(proc.stdout + proc.stderr)
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)