devpi-admin 1.4.2__tar.gz → 1.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/PKG-INFO +29 -1
  2. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/README.md +28 -0
  3. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/_version.py +3 -3
  4. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/main.py +65 -0
  5. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/css/style.css +49 -2
  6. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/js/app.js +100 -19
  7. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/PKG-INFO +29 -1
  8. devpi_admin-1.4.3/tests/test_view_helpers.py +151 -0
  9. devpi_admin-1.4.2/tests/test_view_helpers.py +0 -79
  10. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/.github/workflows/publish.yml +0 -0
  11. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/.github/workflows/tests.yml +0 -0
  12. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/.gitignore +0 -0
  13. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/LICENSE +0 -0
  14. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/__init__.py +0 -0
  15. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/customizer.py +0 -0
  16. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/favicon.svg +0 -0
  17. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/index.html +0 -0
  18. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/js/api.js +0 -0
  19. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/js/marked.min.js +0 -0
  20. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/static/js/theme.js +0 -0
  21. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin/tokens.py +0 -0
  22. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/SOURCES.txt +0 -0
  23. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/dependency_links.txt +0 -0
  24. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/entry_points.txt +0 -0
  25. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/requires.txt +0 -0
  26. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/devpi_admin.egg-info/top_level.txt +0 -0
  27. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/pyproject.toml +0 -0
  28. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/setup.cfg +0 -0
  29. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/__init__.py +0 -0
  30. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_acl_read.py +0 -0
  31. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_devpi_tokens_ui.py +0 -0
  32. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_filter.py +0 -0
  33. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_hooks.py +0 -0
  34. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_json_safe.py +0 -0
  35. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_package.py +0 -0
  36. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_pipconf.py +0 -0
  37. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_tokens.py +0 -0
  38. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_tween.py +0 -0
  39. {devpi_admin-1.4.2 → devpi_admin-1.4.3}/tests/test_wants_html.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License: MIT
@@ -61,6 +61,10 @@ talks to the standard devpi JSON API directly.
61
61
  - **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
62
62
  sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
63
63
  ones with the index pre-filled and locked
64
+ - **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
65
+ the in-memory per-project and project-names caches; the next `+simple/<project>/`
66
+ query (from pip, the UI, or `devpi-client`) goes back to upstream
67
+ (etag-conditional, cheap)
64
68
  - **`Edit`** / **`Delete`** (owner / root)
65
69
  - Create / edit / delete indexes via modal dialogs
66
70
  - `bases` editor with drag & drop priority ordering and transitive inheritance display
@@ -597,6 +601,30 @@ Revoke a single token.
597
601
  - **200:** `{"revoked": true, "id": "abc..."}`
598
602
  - **404:** token id not found
599
603
 
604
+ ### Mirror cache
605
+
606
+ #### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
607
+ Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
608
+ re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
609
+ re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
610
+ mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
611
+ queried). Two caches are expired:
612
+
613
+ - `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
614
+ project, in one pass)
615
+ - `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
616
+ projects" call)
617
+
618
+ Useful when waiting for a freshly-published upstream release that's still hidden
619
+ behind the `mirror_cache_expiry` TTL (default 30 min).
620
+
621
+ - **Auth:** required (any authenticated user)
622
+ - **Primary only:** replicas return 400 (caches are process-local; replicas sync the
623
+ persisted state via the changelog stream once the primary refetches)
624
+ - **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
625
+ - **400:** index is not a mirror, or the call hit a replica
626
+ - **404:** index doesn't exist
627
+
600
628
  ### Replication observability (primary only)
601
629
 
602
630
  #### `GET /+admin-api/replicas`
@@ -37,6 +37,10 @@ talks to the standard devpi JSON API directly.
37
37
  - **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
38
38
  sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
39
39
  ones with the index pre-filled and locked
40
+ - **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
41
+ the in-memory per-project and project-names caches; the next `+simple/<project>/`
42
+ query (from pip, the UI, or `devpi-client`) goes back to upstream
43
+ (etag-conditional, cheap)
40
44
  - **`Edit`** / **`Delete`** (owner / root)
41
45
  - Create / edit / delete indexes via modal dialogs
42
46
  - `bases` editor with drag & drop priority ordering and transitive inheritance display
@@ -573,6 +577,30 @@ Revoke a single token.
573
577
  - **200:** `{"revoked": true, "id": "abc..."}`
574
578
  - **404:** token id not found
575
579
 
580
+ ### Mirror cache
581
+
582
+ #### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
583
+ Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
584
+ re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
585
+ re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
586
+ mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
587
+ queried). Two caches are expired:
588
+
589
+ - `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
590
+ project, in one pass)
591
+ - `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
592
+ projects" call)
593
+
594
+ Useful when waiting for a freshly-published upstream release that's still hidden
595
+ behind the `mirror_cache_expiry` TTL (default 30 min).
596
+
597
+ - **Auth:** required (any authenticated user)
598
+ - **Primary only:** replicas return 400 (caches are process-local; replicas sync the
599
+ persisted state via the changelog stream once the primary refetches)
600
+ - **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
601
+ - **400:** index is not a mirror, or the call hit a replica
602
+ - **404:** index doesn't exist
603
+
576
604
  ### Replication observability (primary only)
577
605
 
578
606
  #### `GET /+admin-api/replicas`
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.4.2'
22
- __version_tuple__ = version_tuple = (1, 4, 2)
21
+ __version__ = version = '1.4.3'
22
+ __version_tuple__ = version_tuple = (1, 4, 3)
23
23
 
24
- __commit_id__ = commit_id = 'g1bf2e00e1'
24
+ __commit_id__ = commit_id = 'geb4eac9d6'
@@ -255,6 +255,16 @@ def devpiserver_pyramid_configure(config, pyramid_config):
255
255
  _revoke_token_view, route_name="devpi_admin_token_revoke",
256
256
  request_method="DELETE")
257
257
 
258
+ # Mirror cache refresh — invalidate per-project retrieve-times and
259
+ # the project-names list so the next pip request re-fetches upstream.
260
+ pyramid_config.add_route(
261
+ "devpi_admin_mirror_refresh_cache",
262
+ "/+admin-api/mirror/{user}/{index}/refresh-cache")
263
+ pyramid_config.add_view(
264
+ _refresh_mirror_cache_view,
265
+ route_name="devpi_admin_mirror_refresh_cache",
266
+ request_method="POST")
267
+
258
268
  # Redirect browser visits to "/" to the SPA. Other routes (JSON API
259
269
  # calls, CLI requests) pass through untouched because they send
260
270
  # Accept: application/json.
@@ -1415,6 +1425,61 @@ def _revoke_token_view(request):
1415
1425
  return _json_response({"revoked": True, "id": tid})
1416
1426
 
1417
1427
 
1428
+ def _refresh_mirror_cache_view(request):
1429
+ """POST /+admin-api/mirror/{user}/{index}/refresh-cache
1430
+
1431
+ Invalidate the in-memory mirror caches so the next pip request goes
1432
+ back to upstream:
1433
+
1434
+ * ``cache_retrieve_times`` — per-project last-fetch timestamp + etag.
1435
+ Expiring forces a conditional GET on the next ``+simple/<project>/``
1436
+ lookup; etag match still reuses the persisted ``PROJSIMPLELINKS``
1437
+ keyfs entry, so the only cost is one HTTP round-trip per project
1438
+ that pip actually queries (we don't pre-fetch).
1439
+ * ``cache_projectnames`` — full PyPI manifest. Expiring forces the
1440
+ next "list all projects" call to refetch (rare path — mostly used
1441
+ by ``pip install <unknown>`` to discover the project exists).
1442
+
1443
+ Caches are process-local: this only works on the primary (the
1444
+ replica's local cache is meaningless to invalidate, and replicas
1445
+ sync persisted state via the changelog stream). Any authenticated
1446
+ user may trigger the refresh — anonymous spam is blocked by auth,
1447
+ upstream-side abuse is bounded by mirror semantics (etag-conditional
1448
+ requests dominate).
1449
+ """
1450
+ xom = request.registry["xom"]
1451
+ _refuse_on_replica(xom)
1452
+ _require_authenticated(request)
1453
+ user = request.matchdict["user"]
1454
+ index = request.matchdict["index"]
1455
+ _validate_name(user, "user")
1456
+ _validate_name(index, "index")
1457
+ with xom.keyfs.read_transaction(allow_reuse=True):
1458
+ stage = xom.model.getstage(user, index)
1459
+ if stage is None:
1460
+ raise HTTPNotFound(
1461
+ json_body={"error": f"index {user}/{index} does not exist"})
1462
+ if stage.ixconfig.get("type") != "mirror":
1463
+ raise HTTPBadRequest(json_body={
1464
+ "error": "cache refresh applies to mirror indexes only"})
1465
+ # `_project2time` is the per-xom singleton dict that backs the
1466
+ # per-project cache; iterating its keys lets us expire only entries
1467
+ # devpi has actually populated. The public `expire(project)` API is
1468
+ # safe for non-tracked projects too (it's pop-with-default), so we
1469
+ # don't need to special-case empty caches.
1470
+ crt = stage.cache_retrieve_times
1471
+ projects = list(getattr(crt, "_project2time", {}).keys())
1472
+ for project in projects:
1473
+ crt.expire(project)
1474
+ stage.cache_projectnames.expire()
1475
+ return _json_response({
1476
+ "result": {
1477
+ "projects_invalidated": len(projects),
1478
+ "projectnames_invalidated": True,
1479
+ },
1480
+ })
1481
+
1482
+
1418
1483
  def _reset_tokens_view(request):
1419
1484
  """DELETE /+admin-api/users/{user}/tokens — revoke all for a user."""
1420
1485
  xom = request.registry["xom"]
@@ -428,7 +428,7 @@ body {
428
428
 
429
429
  .index-grid {
430
430
  display: grid;
431
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
431
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
432
432
  gap: 12px;
433
433
  }
434
434
 
@@ -461,10 +461,19 @@ body {
461
461
  gap: 8px;
462
462
  }
463
463
 
464
+ .index-card-path {
465
+ display: flex;
466
+ align-items: baseline;
467
+ gap: 2px;
468
+ min-width: 0;
469
+ flex: 1 1 auto;
470
+ }
471
+
464
472
  .index-card-tags {
465
473
  display: flex;
466
474
  gap: 4px;
467
475
  margin-left: auto;
476
+ flex-shrink: 0;
468
477
  }
469
478
 
470
479
  .index-card-owner {
@@ -481,7 +490,7 @@ body {
481
490
  .index-card-sep {
482
491
  font-size: 16px;
483
492
  color: var(--text-faint);
484
- margin: 0 1px;
493
+ margin: 0;
485
494
  }
486
495
 
487
496
  .index-card-name {
@@ -1088,6 +1097,44 @@ body {
1088
1097
  color: var(--tag-text);
1089
1098
  }
1090
1099
 
1100
+ /* Compact single-letter variant used in index-card heads where the
1101
+ full word (mirror/stage/volatile/...) would push the kebab off the
1102
+ row in narrow grid columns. Hover tooltip (data-tooltip) carries
1103
+ the long form — uses a CSS pseudo so it appears instantly instead
1104
+ of waiting for the browser's native title delay. */
1105
+ .tag-letter {
1106
+ padding: 1px 6px;
1107
+ font-size: 11px;
1108
+ font-weight: 700;
1109
+ font-family: var(--mono, ui-monospace, monospace);
1110
+ min-width: 18px;
1111
+ text-align: center;
1112
+ position: relative;
1113
+ }
1114
+
1115
+ .tag-letter[data-tooltip]:hover::after,
1116
+ .tag-letter[data-tooltip]:focus-visible::after {
1117
+ content: attr(data-tooltip);
1118
+ position: absolute;
1119
+ bottom: calc(100% + 6px);
1120
+ left: 50%;
1121
+ transform: translateX(-50%);
1122
+ background: var(--bg-surface);
1123
+ color: var(--text);
1124
+ border: 1px solid var(--border);
1125
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
1126
+ padding: 5px 8px;
1127
+ border-radius: 4px;
1128
+ font-size: 12px;
1129
+ font-weight: 400;
1130
+ font-family: inherit;
1131
+ white-space: normal;
1132
+ max-width: 260px;
1133
+ width: max-content;
1134
+ z-index: 100;
1135
+ pointer-events: none;
1136
+ }
1137
+
1091
1138
  .tag-volatile {
1092
1139
  background: #fef3c7;
1093
1140
  color: #92400e;
@@ -2421,7 +2421,13 @@
2421
2421
  closeModal();
2422
2422
  updateAuthUI();
2423
2423
  navigate();
2424
- _triggerPasswordSave(user, pass);
2424
+ // Preserve the current hash through the PRG redirect that
2425
+ // form.submit() to /+admin triggers — otherwise the
2426
+ // browser lands on /+admin/ with no fragment and the
2427
+ // user's original deep link (or the page they were on
2428
+ // when the session expired) is lost.
2429
+ var currentHash = (window.location.hash || '').replace(/^#/, '');
2430
+ _triggerPasswordSave(user, pass, currentHash);
2425
2431
  })
2426
2432
  .catch(showModalError);
2427
2433
  }
@@ -2825,47 +2831,61 @@
2825
2831
 
2826
2832
  // Card header: name + type badge
2827
2833
  var cardHead = el('div', {className: 'index-card-head'});
2828
- cardHead.appendChild(el('a', {
2834
+ // Path container groups owner / sep / name so the only
2835
+ // wide flex gap in the head is between path, tags, and
2836
+ // kebab — the separator hugs both sides like a real
2837
+ // filesystem path.
2838
+ var pathWrap = el('div', {className: 'index-card-path'});
2839
+ pathWrap.appendChild(el('a', {
2829
2840
  href: '#indexes/' + idx._user,
2830
2841
  className: 'index-card-owner',
2831
2842
  textContent: idx._user,
2832
2843
  }));
2833
- cardHead.appendChild(el('span', {
2844
+ pathWrap.appendChild(el('span', {
2834
2845
  className: 'index-card-sep',
2835
2846
  textContent: '/',
2836
2847
  }));
2837
- cardHead.appendChild(el('a', {
2848
+ pathWrap.appendChild(el('a', {
2838
2849
  href: '#packages/' + idx._full,
2839
2850
  className: 'index-card-name',
2840
2851
  textContent: idx._name,
2841
2852
  }));
2853
+ cardHead.appendChild(pathWrap);
2854
+ // Type / state badges. Single-letter labels (M/S/V/W/N)
2855
+ // keep multi-badge headers from wrapping in tight grids;
2856
+ // `title` exposes the full word on hover for clarity.
2842
2857
  var tagGroup = el('div', {className: 'index-card-tags'});
2843
2858
  tagGroup.appendChild(el('span', {
2844
- className: 'tag' + (isMirror ? ' tag-mirror' : ' tag-stage'),
2845
- textContent: idx.type || 'stage',
2859
+ className: 'tag tag-letter'
2860
+ + (isMirror ? ' tag-mirror' : ' tag-stage'),
2861
+ textContent: isMirror ? 'M' : 'S',
2862
+ 'data-tooltip': idx.type || 'stage',
2846
2863
  }));
2847
2864
  if (!isMirror && idx.volatile) {
2848
2865
  tagGroup.appendChild(el('span', {
2849
- className: 'tag tag-volatile',
2850
- textContent: 'volatile',
2866
+ className: 'tag tag-letter tag-volatile',
2867
+ textContent: 'V',
2868
+ 'data-tooltip': 'volatile — uploads may overwrite '
2869
+ + 'existing versions',
2851
2870
  }));
2852
2871
  }
2853
2872
  if (!isMirror && isAnonymousAclUpload(idx.acl_upload)) {
2854
2873
  tagGroup.appendChild(el('span', {
2855
- className: 'tag tag-world-writable',
2856
- textContent: 'world-writable',
2857
- title: 'acl_upload contains :ANONYMOUS: anyone, '
2858
- + 'including unauthenticated callers, can '
2859
- + 'publish packages to this index.',
2874
+ className: 'tag tag-letter tag-world-writable',
2875
+ textContent: 'W',
2876
+ 'data-tooltip': 'world-writableacl_upload '
2877
+ + 'contains :ANONYMOUS:; anyone (including '
2878
+ + 'unauthenticated callers) can publish to '
2879
+ + 'this index.',
2860
2880
  }));
2861
2881
  } else if (!isMirror && isUploadFrozen(idx.acl_upload)) {
2862
2882
  tagGroup.appendChild(el('span', {
2863
- className: 'tag tag-no-upload',
2864
- textContent: 'no upload',
2865
- title: 'acl_upload is empty nobody can publish '
2866
- + 'to this index, not even the owner or root. '
2867
- + 'Add a principal to acl_upload to enable '
2868
- + 'uploads.',
2883
+ className: 'tag tag-letter tag-no-upload',
2884
+ textContent: 'N',
2885
+ 'data-tooltip': 'no uploadacl_upload is empty; '
2886
+ + 'nobody can publish to this index, not even '
2887
+ + 'the owner or root. Add a principal to '
2888
+ + 'acl_upload to enable uploads.',
2869
2889
  }));
2870
2890
  }
2871
2891
  cardHead.appendChild(tagGroup);
@@ -2959,6 +2979,22 @@
2959
2979
  });
2960
2980
  })(idx);
2961
2981
  }
2982
+ // Mirror-only: any authenticated user may trigger an
2983
+ // upstream re-fetch (etag-conditional, low cost). Useful
2984
+ // when waiting for a freshly-published upstream release
2985
+ // that's hidden behind the `mirror_cache_expiry` TTL.
2986
+ if (isMirror && loggedIn) {
2987
+ (function (idxRef) {
2988
+ menuItems.push({
2989
+ label: 'Refresh cache',
2990
+ onclick: function () {
2991
+ closeAllKebabs();
2992
+ refreshMirrorCache(
2993
+ idxRef._user, idxRef._name);
2994
+ },
2995
+ });
2996
+ })(idx);
2997
+ }
2962
2998
  if (menuItems.length) {
2963
2999
  cardHead.appendChild(buildKebabMenu(menuItems));
2964
3000
  }
@@ -3223,6 +3259,51 @@
3223
3259
  .catch(handleApiError);
3224
3260
  }
3225
3261
 
3262
+ function refreshMirrorCache(user, index) {
3263
+ // Non-destructive: just bumps the upstream-fetch clocks. Skip
3264
+ // the confirm dialog; show a small result modal so the user
3265
+ // knows whether the request reached the primary.
3266
+ Api.post(
3267
+ '/+admin-api/mirror/'
3268
+ + encodeURIComponent(user) + '/'
3269
+ + encodeURIComponent(index) + '/refresh-cache',
3270
+ {})
3271
+ .then(function (data) {
3272
+ var r = (data && data.result) || {};
3273
+ var count = r.projects_invalidated;
3274
+ openModal('Cache refreshed', function (body) {
3275
+ body.appendChild(el('p', {
3276
+ textContent: 'Mirror "' + user + '/' + index
3277
+ + '" cache invalidated. '
3278
+ + count + ' project'
3279
+ + (count === 1 ? '' : 's')
3280
+ + ' will re-check upstream on next access.',
3281
+ }));
3282
+ body.appendChild(el('p', {
3283
+ className: 'note',
3284
+ textContent: 'Note: cache is process-local; '
3285
+ + 'replicas will sync once the primary refetches.',
3286
+ }));
3287
+ }, [el('button', {
3288
+ className: 'btn btn-primary',
3289
+ textContent: 'OK',
3290
+ onclick: closeModal,
3291
+ })]);
3292
+ })
3293
+ .catch(function (err) {
3294
+ openModal('Cache refresh failed', function (body) {
3295
+ body.appendChild(el('p', {
3296
+ className: 'error',
3297
+ textContent: (err && err.message) || String(err),
3298
+ }));
3299
+ }, [el('button', {
3300
+ className: 'btn btn-primary',
3301
+ textContent: 'Close',
3302
+ onclick: closeModal,
3303
+ })]);
3304
+ });
3305
+ }
3306
+
3226
3307
  // ========== PACKAGES ==========
3227
3308
 
3228
3309
  var PKG_LIMIT = 100;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License: MIT
@@ -61,6 +61,10 @@ talks to the standard devpi JSON API directly.
61
61
  - **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
62
62
  sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
63
63
  ones with the index pre-filled and locked
64
+ - **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
65
+ the in-memory per-project and project-names caches; the next `+simple/<project>/`
66
+ query (from pip, the UI, or `devpi-client`) goes back to upstream
67
+ (etag-conditional, cheap)
64
68
  - **`Edit`** / **`Delete`** (owner / root)
65
69
  - Create / edit / delete indexes via modal dialogs
66
70
  - `bases` editor with drag & drop priority ordering and transitive inheritance display
@@ -597,6 +601,30 @@ Revoke a single token.
597
601
  - **200:** `{"revoked": true, "id": "abc..."}`
598
602
  - **404:** token id not found
599
603
 
604
+ ### Mirror cache
605
+
606
+ #### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
607
+ Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
608
+ re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
609
+ re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
610
+ mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
611
+ queried). Two caches are expired:
612
+
613
+ - `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
614
+ project, in one pass)
615
+ - `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
616
+ projects" call)
617
+
618
+ Useful when waiting for a freshly-published upstream release that's still hidden
619
+ behind the `mirror_cache_expiry` TTL (default 30 min).
620
+
621
+ - **Auth:** required (any authenticated user)
622
+ - **Primary only:** replicas return 400 (caches are process-local; replicas sync the
623
+ persisted state via the changelog stream once the primary refetches)
624
+ - **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
625
+ - **400:** index is not a mirror, or the call hit a replica
626
+ - **404:** index doesn't exist
627
+
600
628
  ### Replication observability (primary only)
601
629
 
602
630
  #### `GET /+admin-api/replicas`
@@ -0,0 +1,151 @@
1
+ """Tests for view-layer helpers (_get_stage_or_404, _check_read_access)."""
2
+ import json
3
+ import unittest
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPNotFound
7
+
8
+ from devpi_admin.main import (
9
+ _check_read_access, _get_stage_or_404, _refresh_mirror_cache_view,
10
+ _serve_index)
11
+
12
+
13
+ class GetStageOr404Tests(unittest.TestCase):
14
+
15
+ def test_returns_stage(self):
16
+ xom = MagicMock()
17
+ stage = MagicMock()
18
+ xom.model.getstage.return_value = stage
19
+ self.assertIs(_get_stage_or_404(xom, "alice", "dev"), stage)
20
+
21
+ def test_raises_404_when_missing(self):
22
+ xom = MagicMock()
23
+ xom.model.getstage.return_value = None
24
+ with self.assertRaises(HTTPNotFound):
25
+ _get_stage_or_404(xom, "ghost", "ix")
26
+
27
+
28
+ class CheckReadAccessTests(unittest.TestCase):
29
+
30
+ def _make(self, allow, auth_user):
31
+ req = MagicMock()
32
+ req.has_permission.return_value = allow
33
+ req.authenticated_userid = auth_user
34
+ return req
35
+
36
+ def test_allowed_returns_none(self):
37
+ # Should silently pass — no exception, no return value relied upon.
38
+ self.assertIsNone(
39
+ _check_read_access(self._make(True, "alice"), MagicMock()))
40
+
41
+ def test_denied_anonymous_gets_403(self):
42
+ # Anonymous denial returns 403 so devpi-cli / pip can retry with auth.
43
+ with self.assertRaises(HTTPForbidden):
44
+ _check_read_access(self._make(False, None), MagicMock())
45
+
46
+ def test_denied_authenticated_gets_404(self):
47
+ # Authenticated user without read access gets 404 — hides the
48
+ # existence of the private index.
49
+ with self.assertRaises(HTTPNotFound):
50
+ _check_read_access(self._make(False, "bob"), MagicMock())
51
+
52
+
53
+ class ServeIndexTests(unittest.TestCase):
54
+
55
+ def _serve(self):
56
+ # FileResponse needs a real file on disk; STATIC_DIR/index.html
57
+ # is bundled and exists in test runs (verified by test_package).
58
+ request = MagicMock()
59
+ return _serve_index(request)
60
+
61
+ def test_csp_header_present(self):
62
+ resp = self._serve()
63
+ csp = resp.headers.get("Content-Security-Policy", "")
64
+ self.assertIn("default-src 'self'", csp)
65
+ self.assertIn("frame-ancestors 'none'", csp)
66
+ self.assertIn("script-src 'self'", csp)
67
+ # inline script must NOT be allowed
68
+ self.assertNotIn("'unsafe-inline'", csp.split("script-src")[1].split(";")[0])
69
+
70
+ def test_csp_allows_pypi_for_readme_fallback(self):
71
+ resp = self._serve()
72
+ csp = resp.headers.get("Content-Security-Policy", "")
73
+ self.assertIn("https://pypi.org", csp)
74
+
75
+ def test_security_headers_present(self):
76
+ resp = self._serve()
77
+ self.assertEqual(resp.headers.get("X-Content-Type-Options"), "nosniff")
78
+ self.assertEqual(resp.headers.get("Referrer-Policy"), "no-referrer")
79
+
80
+
81
+ class RefreshMirrorCacheViewTests(unittest.TestCase):
82
+
83
+ def _stub_xom(self, stage=None, is_replica=False):
84
+ xom = MagicMock()
85
+ xom.config.role = "replica" if is_replica else "primary"
86
+ xom.model.getstage.return_value = stage
87
+ ctx = MagicMock()
88
+ ctx.__enter__ = MagicMock(return_value=ctx)
89
+ ctx.__exit__ = MagicMock(return_value=False)
90
+ xom.keyfs.read_transaction.return_value = ctx
91
+ return xom
92
+
93
+ def _stub_mirror_stage(self, tracked_projects=("setuptools", "pip")):
94
+ stage = MagicMock()
95
+ stage.ixconfig = {"type": "mirror"}
96
+ stage.cache_retrieve_times._project2time = {
97
+ p: (1.0, None) for p in tracked_projects}
98
+ stage.cache_retrieve_times.expire = MagicMock()
99
+ stage.cache_projectnames.expire = MagicMock()
100
+ return stage
101
+
102
+ def _make(self, xom, user="root", index="pypi", auth_user="alice"):
103
+ req = MagicMock()
104
+ req.registry = {"xom": xom}
105
+ req.matchdict = {"user": user, "index": index}
106
+ req.authenticated_userid = auth_user
107
+ return req
108
+
109
+ def test_expires_all_tracked_projects_and_projectnames(self):
110
+ stage = self._stub_mirror_stage(("setuptools", "pip", "wheel"))
111
+ xom = self._stub_xom(stage=stage)
112
+ with patch("devpi_admin.main._is_replica", return_value=False):
113
+ resp = _refresh_mirror_cache_view(self._make(xom))
114
+ body = json.loads(resp.body)
115
+ self.assertEqual(body["result"]["projects_invalidated"], 3)
116
+ self.assertTrue(body["result"]["projectnames_invalidated"])
117
+ # Every tracked project must have been expired exactly once;
118
+ # the project-names cache must be expired regardless.
119
+ self.assertEqual(stage.cache_retrieve_times.expire.call_count, 3)
120
+ stage.cache_projectnames.expire.assert_called_once_with()
121
+
122
+ def test_404_when_index_missing(self):
123
+ xom = self._stub_xom(stage=None)
124
+ with patch("devpi_admin.main._is_replica", return_value=False):
125
+ with self.assertRaises(HTTPNotFound):
126
+ _refresh_mirror_cache_view(self._make(xom))
127
+
128
+ def test_400_when_index_is_not_mirror(self):
129
+ stage = MagicMock()
130
+ stage.ixconfig = {"type": "stage"}
131
+ xom = self._stub_xom(stage=stage)
132
+ with patch("devpi_admin.main._is_replica", return_value=False):
133
+ with self.assertRaises(HTTPBadRequest):
134
+ _refresh_mirror_cache_view(self._make(xom))
135
+
136
+ def test_replica_refuses(self):
137
+ xom = self._stub_xom(stage=self._stub_mirror_stage())
138
+ with patch("devpi_admin.main._is_replica", return_value=True):
139
+ with self.assertRaises(HTTPBadRequest):
140
+ _refresh_mirror_cache_view(self._make(xom))
141
+
142
+ def test_unauthenticated_blocked(self):
143
+ xom = self._stub_xom(stage=self._stub_mirror_stage())
144
+ with patch("devpi_admin.main._is_replica", return_value=False):
145
+ with self.assertRaises(HTTPForbidden):
146
+ _refresh_mirror_cache_view(
147
+ self._make(xom, auth_user=None))
148
+
149
+
150
+ if __name__ == "__main__":
151
+ unittest.main()
@@ -1,79 +0,0 @@
1
- """Tests for view-layer helpers (_get_stage_or_404, _check_read_access)."""
2
- import unittest
3
- from unittest.mock import MagicMock, patch
4
-
5
- from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound
6
-
7
- from devpi_admin.main import _check_read_access, _get_stage_or_404, _serve_index
8
-
9
-
10
- class GetStageOr404Tests(unittest.TestCase):
11
-
12
- def test_returns_stage(self):
13
- xom = MagicMock()
14
- stage = MagicMock()
15
- xom.model.getstage.return_value = stage
16
- self.assertIs(_get_stage_or_404(xom, "alice", "dev"), stage)
17
-
18
- def test_raises_404_when_missing(self):
19
- xom = MagicMock()
20
- xom.model.getstage.return_value = None
21
- with self.assertRaises(HTTPNotFound):
22
- _get_stage_or_404(xom, "ghost", "ix")
23
-
24
-
25
- class CheckReadAccessTests(unittest.TestCase):
26
-
27
- def _make(self, allow, auth_user):
28
- req = MagicMock()
29
- req.has_permission.return_value = allow
30
- req.authenticated_userid = auth_user
31
- return req
32
-
33
- def test_allowed_returns_none(self):
34
- # Should silently pass — no exception, no return value relied upon.
35
- self.assertIsNone(
36
- _check_read_access(self._make(True, "alice"), MagicMock()))
37
-
38
- def test_denied_anonymous_gets_403(self):
39
- # Anonymous denial returns 403 so devpi-cli / pip can retry with auth.
40
- with self.assertRaises(HTTPForbidden):
41
- _check_read_access(self._make(False, None), MagicMock())
42
-
43
- def test_denied_authenticated_gets_404(self):
44
- # Authenticated user without read access gets 404 — hides the
45
- # existence of the private index.
46
- with self.assertRaises(HTTPNotFound):
47
- _check_read_access(self._make(False, "bob"), MagicMock())
48
-
49
-
50
- class ServeIndexTests(unittest.TestCase):
51
-
52
- def _serve(self):
53
- # FileResponse needs a real file on disk; STATIC_DIR/index.html
54
- # is bundled and exists in test runs (verified by test_package).
55
- request = MagicMock()
56
- return _serve_index(request)
57
-
58
- def test_csp_header_present(self):
59
- resp = self._serve()
60
- csp = resp.headers.get("Content-Security-Policy", "")
61
- self.assertIn("default-src 'self'", csp)
62
- self.assertIn("frame-ancestors 'none'", csp)
63
- self.assertIn("script-src 'self'", csp)
64
- # inline script must NOT be allowed
65
- self.assertNotIn("'unsafe-inline'", csp.split("script-src")[1].split(";")[0])
66
-
67
- def test_csp_allows_pypi_for_readme_fallback(self):
68
- resp = self._serve()
69
- csp = resp.headers.get("Content-Security-Policy", "")
70
- self.assertIn("https://pypi.org", csp)
71
-
72
- def test_security_headers_present(self):
73
- resp = self._serve()
74
- self.assertEqual(resp.headers.get("X-Content-Type-Options"), "nosniff")
75
- self.assertEqual(resp.headers.get("Referrer-Policy"), "no-referrer")
76
-
77
-
78
- if __name__ == "__main__":
79
- unittest.main()
File without changes
File without changes
File without changes
File without changes