arthexis 0.1.22__py3-none-any.whl → 0.1.23__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/tests.py CHANGED
@@ -2138,6 +2138,25 @@ class PackageReleaseCurrentTests(TestCase):
2138
2138
  self.package.save()
2139
2139
  self.assertFalse(self.release.is_current)
2140
2140
 
2141
+ def test_is_current_false_when_version_has_plus(self):
2142
+ self.version_path.write_text("1.0.0+")
2143
+ self.assertFalse(self.release.is_current)
2144
+
2145
+
2146
+ class PackageReleaseRevisionTests(TestCase):
2147
+ def setUp(self):
2148
+ self.package = Package.objects.get(name="arthexis")
2149
+ self.release = PackageRelease.objects.create(
2150
+ package=self.package,
2151
+ version="1.0.0",
2152
+ revision="abcdef123456",
2153
+ )
2154
+
2155
+ def test_matches_revision_ignores_plus_suffix(self):
2156
+ self.assertTrue(
2157
+ PackageRelease.matches_revision("1.0.0+", "abcdef123456")
2158
+ )
2159
+
2141
2160
  def test_is_current_false_when_version_differs(self):
2142
2161
  self.release.version = "2.0.0"
2143
2162
  self.release.save()
@@ -2188,13 +2207,22 @@ class PackageAdminPrepareNextReleaseTests(TestCase):
2188
2207
 
2189
2208
  def test_prepare_next_release_active_creates_release(self):
2190
2209
  PackageRelease.all_objects.filter(package=self.package).delete()
2191
- request = self.factory.get("/admin/core/package/prepare-next-release/")
2210
+ request = self.factory.post("/admin/core/package/prepare-next-release/")
2192
2211
  response = self.admin.prepare_next_release_active(request)
2193
2212
  self.assertEqual(response.status_code, 302)
2194
2213
  self.assertEqual(
2195
2214
  PackageRelease.all_objects.filter(package=self.package).count(), 1
2196
2215
  )
2197
2216
 
2217
+ def test_prepare_next_release_active_get_creates_release(self):
2218
+ PackageRelease.all_objects.filter(package=self.package).delete()
2219
+ request = self.factory.get("/admin/core/package/prepare-next-release/")
2220
+ response = self.admin.prepare_next_release_active(request)
2221
+ self.assertEqual(response.status_code, 302)
2222
+ self.assertTrue(
2223
+ PackageRelease.all_objects.filter(package=self.package).exists()
2224
+ )
2225
+
2198
2226
 
2199
2227
  class PackageAdminChangeViewTests(TestCase):
2200
2228
  def setUp(self):
core/user_data.py CHANGED
@@ -201,9 +201,49 @@ def dump_user_fixture(instance, user=None) -> None:
201
201
 
202
202
  def delete_user_fixture(instance, user=None) -> None:
203
203
  target_user = user or _resolve_fixture_user(instance)
204
- if target_user is None:
204
+ filename = (
205
+ f"{instance._meta.app_label}_{instance._meta.model_name}_{instance.pk}.json"
206
+ )
207
+
208
+ def _remove_for_user(candidate) -> None:
209
+ if candidate is None:
210
+ return
211
+ base_path = Path(
212
+ getattr(candidate, "data_path", "") or Path(settings.BASE_DIR) / "data"
213
+ )
214
+ username = _username_for(candidate)
215
+ if not username:
216
+ return
217
+ user_dir = base_path / username
218
+ if user_dir.exists():
219
+ (user_dir / filename).unlink(missing_ok=True)
220
+
221
+ if target_user is not None:
222
+ _remove_for_user(target_user)
205
223
  return
206
- _fixture_path(target_user, instance).unlink(missing_ok=True)
224
+
225
+ root = Path(settings.BASE_DIR) / "data"
226
+ if root.exists():
227
+ (root / filename).unlink(missing_ok=True)
228
+ for path in root.iterdir():
229
+ if path.is_dir():
230
+ (path / filename).unlink(missing_ok=True)
231
+
232
+ UserModel = get_user_model()
233
+ manager = getattr(UserModel, "all_objects", UserModel._default_manager)
234
+ for candidate in manager.all():
235
+ data_path = getattr(candidate, "data_path", "")
236
+ if not data_path:
237
+ continue
238
+ base_path = Path(data_path)
239
+ if not base_path.exists():
240
+ continue
241
+ username = _username_for(candidate)
242
+ if not username:
243
+ continue
244
+ user_dir = base_path / username
245
+ if user_dir.exists():
246
+ (user_dir / filename).unlink(missing_ok=True)
207
247
 
208
248
 
209
249
  def _mark_fixture_user_data(path: Path) -> None:
core/views.py CHANGED
@@ -690,16 +690,17 @@ def _ensure_origin_main_unchanged(log_path: Path) -> None:
690
690
  def _next_patch_version(version: str) -> str:
691
691
  from packaging.version import InvalidVersion, Version
692
692
 
693
+ cleaned = version.rstrip("+")
693
694
  try:
694
- parsed = Version(version)
695
+ parsed = Version(cleaned)
695
696
  except InvalidVersion:
696
- parts = version.split(".")
697
+ parts = cleaned.split(".") if cleaned else []
697
698
  for index in range(len(parts) - 1, -1, -1):
698
699
  segment = parts[index]
699
700
  if segment.isdigit():
700
701
  parts[index] = str(int(segment) + 1)
701
702
  return ".".join(parts)
702
- return version
703
+ return cleaned or version
703
704
  return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
704
705
 
705
706
 
@@ -768,7 +769,9 @@ def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
768
769
  version_path = Path("VERSION")
769
770
  if version_path.exists():
770
771
  try:
771
- repo_version = Version(version_path.read_text(encoding="utf-8").strip())
772
+ raw_version = version_path.read_text(encoding="utf-8").strip()
773
+ cleaned_version = raw_version.rstrip("+") or "0.0.0"
774
+ repo_version = Version(cleaned_version)
772
775
  except InvalidVersion:
773
776
  repo_version = None
774
777
 
@@ -935,7 +938,7 @@ def _refresh_changelog_once(ctx, log_path: Path) -> None:
935
938
  ctx["changelog_refreshed"] = True
936
939
 
937
940
 
938
- def _step_check_todos(release, ctx, log_path: Path) -> None:
941
+ def _step_check_todos(release, ctx, log_path: Path, *, user=None) -> None:
939
942
  _refresh_changelog_once(ctx, log_path)
940
943
 
941
944
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
@@ -975,7 +978,7 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
975
978
  ctx["todos_ack"] = True
976
979
 
977
980
 
978
- def _step_check_version(release, ctx, log_path: Path) -> None:
981
+ def _step_check_version(release, ctx, log_path: Path, *, user=None) -> None:
979
982
  from . import release as release_utils
980
983
  from packaging.version import InvalidVersion, Version
981
984
 
@@ -1110,10 +1113,12 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
1110
1113
  version_path = Path("VERSION")
1111
1114
  if version_path.exists():
1112
1115
  current = version_path.read_text(encoding="utf-8").strip()
1113
- if current and Version(release.version) < Version(current):
1114
- raise Exception(
1115
- f"Version {release.version} is older than existing {current}"
1116
- )
1116
+ if current:
1117
+ current_clean = current.rstrip("+") or "0.0.0"
1118
+ if Version(release.version) < Version(current_clean):
1119
+ raise Exception(
1120
+ f"Version {release.version} is older than existing {current}"
1121
+ )
1117
1122
 
1118
1123
  _append_log(log_path, f"Checking if version {release.version} exists on PyPI")
1119
1124
  if release_utils.network_available():
@@ -1163,17 +1168,17 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
1163
1168
  _append_log(log_path, "Network unavailable, skipping PyPI check")
1164
1169
 
1165
1170
 
1166
- def _step_handle_migrations(release, ctx, log_path: Path) -> None:
1171
+ def _step_handle_migrations(release, ctx, log_path: Path, *, user=None) -> None:
1167
1172
  _append_log(log_path, "Freeze, squash and approve migrations")
1168
1173
  _append_log(log_path, "Migration review acknowledged (manual step)")
1169
1174
 
1170
1175
 
1171
- def _step_changelog_docs(release, ctx, log_path: Path) -> None:
1176
+ def _step_changelog_docs(release, ctx, log_path: Path, *, user=None) -> None:
1172
1177
  _append_log(log_path, "Compose CHANGELOG and documentation")
1173
1178
  _append_log(log_path, "CHANGELOG and documentation review recorded")
1174
1179
 
1175
1180
 
1176
- def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1181
+ def _step_pre_release_actions(release, ctx, log_path: Path, *, user=None) -> None:
1177
1182
  _append_log(log_path, "Execute pre-release actions")
1178
1183
  if ctx.get("dry_run"):
1179
1184
  _append_log(log_path, "Dry run: skipping pre-release actions")
@@ -1249,12 +1254,12 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1249
1254
  _append_log(log_path, "Pre-release actions complete")
1250
1255
 
1251
1256
 
1252
- def _step_run_tests(release, ctx, log_path: Path) -> None:
1257
+ def _step_run_tests(release, ctx, log_path: Path, *, user=None) -> None:
1253
1258
  _append_log(log_path, "Complete test suite with --all flag")
1254
1259
  _append_log(log_path, "Test suite completion acknowledged")
1255
1260
 
1256
1261
 
1257
- def _step_promote_build(release, ctx, log_path: Path) -> None:
1262
+ def _step_promote_build(release, ctx, log_path: Path, *, user=None) -> None:
1258
1263
  from . import release as release_utils
1259
1264
 
1260
1265
  _append_log(log_path, "Generating build files")
@@ -1266,7 +1271,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1266
1271
  release_utils.promote(
1267
1272
  package=release.to_package(),
1268
1273
  version=release.version,
1269
- creds=release.to_credentials(),
1274
+ creds=release.to_credentials(user=user),
1270
1275
  )
1271
1276
  _append_log(
1272
1277
  log_path,
@@ -1314,8 +1319,10 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1314
1319
  _append_log(new_log, "Build complete")
1315
1320
 
1316
1321
 
1317
- def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
1318
- if release.to_credentials() is None:
1322
+ def _step_release_manager_approval(
1323
+ release, ctx, log_path: Path, *, user=None
1324
+ ) -> None:
1325
+ if release.to_credentials(user=user) is None:
1319
1326
  ctx.pop("release_approval", None)
1320
1327
  if not ctx.get("approval_credentials_missing"):
1321
1328
  _append_log(log_path, "Release manager publishing credentials missing")
@@ -1349,14 +1356,14 @@ def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
1349
1356
  raise ApprovalRequired()
1350
1357
 
1351
1358
 
1352
- def _step_publish(release, ctx, log_path: Path) -> None:
1359
+ def _step_publish(release, ctx, log_path: Path, *, user=None) -> None:
1353
1360
  from . import release as release_utils
1354
1361
 
1355
1362
  if ctx.get("dry_run"):
1356
1363
  test_repository_url = os.environ.get(
1357
1364
  "PYPI_TEST_REPOSITORY_URL", "https://test.pypi.org/legacy/"
1358
1365
  )
1359
- test_creds = release.to_credentials()
1366
+ test_creds = release.to_credentials(user=user)
1360
1367
  if not (test_creds and test_creds.has_auth()):
1361
1368
  test_creds = release_utils.Credentials(
1362
1369
  token=os.environ.get("PYPI_TEST_API_TOKEN"),
@@ -1396,7 +1403,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1396
1403
  release_utils.build(
1397
1404
  package=package,
1398
1405
  version=release.version,
1399
- creds=release.to_credentials(),
1406
+ creds=release.to_credentials(user=user),
1400
1407
  dist=True,
1401
1408
  tests=False,
1402
1409
  twine=False,
@@ -1425,13 +1432,13 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1425
1432
  release_utils.publish(
1426
1433
  package=release.to_package(),
1427
1434
  version=release.version,
1428
- creds=target.credentials or release.to_credentials(),
1435
+ creds=target.credentials or release.to_credentials(user=user),
1429
1436
  repositories=[target],
1430
1437
  )
1431
1438
  _append_log(log_path, "Dry run: skipped release metadata updates")
1432
1439
  return
1433
1440
 
1434
- targets = release.build_publish_targets()
1441
+ targets = release.build_publish_targets(user=user)
1435
1442
  repo_labels = []
1436
1443
  for target in targets:
1437
1444
  label = target.name
@@ -1450,7 +1457,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1450
1457
  release_utils.publish(
1451
1458
  package=release.to_package(),
1452
1459
  version=release.version,
1453
- creds=release.to_credentials(),
1460
+ creds=release.to_credentials(user=user),
1454
1461
  repositories=targets,
1455
1462
  )
1456
1463
  except release_utils.PostPublishWarning as warning:
@@ -1819,7 +1826,7 @@ def release_progress(request, pk: int, action: str):
1819
1826
  return redirect(request.path)
1820
1827
 
1821
1828
  manager = release.release_manager or release.package.release_manager
1822
- credentials_ready = bool(release.to_credentials())
1829
+ credentials_ready = bool(release.to_credentials(user=request.user))
1823
1830
  if credentials_ready and ctx.get("approval_credentials_missing"):
1824
1831
  ctx.pop("approval_credentials_missing", None)
1825
1832
 
@@ -1990,7 +1997,7 @@ def release_progress(request, pk: int, action: str):
1990
1997
  if to_run == step_count:
1991
1998
  name, func = steps[to_run]
1992
1999
  try:
1993
- func(release, ctx, log_path)
2000
+ func(release, ctx, log_path, user=request.user)
1994
2001
  except PendingTodos:
1995
2002
  pass
1996
2003
  except ApprovalRequired:
nodes/admin.py CHANGED
@@ -19,7 +19,7 @@ from django.utils.html import format_html, format_html_join
19
19
  from django.utils.translation import gettext_lazy as _
20
20
  from pathlib import Path
21
21
  from types import SimpleNamespace
22
- from urllib.parse import urlparse, urlsplit, urlunsplit
22
+ from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit
23
23
  import base64
24
24
  import ipaddress
25
25
  import json
@@ -440,6 +440,12 @@ class NodeAdmin(EntityModelAdmin):
440
440
  },
441
441
  "target": reverse("admin:index"),
442
442
  }
443
+ mac_address = str(local_node.mac_address or "").strip()
444
+ if mac_address:
445
+ payload["requester_mac"] = mac_address
446
+ public_key = local_node.public_key
447
+ if public_key:
448
+ payload["requester_public_key"] = public_key
443
449
  return payload
444
450
 
445
451
  def _start_proxy_session(self, request, node):
@@ -474,29 +480,64 @@ class NodeAdmin(EntityModelAdmin):
474
480
  }
475
481
 
476
482
  last_error = ""
483
+ redirect_codes = {301, 302, 303, 307, 308}
484
+
477
485
  for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
478
- try:
479
- response = requests.post(url, data=body, headers=headers, timeout=5)
480
- except RequestException as exc:
481
- last_error = str(exc)
482
- continue
483
- if not response.ok:
484
- last_error = f"{response.status_code} {response.text}"
485
- continue
486
- try:
487
- data = response.json()
488
- except ValueError:
489
- last_error = "Invalid JSON response"
490
- continue
491
- login_url = data.get("login_url")
492
- if not login_url:
493
- last_error = "login_url missing"
494
- continue
495
- return {
496
- "ok": True,
497
- "login_url": login_url,
498
- "expires": data.get("expires"),
499
- }
486
+ candidate_url = url
487
+ redirects_followed = 0
488
+ success = False
489
+
490
+ while True:
491
+ try:
492
+ response = requests.post(
493
+ candidate_url,
494
+ data=body,
495
+ headers=headers,
496
+ timeout=5,
497
+ allow_redirects=False,
498
+ )
499
+ except RequestException as exc:
500
+ last_error = str(exc)
501
+ break
502
+
503
+ if response.status_code in redirect_codes:
504
+ location = response.headers.get("Location")
505
+ if not location:
506
+ last_error = f"{response.status_code} redirect missing Location header"
507
+ break
508
+
509
+ redirects_followed += 1
510
+ if redirects_followed > 3:
511
+ last_error = "Too many redirects"
512
+ break
513
+
514
+ candidate_url = urljoin(candidate_url, location)
515
+ continue
516
+
517
+ if not response.ok:
518
+ last_error = f"{response.status_code} {response.text}"
519
+ break
520
+
521
+ try:
522
+ data = response.json()
523
+ except ValueError:
524
+ last_error = "Invalid JSON response"
525
+ break
526
+
527
+ login_url = data.get("login_url")
528
+ if not login_url:
529
+ last_error = "login_url missing"
530
+ break
531
+
532
+ success = True
533
+ break
534
+
535
+ if success:
536
+ return {
537
+ "ok": True,
538
+ "login_url": login_url,
539
+ "expires": data.get("expires"),
540
+ }
500
541
 
501
542
  return {
502
543
  "ok": False,
nodes/models.py CHANGED
@@ -4,6 +4,7 @@ from collections.abc import Iterable
4
4
  from copy import deepcopy
5
5
  from dataclasses import dataclass
6
6
  from django.db import models
7
+ from django.db.models import Q
7
8
  from django.db.utils import DatabaseError
8
9
  from django.db.models.signals import post_delete
9
10
  from django.dispatch import Signal, receiver
@@ -294,7 +295,14 @@ class Node(Entity):
294
295
  """Return the node representing the current host if it exists."""
295
296
  mac = cls.get_current_mac()
296
297
  try:
297
- return cls.objects.filter(mac_address=mac).first()
298
+ node = cls.objects.filter(mac_address__iexact=mac).first()
299
+ if node:
300
+ return node
301
+ return (
302
+ cls.objects.filter(current_relation=cls.Relation.SELF)
303
+ .filter(Q(mac_address__isnull=True) | Q(mac_address=""))
304
+ .first()
305
+ )
298
306
  except DatabaseError:
299
307
  logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
300
308
  return None
nodes/tests.py CHANGED
@@ -1844,6 +1844,8 @@ class NodeAdminTests(TestCase):
1844
1844
  mock_post.assert_called()
1845
1845
  payload = json.loads(mock_post.call_args[1]["data"])
1846
1846
  self.assertEqual(payload.get("requester"), str(local_node.uuid))
1847
+ self.assertEqual(payload.get("requester_mac"), local_node.mac_address)
1848
+ self.assertEqual(payload.get("requester_public_key"), local_node.public_key)
1847
1849
 
1848
1850
  @patch("nodes.admin.requests.post")
1849
1851
  def test_proxy_view_falls_back_to_http_after_ssl_error(self, mock_post):
@@ -1882,6 +1884,52 @@ class NodeAdminTests(TestCase):
1882
1884
  self.assertIn("/nodes/proxy/session/", second_url)
1883
1885
  payload = json.loads(mock_post.call_args_list[-1].kwargs["data"])
1884
1886
  self.assertEqual(payload.get("requester"), str(local_node.uuid))
1887
+ self.assertEqual(payload.get("requester_mac"), local_node.mac_address)
1888
+ self.assertEqual(payload.get("requester_public_key"), local_node.public_key)
1889
+
1890
+ @patch("nodes.admin.requests.post")
1891
+ def test_proxy_view_retries_post_after_redirect(self, mock_post):
1892
+ self.client.get(reverse("admin:nodes_node_register_current"))
1893
+ remote = Node.objects.create(
1894
+ hostname="redirect-node",
1895
+ public_endpoint="http://remote.example",
1896
+ address="198.51.100.30",
1897
+ mac_address="aa:bb:cc:dd:ee:20",
1898
+ )
1899
+
1900
+ redirect_response = SimpleNamespace(
1901
+ status_code=301,
1902
+ ok=True,
1903
+ text="redirect",
1904
+ headers={"Location": "https://remote.example/nodes/proxy/session/"},
1905
+ )
1906
+ success_response = SimpleNamespace(
1907
+ status_code=200,
1908
+ ok=True,
1909
+ text="ok",
1910
+ headers={},
1911
+ json=lambda: {
1912
+ "login_url": "https://remote.example/nodes/proxy/login/token",
1913
+ "expires": "2025-01-01T00:00:00",
1914
+ },
1915
+ )
1916
+
1917
+ mock_post.side_effect = [redirect_response, success_response]
1918
+
1919
+ response = self.client.get(
1920
+ reverse("admin:nodes_node_proxy", args=[remote.pk])
1921
+ )
1922
+
1923
+ self.assertEqual(response.status_code, 200)
1924
+ self.assertEqual(mock_post.call_count, 2)
1925
+
1926
+ first_call_kwargs = mock_post.call_args_list[0].kwargs
1927
+ self.assertFalse(first_call_kwargs.get("allow_redirects", True))
1928
+
1929
+ second_url = mock_post.call_args_list[1].args[0]
1930
+ self.assertEqual(second_url, "https://remote.example/nodes/proxy/session/")
1931
+ second_call_kwargs = mock_post.call_args_list[1].kwargs
1932
+ self.assertFalse(second_call_kwargs.get("allow_redirects", True))
1885
1933
 
1886
1934
  def test_proxy_link_displayed_for_remote_nodes(self):
1887
1935
  Node.objects.create(
@@ -2629,6 +2677,32 @@ class NodeProxyGatewayTests(TestCase):
2629
2677
  second = self.client.get(parsed.path)
2630
2678
  self.assertEqual(second.status_code, 410)
2631
2679
 
2680
+ def test_proxy_session_accepts_mac_hint_when_uuid_unknown(self):
2681
+ payload = {
2682
+ "requester": str(uuid.uuid4()),
2683
+ "requester_mac": self.node.mac_address,
2684
+ "requester_public_key": self.node.public_key,
2685
+ "user": {
2686
+ "username": "proxy-user",
2687
+ "email": "proxy@example.com",
2688
+ "first_name": "Proxy",
2689
+ "last_name": "User",
2690
+ "is_staff": True,
2691
+ "is_superuser": True,
2692
+ "groups": [],
2693
+ "permissions": [],
2694
+ },
2695
+ "target": "/admin/",
2696
+ }
2697
+ body, signature = self._sign(payload)
2698
+ response = self.client.post(
2699
+ reverse("node-proxy-session"),
2700
+ data=body,
2701
+ content_type="application/json",
2702
+ HTTP_X_SIGNATURE=signature,
2703
+ )
2704
+ self.assertEqual(response.status_code, 200)
2705
+
2632
2706
  def test_proxy_execute_lists_nodes(self):
2633
2707
  Node.objects.create(
2634
2708
  hostname="target",