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