arthexis 0.1.6__py3-none-any.whl → 0.1.7__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/release.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import os
4
4
  import subprocess
5
5
  import sys
6
+ import shutil
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
9
  from typing import Optional
@@ -82,20 +83,12 @@ def _git_clean() -> bool:
82
83
  return not proc.stdout.strip()
83
84
 
84
85
 
85
- def _current_commit() -> str:
86
- return subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip()
86
+ def _git_has_staged_changes() -> bool:
87
+ """Return True if there are staged changes ready to commit."""
88
+ proc = subprocess.run(["git", "diff", "--cached", "--quiet"])
89
+ return proc.returncode != 0
87
90
 
88
91
 
89
- def _current_branch() -> str:
90
- return (
91
- subprocess.check_output([
92
- "git",
93
- "rev-parse",
94
- "--abbrev-ref",
95
- "HEAD",
96
- ]).decode().strip()
97
- )
98
-
99
92
 
100
93
  def _manager_credentials() -> Optional[Credentials]:
101
94
  """Return credentials from the Package's release manager if available."""
@@ -174,69 +167,6 @@ def _write_pyproject(package: Package, version: str, requirements: list[str]) ->
174
167
  Path("pyproject.toml").write_text(_dump_toml(content), encoding="utf-8")
175
168
 
176
169
 
177
- def _ensure_changelog() -> str:
178
- header = "Changelog\n=========\n\n"
179
- path = Path("CHANGELOG.rst")
180
- text = path.read_text(encoding="utf-8") if path.exists() else ""
181
- if not text.startswith("Changelog"):
182
- text = header + text
183
- if "Unreleased" not in text:
184
- text = text[: len(header)] + "Unreleased\n----------\n\n" + text[len(header):]
185
- return text
186
-
187
-
188
- def _pop_unreleased(text: str) -> tuple[str, str]:
189
- lines = text.splitlines()
190
- try:
191
- idx = lines.index("Unreleased")
192
- except ValueError:
193
- return "", text
194
- body = []
195
- i = idx + 2
196
- while i < len(lines) and lines[i].startswith("- "):
197
- body.append(lines[i])
198
- i += 1
199
- if i < len(lines) and lines[i] == "":
200
- i += 1
201
- new_lines = lines[:idx] + lines[i:]
202
- return "\n".join(body), "\n".join(new_lines) + ("\n" if text.endswith("\n") else "")
203
-
204
-
205
- def _last_changelog_revision() -> Optional[str]:
206
- path = Path("CHANGELOG.rst")
207
- if not path.exists():
208
- return None
209
- for line in path.read_text(encoding="utf-8").splitlines():
210
- if "[revision" in line:
211
- try:
212
- return line.split("[revision", 1)[1].split("]", 1)[0].strip()
213
- except Exception:
214
- return None
215
- return None
216
-
217
-
218
- def update_changelog(version: str, revision: str, prev_revision: Optional[str] = None) -> None:
219
- text = _ensure_changelog()
220
- body, text = _pop_unreleased(text)
221
- if not body:
222
- prev_revision = prev_revision or _last_changelog_revision()
223
- log_range = f"{prev_revision}..HEAD" if prev_revision else "HEAD"
224
- proc = subprocess.run(
225
- ["git", "log", "--pretty=%h %s", "--no-merges", log_range],
226
- capture_output=True,
227
- text=True,
228
- check=False,
229
- )
230
- body = "\n".join(
231
- f"- {l.strip()}" for l in proc.stdout.splitlines() if l.strip()
232
- )
233
- header = f"{version} [revision {revision}]"
234
- underline = "-" * len(header)
235
- entry = "\n".join([header, underline, "", body, ""]).rstrip() + "\n\n"
236
- base_header = "Changelog\n=========\n\n"
237
- remaining = text[len(base_header):]
238
- new_text = base_header + "Unreleased\n----------\n\n" + entry + remaining
239
- Path("CHANGELOG.rst").write_text(new_text, encoding="utf-8")
240
170
 
241
171
 
242
172
  @requires_network
@@ -268,8 +198,8 @@ def build(
268
198
  "Git repository is not clean. Commit, stash, or enable auto stash before building."
269
199
  )
270
200
 
201
+ version_path = Path("VERSION")
271
202
  if version is None:
272
- version_path = Path("VERSION")
273
203
  if not version_path.exists():
274
204
  raise ReleaseError("VERSION file not found")
275
205
  version = version_path.read_text().strip()
@@ -278,6 +208,9 @@ def build(
278
208
  patch += 1
279
209
  version = f"{major}.{minor}.{patch}"
280
210
  version_path.write_text(version + "\n")
211
+ else:
212
+ # Ensure the VERSION file reflects the provided release version
213
+ version_path.write_text(version + "\n")
281
214
 
282
215
  requirements = [
283
216
  line.strip()
@@ -291,16 +224,10 @@ def build(
291
224
  if proc.returncode != 0:
292
225
  raise TestsFailed(log_path, proc.stdout + proc.stderr)
293
226
 
294
- commit_hash = _current_commit()
295
- prev_revision = _last_changelog_revision()
296
- update_changelog(version, commit_hash, prev_revision)
297
-
298
227
  _write_pyproject(package, version, requirements)
299
228
  if dist:
300
229
  if Path("dist").exists():
301
- for p in Path("dist").glob("*"):
302
- p.unlink()
303
- Path("dist").rmdir()
230
+ shutil.rmtree("dist")
304
231
  try:
305
232
  import build # type: ignore
306
233
  except Exception:
@@ -308,10 +235,11 @@ def build(
308
235
  _run([sys.executable, "-m", "build"])
309
236
 
310
237
  if git:
311
- files = ["VERSION", "pyproject.toml", "CHANGELOG.rst"]
238
+ files = ["VERSION", "pyproject.toml"]
312
239
  _run(["git", "add"] + files)
313
240
  msg = f"PyPI Release v{version}" if twine else f"Release v{version}"
314
- _run(["git", "commit", "-m", msg])
241
+ if _git_has_staged_changes():
242
+ _run(["git", "commit", "-m", msg])
315
243
  _run(["git", "push"])
316
244
 
317
245
  if tag:
@@ -359,63 +287,23 @@ def promote(
359
287
  package: Package = DEFAULT_PACKAGE,
360
288
  version: str,
361
289
  creds: Optional[Credentials] = None,
362
- ) -> tuple[str, str, str]:
363
- """Create a release branch and build the package without tests.
364
-
365
- Returns a tuple of the release commit hash, the new branch name and the
366
- original branch name.
367
- """
368
- current = _current_branch()
369
- tmp_branch = f"release/{version}"
370
- stashed = False
371
- try:
372
- try:
373
- _run(["git", "checkout", "-b", tmp_branch])
374
- except subprocess.CalledProcessError:
375
- _run(["git", "checkout", tmp_branch])
376
- if not _git_clean():
377
- _run(["git", "stash", "--include-untracked"])
378
- stashed = True
379
- build(
380
- package=package,
381
- version=version,
382
- creds=creds,
383
- tests=False,
384
- dist=True,
385
- git=False,
386
- tag=False,
387
- stash=True,
388
- )
389
- try: # best effort
390
- _run(
391
- [
392
- sys.executable,
393
- "manage.py",
394
- "squashmigrations",
395
- "core",
396
- "0001",
397
- "--noinput",
398
- ],
399
- check=False,
400
- )
401
- except Exception:
402
- # The squashmigrations command may not be available or could fail
403
- # (e.g. when no migrations exist). Any errors should not interrupt
404
- # the release promotion flow.
405
- pass
406
- _run(["git", "add", "."]) # add all changes
290
+ ) -> None:
291
+ """Build the package and commit the release on the current branch."""
292
+ if not _git_clean():
293
+ raise ReleaseError("Git repository is not clean")
294
+ build(
295
+ package=package,
296
+ version=version,
297
+ creds=creds,
298
+ tests=False,
299
+ dist=True,
300
+ git=False,
301
+ tag=False,
302
+ stash=False,
303
+ )
304
+ _run(["git", "add", "."]) # add all changes
305
+ if _git_has_staged_changes():
407
306
  _run(["git", "commit", "-m", f"Release v{version}"])
408
- commit_hash = _current_commit()
409
- release_name = f"{package.name}-{version}-{commit_hash[:7]}"
410
- branch = f"release-{release_name}"
411
- _run(["git", "branch", "-m", branch])
412
- except Exception:
413
- _run(["git", "checkout", current])
414
- raise
415
- finally:
416
- if stashed:
417
- _run(["git", "stash", "pop"], check=False)
418
- return commit_hash, branch, current
419
307
 
420
308
 
421
309
  def publish(
core/system.py CHANGED
@@ -31,8 +31,8 @@ def _gather_info() -> dict:
31
31
  info["mode"] = mode
32
32
  info["port"] = 8000 if mode == "public" else 8888
33
33
 
34
- role_file = lock_dir / "role.lck"
35
- info["role"] = role_file.read_text().strip() if role_file.exists() else "unknown"
34
+ # Use settings.NODE_ROLE as the single source of truth for the node role.
35
+ info["role"] = getattr(settings, "NODE_ROLE", "Terminal")
36
36
 
37
37
  info["features"] = {
38
38
  "celery": (lock_dir / "celery.lck").exists(),
@@ -0,0 +1,21 @@
1
+ import os
2
+
3
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
+
5
+ import django
6
+ django.setup()
7
+
8
+ from django.test import SimpleTestCase, override_settings
9
+ from core.system import _gather_info
10
+
11
+
12
+ class SystemInfoRoleTests(SimpleTestCase):
13
+ @override_settings(NODE_ROLE="Terminal")
14
+ def test_defaults_to_terminal(self):
15
+ info = _gather_info()
16
+ self.assertEqual(info["role"], "Terminal")
17
+
18
+ @override_settings(NODE_ROLE="Satellite")
19
+ def test_uses_settings_role(self):
20
+ info = _gather_info()
21
+ self.assertEqual(info["role"], "Satellite")
core/tests.py CHANGED
@@ -4,11 +4,13 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
4
  import django
5
5
  django.setup()
6
6
 
7
- from django.test import Client, TestCase
7
+ from django.test import Client, TestCase, RequestFactory
8
8
  from django.urls import reverse
9
9
  from django.http import HttpRequest
10
10
  import json
11
11
  from unittest import mock
12
+ from pathlib import Path
13
+ import subprocess
12
14
 
13
15
  from django.utils import timezone
14
16
  from .models import (
@@ -24,13 +26,18 @@ from .models import (
24
26
  RFID,
25
27
  FediverseProfile,
26
28
  SecurityGroup,
29
+ Package,
30
+ PackageRelease,
27
31
  )
32
+ from django.contrib.admin.sites import AdminSite
33
+ from core.admin import PackageReleaseAdmin
28
34
  from ocpp.models import Transaction, Charger
29
35
 
30
36
  from django.core.exceptions import ValidationError
31
37
  from django.core.management import call_command
32
38
  from django.db import IntegrityError
33
39
  from .backends import LocalhostAdminBackend
40
+ from core.views import _step_check_pypi, _step_promote_build, _step_publish
34
41
 
35
42
 
36
43
  class DefaultAdminTests(TestCase):
@@ -481,3 +488,195 @@ class FediverseProfileTests(TestCase):
481
488
  profile.test_connection()
482
489
  self.assertIsNone(profile.verified_on)
483
490
 
491
+
492
+ class ReleaseProcessTests(TestCase):
493
+ def setUp(self):
494
+ self.package = Package.objects.create(name="pkg")
495
+ self.release = PackageRelease.objects.create(
496
+ package=self.package, version="1.0.0"
497
+ )
498
+
499
+ @mock.patch("core.views.release_utils._git_clean", return_value=False)
500
+ def test_step_check_requires_clean_repo(self, git_clean):
501
+ with self.assertRaises(Exception):
502
+ _step_check_pypi(self.release, {}, Path("rel.log"))
503
+
504
+ @mock.patch("core.views.release_utils._git_clean", return_value=True)
505
+ @mock.patch("core.views.release_utils.network_available", return_value=False)
506
+ def test_step_check_keeps_repo_clean(self, network_available, git_clean):
507
+ version_path = Path("VERSION")
508
+ original = version_path.read_text(encoding="utf-8")
509
+ _step_check_pypi(self.release, {}, Path("rel.log"))
510
+ proc = subprocess.run(
511
+ ["git", "status", "--porcelain", str(version_path)],
512
+ capture_output=True,
513
+ text=True,
514
+ )
515
+ self.assertFalse(proc.stdout.strip())
516
+ self.assertEqual(version_path.read_text(encoding="utf-8"), original)
517
+
518
+ @mock.patch("core.models.PackageRelease.dump_fixture")
519
+ def test_save_does_not_dump_fixture(self, dump):
520
+ self.release.pypi_url = "https://example.com"
521
+ self.release.save()
522
+ dump.assert_not_called()
523
+
524
+ @mock.patch("core.views.subprocess.run")
525
+ @mock.patch("core.views.PackageRelease.dump_fixture")
526
+ @mock.patch("core.views.release_utils.promote", side_effect=Exception("boom"))
527
+ def test_promote_cleans_repo_on_failure(
528
+ self, promote, dump_fixture, run
529
+ ):
530
+ with self.assertRaises(Exception):
531
+ _step_promote_build(self.release, {}, Path("rel.log"))
532
+ dump_fixture.assert_not_called()
533
+ run.assert_any_call(["git", "reset", "--hard"], check=False)
534
+ run.assert_any_call(["git", "clean", "-fd"], check=False)
535
+
536
+ @mock.patch("core.views.subprocess.run")
537
+ @mock.patch("core.views.PackageRelease.dump_fixture")
538
+ @mock.patch("core.views.release_utils.promote")
539
+ def test_promote_rebases_and_pushes_main(self, promote, dump_fixture, run):
540
+ import subprocess as sp
541
+
542
+ def fake_run(cmd, check=True, capture_output=False, text=False):
543
+ if capture_output:
544
+ return sp.CompletedProcess(cmd, 0, stdout="", stderr="")
545
+ return sp.CompletedProcess(cmd, 0)
546
+
547
+ run.side_effect = fake_run
548
+ _step_promote_build(self.release, {}, Path("rel.log"))
549
+ run.assert_any_call(["git", "fetch", "origin", "main"], check=True)
550
+ run.assert_any_call(["git", "rebase", "origin/main"], check=True)
551
+ run.assert_any_call(["git", "push"], check=True)
552
+
553
+ @mock.patch("core.views.subprocess.run")
554
+ @mock.patch("core.views.PackageRelease.dump_fixture")
555
+ def test_promote_advances_version(self, dump_fixture, run):
556
+ import subprocess as sp
557
+
558
+ def fake_run(cmd, check=True, capture_output=False, text=False):
559
+ if capture_output:
560
+ return sp.CompletedProcess(cmd, 0, stdout="", stderr="")
561
+ return sp.CompletedProcess(cmd, 0)
562
+
563
+ run.side_effect = fake_run
564
+
565
+ version_path = Path("VERSION")
566
+ original = version_path.read_text(encoding="utf-8")
567
+ version_path.write_text("0.0.1\n", encoding="utf-8")
568
+
569
+ def fake_promote(*args, **kwargs):
570
+ version_path.write_text(self.release.version + "\n", encoding="utf-8")
571
+
572
+ with mock.patch("core.views.release_utils.promote", side_effect=fake_promote):
573
+ _step_promote_build(self.release, {}, Path("rel.log"))
574
+
575
+ self.assertEqual(
576
+ version_path.read_text(encoding="utf-8"),
577
+ self.release.version + "\n",
578
+ )
579
+ version_path.write_text(original, encoding="utf-8")
580
+
581
+ @mock.patch("core.views.PackageRelease.dump_fixture")
582
+ @mock.patch("core.views.release_utils.publish")
583
+ def test_publish_sets_pypi_url(self, publish, dump_fixture):
584
+ _step_publish(self.release, {}, Path("rel.log"))
585
+ self.release.refresh_from_db()
586
+ self.assertEqual(
587
+ self.release.pypi_url,
588
+ f"https://pypi.org/project/{self.package.name}/{self.release.version}/",
589
+ )
590
+ dump_fixture.assert_called_once()
591
+
592
+ @mock.patch("core.views.PackageRelease.dump_fixture")
593
+ @mock.patch("core.views.release_utils.publish", side_effect=Exception("boom"))
594
+ def test_publish_failure_keeps_url_blank(self, publish, dump_fixture):
595
+ with self.assertRaises(Exception):
596
+ _step_publish(self.release, {}, Path("rel.log"))
597
+ self.release.refresh_from_db()
598
+ self.assertEqual(self.release.pypi_url, "")
599
+ dump_fixture.assert_not_called()
600
+
601
+ def test_release_progress_uses_lockfile(self):
602
+ run = []
603
+
604
+ def step1(release, ctx, log_path):
605
+ run.append("step1")
606
+
607
+ def step2(release, ctx, log_path):
608
+ run.append("step2")
609
+
610
+ steps = [("One", step1), ("Two", step2)]
611
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
612
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
613
+ with mock.patch("core.views.PUBLISH_STEPS", steps):
614
+ self.client.force_login(user)
615
+ self.client.get(f"{url}?step=0")
616
+ self.assertEqual(run, ["step1"])
617
+ client2 = Client()
618
+ client2.force_login(user)
619
+ client2.get(f"{url}?step=1")
620
+ self.assertEqual(run, ["step1", "step2"])
621
+ lock_file = Path("locks") / f"release_publish_{self.release.pk}.json"
622
+ self.assertFalse(lock_file.exists())
623
+
624
+ def test_release_progress_restart(self):
625
+ run = []
626
+
627
+ def step_fail(release, ctx, log_path):
628
+ run.append("step")
629
+ raise Exception("boom")
630
+
631
+ steps = [("Fail", step_fail)]
632
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
633
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
634
+ count_file = Path("locks") / f"release_publish_{self.release.pk}.restarts"
635
+ if count_file.exists():
636
+ count_file.unlink()
637
+ with mock.patch("core.views.PUBLISH_STEPS", steps):
638
+ self.client.force_login(user)
639
+ self.assertFalse(count_file.exists())
640
+ self.client.get(f"{url}?step=0")
641
+ self.client.get(f"{url}?step=0")
642
+ self.assertEqual(run, ["step"])
643
+ self.assertFalse(count_file.exists())
644
+ self.client.get(f"{url}?restart=1")
645
+ self.assertTrue(count_file.exists())
646
+ self.assertEqual(count_file.read_text(), "1")
647
+ self.client.get(f"{url}?step=0")
648
+ self.assertEqual(run, ["step", "step"])
649
+ self.client.get(f"{url}?restart=1")
650
+ self.assertEqual(count_file.read_text(), "2")
651
+
652
+
653
+ class PackageReleaseAdminActionTests(TestCase):
654
+ def setUp(self):
655
+ self.factory = RequestFactory()
656
+ self.site = AdminSite()
657
+ self.admin = PackageReleaseAdmin(PackageRelease, self.site)
658
+ self.admin.message_user = lambda *args, **kwargs: None
659
+ self.package = Package.objects.create(name="pkg")
660
+ self.release = PackageRelease.objects.create(
661
+ package=self.package,
662
+ version="1.0.0",
663
+ pypi_url="https://pypi.org/project/pkg/1.0.0/",
664
+ )
665
+ self.request = self.factory.get("/")
666
+
667
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
668
+ @mock.patch("core.admin.requests.get")
669
+ def test_validate_deletes_missing_release(self, mock_get, dump):
670
+ mock_get.return_value.status_code = 404
671
+ self.admin.validate_releases(self.request, PackageRelease.objects.all())
672
+ self.assertEqual(PackageRelease.objects.count(), 0)
673
+ dump.assert_called_once()
674
+
675
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
676
+ @mock.patch("core.admin.requests.get")
677
+ def test_validate_keeps_existing_release(self, mock_get, dump):
678
+ mock_get.return_value.status_code = 200
679
+ self.admin.validate_releases(self.request, PackageRelease.objects.all())
680
+ self.assertEqual(PackageRelease.objects.count(), 1)
681
+ dump.assert_not_called()
682
+