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