arthexis 0.1.14__py3-none-any.whl → 0.1.16__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.

core/system.py CHANGED
@@ -10,17 +10,28 @@ import re
10
10
  import socket
11
11
  import subprocess
12
12
  import shutil
13
+ import logging
13
14
  from typing import Callable, Iterable, Optional
14
15
 
15
16
  from django.conf import settings
16
- from django.contrib import admin
17
+ from django.contrib import admin, messages
17
18
  from django.template.response import TemplateResponse
18
- from django.urls import path
19
+ from django.http import HttpResponseRedirect
20
+ from django.urls import path, reverse
19
21
  from django.utils import timezone
20
22
  from django.utils.formats import date_format
21
- from django.utils.translation import gettext_lazy as _
23
+ from django.utils.html import format_html, format_html_join
24
+ from django.utils.translation import gettext_lazy as _, ngettext
22
25
 
23
26
  from core.auto_upgrade import AUTO_UPGRADE_TASK_NAME, AUTO_UPGRADE_TASK_PATH
27
+ from core import changelog as changelog_utils
28
+ from core.release import (
29
+ _git_authentication_missing,
30
+ _git_remote_url,
31
+ _manager_git_credentials,
32
+ _remote_with_credentials,
33
+ )
34
+ from core.tasks import check_github_updates
24
35
  from utils import revision
25
36
 
26
37
 
@@ -29,6 +40,9 @@ AUTO_UPGRADE_SKIP_LOCK_NAME = "auto_upgrade_skip_revisions.lck"
29
40
  AUTO_UPGRADE_LOG_NAME = "auto-upgrade.log"
30
41
 
31
42
 
43
+ logger = logging.getLogger(__name__)
44
+
45
+
32
46
  def _auto_upgrade_mode_file(base_dir: Path) -> Path:
33
47
  return base_dir / "locks" / AUTO_UPGRADE_LOCK_NAME
34
48
 
@@ -41,6 +55,246 @@ def _auto_upgrade_log_file(base_dir: Path) -> Path:
41
55
  return base_dir / "logs" / AUTO_UPGRADE_LOG_NAME
42
56
 
43
57
 
58
+ def _open_changelog_entries() -> list[dict[str, str]]:
59
+ """Return changelog entries that are not yet part of a tagged release."""
60
+
61
+ changelog_path = Path("CHANGELOG.rst")
62
+ try:
63
+ text = changelog_path.read_text(encoding="utf-8")
64
+ except FileNotFoundError:
65
+ return []
66
+ except OSError:
67
+ return []
68
+
69
+ collecting = False
70
+ entries: list[dict[str, str]] = []
71
+ for raw_line in text.splitlines():
72
+ line = raw_line.strip()
73
+ if not collecting:
74
+ if line == "Unreleased":
75
+ collecting = True
76
+ continue
77
+
78
+ if not line:
79
+ if entries:
80
+ break
81
+ continue
82
+
83
+ if set(line) == {"-"}:
84
+ # Underline immediately following the section heading.
85
+ continue
86
+
87
+ if not line.startswith("- "):
88
+ break
89
+
90
+ trimmed = line[2:].strip()
91
+ if not trimmed:
92
+ continue
93
+ parts = trimmed.split(" ", 1)
94
+ sha = parts[0]
95
+ message = parts[1] if len(parts) > 1 else ""
96
+ entries.append({"sha": sha, "message": message})
97
+
98
+ return entries
99
+
100
+
101
+ def _exclude_changelog_entries(shas: Iterable[str]) -> int:
102
+ """Remove entries matching ``shas`` from the changelog.
103
+
104
+ Returns the number of entries removed. Only entries within the
105
+ ``Unreleased`` section are considered.
106
+ """
107
+
108
+ normalized_shas = {sha.strip() for sha in shas if sha and sha.strip()}
109
+ if not normalized_shas:
110
+ return 0
111
+
112
+ changelog_path = Path("CHANGELOG.rst")
113
+ try:
114
+ text = changelog_path.read_text(encoding="utf-8")
115
+ except (FileNotFoundError, OSError):
116
+ return 0
117
+
118
+ lines = text.splitlines(keepends=True)
119
+ new_lines: list[str] = []
120
+ collecting = False
121
+ removed = 0
122
+
123
+ for raw_line in lines:
124
+ stripped = raw_line.strip()
125
+
126
+ if not collecting:
127
+ new_lines.append(raw_line)
128
+ if stripped == "Unreleased":
129
+ collecting = True
130
+ continue
131
+
132
+ if not stripped:
133
+ new_lines.append(raw_line)
134
+ continue
135
+
136
+ if set(stripped) == {"-"}:
137
+ new_lines.append(raw_line)
138
+ continue
139
+
140
+ if not stripped.startswith("- "):
141
+ new_lines.append(raw_line)
142
+ collecting = False
143
+ continue
144
+
145
+ trimmed = stripped[2:].strip()
146
+ if not trimmed:
147
+ new_lines.append(raw_line)
148
+ continue
149
+
150
+ sha = trimmed.split(" ", 1)[0]
151
+ if sha in normalized_shas:
152
+ removed += 1
153
+ normalized_shas.remove(sha)
154
+ continue
155
+
156
+ new_lines.append(raw_line)
157
+
158
+ if removed:
159
+ new_text = "".join(new_lines)
160
+ if not new_text.endswith("\n"):
161
+ new_text += "\n"
162
+ changelog_path.write_text(new_text, encoding="utf-8")
163
+
164
+ return removed
165
+
166
+
167
+ def _regenerate_changelog() -> None:
168
+ """Rebuild the changelog file using recent git commits."""
169
+
170
+ changelog_path = Path("CHANGELOG.rst")
171
+ previous_text = (
172
+ changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
173
+ )
174
+ range_spec = changelog_utils.determine_range_spec()
175
+ sections = changelog_utils.collect_sections(
176
+ range_spec=range_spec, previous_text=previous_text
177
+ )
178
+ content = changelog_utils.render_changelog(sections)
179
+ if not content.endswith("\n"):
180
+ content += "\n"
181
+ changelog_path.write_text(content, encoding="utf-8")
182
+
183
+
184
+ def _format_git_command_output(
185
+ command: list[str], result: subprocess.CompletedProcess[str]
186
+ ) -> str:
187
+ """Return a readable summary of a git command execution."""
188
+
189
+ command_display = "$ " + " ".join(command)
190
+ message_parts = []
191
+ if result.stdout:
192
+ message_parts.append(result.stdout.strip())
193
+ if result.stderr:
194
+ message_parts.append(result.stderr.strip())
195
+ if result.returncode != 0:
196
+ message_parts.append(f"[exit status {result.returncode}]")
197
+ if message_parts:
198
+ return command_display + "\n" + "\n".join(part for part in message_parts if part)
199
+ return command_display
200
+
201
+
202
+ def _git_status() -> str:
203
+ """Return the repository status after attempting to commit."""
204
+
205
+ status_result = subprocess.run(
206
+ ["git", "status", "--short", "--branch"],
207
+ capture_output=True,
208
+ text=True,
209
+ check=False,
210
+ )
211
+ stdout = status_result.stdout.strip()
212
+ stderr = status_result.stderr.strip()
213
+ if stdout and stderr:
214
+ return stdout + "\n" + stderr
215
+ return stdout or stderr
216
+
217
+
218
+ def _commit_changelog() -> tuple[bool, str, str]:
219
+ """Stage, commit, and push the changelog file."""
220
+
221
+ def _retry_push_with_release_credentials(
222
+ command: list[str],
223
+ result: subprocess.CompletedProcess[str],
224
+ ) -> bool:
225
+ exc = subprocess.CalledProcessError(
226
+ result.returncode,
227
+ command,
228
+ output=result.stdout,
229
+ stderr=result.stderr,
230
+ )
231
+ if not _git_authentication_missing(exc):
232
+ return False
233
+
234
+ creds = _manager_git_credentials()
235
+ if not creds or not creds.has_auth():
236
+ return False
237
+
238
+ remote_url = _git_remote_url("origin")
239
+ if not remote_url:
240
+ return False
241
+
242
+ authed_url = _remote_with_credentials(remote_url, creds)
243
+ if not authed_url:
244
+ return False
245
+
246
+ retry_command = ["git", "push", authed_url]
247
+ retry_result = subprocess.run(
248
+ retry_command,
249
+ capture_output=True,
250
+ text=True,
251
+ check=False,
252
+ )
253
+ formatted_retry = _format_git_command_output(retry_command, retry_result)
254
+ if formatted_retry:
255
+ outputs.append(formatted_retry)
256
+ logger.info(
257
+ "Executed %s with exit code %s",
258
+ retry_command,
259
+ retry_result.returncode,
260
+ )
261
+ return retry_result.returncode == 0
262
+
263
+ git_commands: list[list[str]] = [
264
+ ["git", "add", "CHANGELOG.rst"],
265
+ [
266
+ "git",
267
+ "commit",
268
+ "-m",
269
+ "chore: update changelog",
270
+ "--",
271
+ "CHANGELOG.rst",
272
+ ],
273
+ ["git", "push"],
274
+ ]
275
+ outputs: list[str] = []
276
+ success = True
277
+
278
+ for command in git_commands:
279
+ result = subprocess.run(
280
+ command, capture_output=True, text=True, check=False
281
+ )
282
+ formatted = _format_git_command_output(command, result)
283
+ outputs.append(formatted)
284
+ logger.info("Executed %s with exit code %s", command, result.returncode)
285
+ if result.returncode != 0:
286
+ if command[:2] == ["git", "push"] and _retry_push_with_release_credentials(
287
+ command, result
288
+ ):
289
+ continue
290
+ success = False
291
+ break
292
+
293
+ command_output = "\n\n".join(output for output in outputs if output)
294
+ repo_status = _git_status()
295
+ return success, command_output, repo_status
296
+
297
+
44
298
  @dataclass(frozen=True)
45
299
  class SystemField:
46
300
  """Metadata describing a single entry on the system admin page."""
@@ -584,7 +838,14 @@ def _gather_info() -> dict:
584
838
  info["service"] = service_file.read_text().strip() if service_file.exists() else ""
585
839
 
586
840
  mode_file = lock_dir / "nginx_mode.lck"
587
- mode = mode_file.read_text().strip() if mode_file.exists() else "internal"
841
+ if mode_file.exists():
842
+ try:
843
+ raw_mode = mode_file.read_text().strip()
844
+ except OSError:
845
+ raw_mode = ""
846
+ else:
847
+ raw_mode = ""
848
+ mode = raw_mode.lower() or "internal"
588
849
  info["mode"] = mode
589
850
  default_port = 8000 if mode == "public" else 8888
590
851
  detected_port: int | None = None
@@ -721,6 +982,99 @@ def _system_view(request):
721
982
  return TemplateResponse(request, "admin/system.html", context)
722
983
 
723
984
 
985
+ def _system_changelog_report_view(request):
986
+ if request.method == "POST":
987
+ action = request.POST.get("action")
988
+ if action == "exclude":
989
+ selected_shas = request.POST.getlist("selected_shas")
990
+ removed = _exclude_changelog_entries(selected_shas)
991
+ if removed:
992
+ messages.success(
993
+ request,
994
+ ngettext(
995
+ "Excluded %(count)d changelog entry.",
996
+ "Excluded %(count)d changelog entries.",
997
+ removed,
998
+ )
999
+ % {"count": removed},
1000
+ )
1001
+ else:
1002
+ if selected_shas:
1003
+ messages.info(
1004
+ request,
1005
+ _(
1006
+ "The selected changelog entries were not found or have already been excluded."
1007
+ ),
1008
+ )
1009
+ else:
1010
+ messages.info(
1011
+ request,
1012
+ _("Select at least one changelog entry to exclude."),
1013
+ )
1014
+ elif action == "commit":
1015
+ success, command_output, repo_status = _commit_changelog()
1016
+ details: list[str] = []
1017
+ if command_output:
1018
+ details.append(
1019
+ format_html(
1020
+ "<div class=\"changelog-git-output\"><strong>{}</strong><pre>{}</pre></div>",
1021
+ _("Command log"),
1022
+ command_output,
1023
+ )
1024
+ )
1025
+ if repo_status:
1026
+ details.append(
1027
+ format_html(
1028
+ "<div class=\"changelog-git-status\"><strong>{}</strong><pre>{}</pre></div>",
1029
+ _("Repository status"),
1030
+ repo_status,
1031
+ )
1032
+ )
1033
+ details_html = (
1034
+ format_html_join("", "{}", ((detail,) for detail in details))
1035
+ if details
1036
+ else ""
1037
+ )
1038
+ if success:
1039
+ base_message = _("Committed the changelog and pushed to the current branch.")
1040
+ messages.success(request, format_html("{}{}", base_message, details_html))
1041
+ else:
1042
+ base_message = _("Unable to commit the changelog.")
1043
+ messages.error(request, format_html("{}{}", base_message, details_html))
1044
+ else:
1045
+ try:
1046
+ _regenerate_changelog()
1047
+ except subprocess.CalledProcessError as exc:
1048
+ logger.exception("Changelog regeneration failed")
1049
+ messages.error(
1050
+ request,
1051
+ _("Unable to recalculate the changelog: %(error)s")
1052
+ % {"error": exc.stderr.strip() if exc.stderr else str(exc)},
1053
+ )
1054
+ except Exception as exc: # pragma: no cover - unexpected failure
1055
+ logger.exception("Unexpected error while regenerating changelog")
1056
+ messages.error(
1057
+ request,
1058
+ _("Unable to recalculate the changelog: %(error)s")
1059
+ % {"error": str(exc)},
1060
+ )
1061
+ else:
1062
+ messages.success(
1063
+ request,
1064
+ _("Successfully recalculated the changelog from recent commits."),
1065
+ )
1066
+ return HttpResponseRedirect(reverse("admin:system-changelog-report"))
1067
+
1068
+ context = admin.site.each_context(request)
1069
+ context.update(
1070
+ {
1071
+ "title": _("Changelog Report"),
1072
+ "open_changelog_entries": _open_changelog_entries(),
1073
+ }
1074
+ )
1075
+ return TemplateResponse(request, "admin/system_changelog_report.html", context)
1076
+
1077
+
724
1078
  def _system_upgrade_report_view(request):
725
1079
  context = admin.site.each_context(request)
726
1080
  context.update(
@@ -732,6 +1086,49 @@ def _system_upgrade_report_view(request):
732
1086
  return TemplateResponse(request, "admin/system_upgrade_report.html", context)
733
1087
 
734
1088
 
1089
+ def _trigger_upgrade_check() -> bool:
1090
+ """Return ``True`` when the upgrade check was queued asynchronously."""
1091
+
1092
+ try:
1093
+ check_github_updates.delay()
1094
+ except Exception:
1095
+ logger.exception("Failed to enqueue upgrade check; running synchronously instead")
1096
+ check_github_updates()
1097
+ return False
1098
+ return True
1099
+
1100
+
1101
+ def _system_trigger_upgrade_check_view(request):
1102
+ if request.method != "POST":
1103
+ return HttpResponseRedirect(reverse("admin:system-upgrade-report"))
1104
+
1105
+ try:
1106
+ queued = _trigger_upgrade_check()
1107
+ except Exception as exc: # pragma: no cover - unexpected failure
1108
+ logger.exception("Unable to trigger upgrade check")
1109
+ messages.error(
1110
+ request,
1111
+ _("Unable to trigger an upgrade check: %(error)s")
1112
+ % {"error": str(exc)},
1113
+ )
1114
+ else:
1115
+ if queued:
1116
+ messages.success(
1117
+ request,
1118
+ _("Upgrade check requested. The task will run shortly."),
1119
+ )
1120
+ else:
1121
+ messages.success(
1122
+ request,
1123
+ _(
1124
+ "Upgrade check started locally. Review the auto-upgrade log for"
1125
+ " progress."
1126
+ ),
1127
+ )
1128
+
1129
+ return HttpResponseRedirect(reverse("admin:system-upgrade-report"))
1130
+
1131
+
735
1132
  def patch_admin_system_view() -> None:
736
1133
  """Add custom admin view for system information."""
737
1134
  original_get_urls = admin.site.get_urls
@@ -740,11 +1137,21 @@ def patch_admin_system_view() -> None:
740
1137
  urls = original_get_urls()
741
1138
  custom = [
742
1139
  path("system/", admin.site.admin_view(_system_view), name="system"),
1140
+ path(
1141
+ "system/changelog-report/",
1142
+ admin.site.admin_view(_system_changelog_report_view),
1143
+ name="system-changelog-report",
1144
+ ),
743
1145
  path(
744
1146
  "system/upgrade-report/",
745
1147
  admin.site.admin_view(_system_upgrade_report_view),
746
1148
  name="system-upgrade-report",
747
1149
  ),
1150
+ path(
1151
+ "system/upgrade-report/run-check/",
1152
+ admin.site.admin_view(_system_trigger_upgrade_check_view),
1153
+ name="system-upgrade-run-check",
1154
+ ),
748
1155
  ]
749
1156
  return custom + urls
750
1157
 
core/tasks.py CHANGED
@@ -132,11 +132,15 @@ def check_github_updates() -> None:
132
132
  mode = "version"
133
133
  if mode_file.exists():
134
134
  try:
135
- mode = mode_file.read_text().strip() or "version"
135
+ raw_mode = mode_file.read_text().strip()
136
136
  except (OSError, UnicodeDecodeError):
137
137
  logger.warning(
138
138
  "Failed to read auto-upgrade mode lockfile", exc_info=True
139
139
  )
140
+ else:
141
+ cleaned_mode = raw_mode.lower()
142
+ if cleaned_mode:
143
+ mode = cleaned_mode
140
144
 
141
145
  branch = "main"
142
146
  subprocess.run(["git", "fetch", "origin", branch], cwd=base_dir, check=True)
core/test_system_info.py CHANGED
@@ -55,6 +55,22 @@ class SystemInfoScreenModeTests(SimpleTestCase):
55
55
  lock_dir.rmdir()
56
56
 
57
57
 
58
+ class SystemInfoModeTests(SimpleTestCase):
59
+ def test_public_mode_case_insensitive(self):
60
+ lock_dir = Path(settings.BASE_DIR) / "locks"
61
+ lock_dir.mkdir(exist_ok=True)
62
+ lock_file = lock_dir / "nginx_mode.lck"
63
+ lock_file.write_text("PUBLIC", encoding="utf-8")
64
+ try:
65
+ info = _gather_info()
66
+ self.assertEqual(info["mode"], "public")
67
+ self.assertEqual(info["port"], 8000)
68
+ finally:
69
+ lock_file.unlink()
70
+ if not any(lock_dir.iterdir()):
71
+ lock_dir.rmdir()
72
+
73
+
58
74
  class SystemInfoRevisionTests(SimpleTestCase):
59
75
  @patch("core.system.revision.get_revision", return_value="abcdef1234567890")
60
76
  def test_includes_full_revision(self, mock_revision):