devpi-admin 1.4.1__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.1 → devpi_admin-1.4.3}/.github/workflows/publish.yml +2 -2
  2. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/.github/workflows/tests.yml +2 -2
  3. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/PKG-INFO +29 -1
  4. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/README.md +28 -0
  5. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/_version.py +3 -3
  6. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/main.py +65 -0
  7. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/css/style.css +54 -3
  8. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/js/app.js +173 -28
  9. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/PKG-INFO +29 -1
  10. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_devpi_tokens_ui.py +17 -0
  11. devpi_admin-1.4.3/tests/test_view_helpers.py +151 -0
  12. devpi_admin-1.4.1/tests/test_view_helpers.py +0 -79
  13. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/.gitignore +0 -0
  14. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/LICENSE +0 -0
  15. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/__init__.py +0 -0
  16. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/customizer.py +0 -0
  17. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/favicon.svg +0 -0
  18. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/index.html +0 -0
  19. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/js/api.js +0 -0
  20. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/js/marked.min.js +0 -0
  21. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/js/theme.js +0 -0
  22. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/tokens.py +0 -0
  23. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/SOURCES.txt +0 -0
  24. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/dependency_links.txt +0 -0
  25. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/entry_points.txt +0 -0
  26. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/requires.txt +0 -0
  27. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/top_level.txt +0 -0
  28. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/pyproject.toml +0 -0
  29. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/setup.cfg +0 -0
  30. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/__init__.py +0 -0
  31. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_acl_read.py +0 -0
  32. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_filter.py +0 -0
  33. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_hooks.py +0 -0
  34. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_json_safe.py +0 -0
  35. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_package.py +0 -0
  36. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_pipconf.py +0 -0
  37. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_tokens.py +0 -0
  38. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_tween.py +0 -0
  39. {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_wants_html.py +0 -0
@@ -12,12 +12,12 @@ jobs:
12
12
  id-token: write
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@v4
15
+ - uses: actions/checkout@v5
16
16
  with:
17
17
  fetch-depth: 0 # needed for setuptools-scm to derive version from tag
18
18
 
19
19
  - name: Set up Python
20
- uses: actions/setup-python@v5
20
+ uses: actions/setup-python@v6
21
21
  with:
22
22
  python-version: "3.14"
23
23
 
@@ -14,12 +14,12 @@ jobs:
14
14
  python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@v4
17
+ - uses: actions/checkout@v5
18
18
  with:
19
19
  fetch-depth: 0 # needed for setuptools-scm to read git tags
20
20
 
21
21
  - name: Set up Python ${{ matrix.python-version }}
22
- uses: actions/setup-python@v5
22
+ uses: actions/setup-python@v6
23
23
  with:
24
24
  python-version: ${{ matrix.python-version }}
25
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.1
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.1'
22
- __version_tuple__ = version_tuple = (1, 4, 1)
21
+ __version__ = version = '1.4.3'
22
+ __version_tuple__ = version_tuple = (1, 4, 3)
23
23
 
24
- __commit_id__ = commit_id = 'gf69f25fba'
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;
@@ -1588,15 +1635,18 @@ body {
1588
1635
  align-items: center;
1589
1636
  justify-content: space-between;
1590
1637
  padding: 2px 0;
1638
+ gap: 4px;
1591
1639
  }
1592
1640
 
1593
1641
  .pkg-version-link {
1594
- flex: 1;
1642
+ flex: 1 1 auto;
1643
+ min-width: 0;
1595
1644
  padding: 5px 8px;
1596
1645
  border-radius: 4px;
1597
1646
  font-size: 13px;
1598
1647
  color: var(--text-muted);
1599
1648
  text-decoration: none;
1649
+ overflow-wrap: anywhere;
1600
1650
  }
1601
1651
 
1602
1652
  .pkg-version-link:hover {
@@ -1619,6 +1669,7 @@ body {
1619
1669
  font-size: 16px;
1620
1670
  line-height: 1;
1621
1671
  border-radius: 4px;
1672
+ flex-shrink: 0;
1622
1673
  }
1623
1674
 
1624
1675
  .pkg-version-del:hover {
@@ -1242,10 +1242,65 @@
1242
1242
  // Token types supported by the unified Issue modal. The selector at the
1243
1243
  // top of the modal switches the form between Devpi tokens (multi-index,
1244
1244
  // permission checkboxes) and Admin tokens (single index, scope select).
1245
- // Devpi is the default unless the plugin isn't installed.
1245
+ // When the caller doesn't pin a type, the modal defaults to whichever
1246
+ // backend the user *most recently* issued against — saves picking the
1247
+ // same radio every time. Falls back to Devpi (or Admin if the plugin
1248
+ // isn't installed) for users with no tokens yet.
1246
1249
  var TOKEN_TYPE_DEVPI = 'devpi';
1247
1250
  var TOKEN_TYPE_ADMIN = 'admin';
1248
1251
 
1252
+ function _detectRecentTokenType(username) {
1253
+ // Returns Promise<TOKEN_TYPE_*|null>. ``null`` means the user has
1254
+ // no tokens (or detection failed) — caller picks a static default.
1255
+ //
1256
+ // Admin tokens carry ``issued_at`` (epoch). Macaroon tokens have
1257
+ // no first-class issuance timestamp; we use ``not_before`` when
1258
+ // present (it tracks the issue moment for the typical "starts
1259
+ // now" flow) and fall back to ``expires`` so a freshly-issued
1260
+ // long-TTL token still wins over an older short-TTL one.
1261
+ var pAdmin = Api.get(
1262
+ '/+admin-api/users/' + encodeURIComponent(username) + '/tokens')
1263
+ .then(function (data) {
1264
+ var tokens = (data && data.result) || [];
1265
+ var max = 0;
1266
+ for (var i = 0; i < tokens.length; i++) {
1267
+ var t = tokens[i].issued_at || 0;
1268
+ if (t > max) max = t;
1269
+ }
1270
+ return max || null;
1271
+ })
1272
+ .catch(function () { return null; });
1273
+
1274
+ var pDevpi = hasDevpiTokens()
1275
+ ? Api.get('/' + encodeURIComponent(username) + '/+tokens')
1276
+ .then(function (data) {
1277
+ var tokens = (data && data.result
1278
+ && data.result.tokens) || {};
1279
+ var max = 0;
1280
+ for (var id in tokens) {
1281
+ if (!Object.prototype.hasOwnProperty.call(
1282
+ tokens, id)) continue;
1283
+ var parsed = parseMacaroonRestrictions(
1284
+ tokens[id].restrictions);
1285
+ var t = parsed.not_before
1286
+ || parsed.expires || 0;
1287
+ if (t > max) max = t;
1288
+ }
1289
+ return max || null;
1290
+ })
1291
+ .catch(function () { return null; })
1292
+ : Promise.resolve(null);
1293
+
1294
+ return Promise.all([pAdmin, pDevpi]).then(function (results) {
1295
+ var adminTs = results[0];
1296
+ var devpiTs = results[1];
1297
+ if (!adminTs && !devpiTs) return null;
1298
+ if (!adminTs) return TOKEN_TYPE_DEVPI;
1299
+ if (!devpiTs) return TOKEN_TYPE_ADMIN;
1300
+ return adminTs >= devpiTs ? TOKEN_TYPE_ADMIN : TOKEN_TYPE_DEVPI;
1301
+ });
1302
+ }
1303
+
1249
1304
  // Public entry point. `options` may include:
1250
1305
  // • `preselectType` — 'devpi' or 'admin'
1251
1306
  // • `preselectIndexes` — list of 'user/index' strings
@@ -1259,18 +1314,25 @@
1259
1314
  function showIssueTokenModal(username, options) {
1260
1315
  options = options || {};
1261
1316
  var presel = (options.preselectIndexes || []).slice();
1262
- var preselType = options.preselectType || TOKEN_TYPE_DEVPI;
1317
+ var explicitType = options.preselectType || null;
1263
1318
  _issueReturnTo = options.returnTo || function () {
1264
1319
  showTokensModal(username);
1265
1320
  };
1266
1321
  _issueLockedIndex = (options.lockIndex && presel.length)
1267
1322
  ? presel[0] : null;
1268
- // If the user asked for Devpi but the plugin isn't installed, silently
1269
- // fall back to Admin saves them a trip back through the kebab.
1270
- if (preselType === TOKEN_TYPE_DEVPI && !hasDevpiTokens()) {
1271
- preselType = TOKEN_TYPE_ADMIN;
1272
- }
1273
- fetchRoot().then(function (rootResult) {
1323
+ // Caller pinned the type honour it; otherwise detect from the
1324
+ // user's existing tokens (most-recently-issued backend wins).
1325
+ // Detection failure falls back to Devpi (or Admin if the plugin
1326
+ // isn't installed).
1327
+ var pType = explicitType
1328
+ ? Promise.resolve(explicitType)
1329
+ : _detectRecentTokenType(username);
1330
+ Promise.all([fetchRoot(), pType]).then(function (results) {
1331
+ var rootResult = results[0];
1332
+ var preselType = results[1] || TOKEN_TYPE_DEVPI;
1333
+ if (preselType === TOKEN_TYPE_DEVPI && !hasDevpiTokens()) {
1334
+ preselType = TOKEN_TYPE_ADMIN;
1335
+ }
1274
1336
  var indexInfos = getAllIndexes(rootResult);
1275
1337
  var aclByIndex = {};
1276
1338
  // Indexes accessible to the bound user: ones they own, ones
@@ -1304,11 +1366,13 @@
1304
1366
  };
1305
1367
  _renderIssueTokenModal(username, presel, preselType);
1306
1368
  }).catch(function () {
1369
+ var fallbackType = explicitType
1370
+ || (hasDevpiTokens() ? TOKEN_TYPE_DEVPI : TOKEN_TYPE_ADMIN);
1307
1371
  _issueContext = {
1308
1372
  accessibleIndexes: presel.slice(),
1309
1373
  aclByIndex: {},
1310
1374
  };
1311
- _renderIssueTokenModal(username, presel, preselType);
1375
+ _renderIssueTokenModal(username, presel, fallbackType);
1312
1376
  });
1313
1377
  }
1314
1378
 
@@ -2357,7 +2421,13 @@
2357
2421
  closeModal();
2358
2422
  updateAuthUI();
2359
2423
  navigate();
2360
- _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);
2361
2431
  })
2362
2432
  .catch(showModalError);
2363
2433
  }
@@ -2761,47 +2831,61 @@
2761
2831
 
2762
2832
  // Card header: name + type badge
2763
2833
  var cardHead = el('div', {className: 'index-card-head'});
2764
- 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', {
2765
2840
  href: '#indexes/' + idx._user,
2766
2841
  className: 'index-card-owner',
2767
2842
  textContent: idx._user,
2768
2843
  }));
2769
- cardHead.appendChild(el('span', {
2844
+ pathWrap.appendChild(el('span', {
2770
2845
  className: 'index-card-sep',
2771
2846
  textContent: '/',
2772
2847
  }));
2773
- cardHead.appendChild(el('a', {
2848
+ pathWrap.appendChild(el('a', {
2774
2849
  href: '#packages/' + idx._full,
2775
2850
  className: 'index-card-name',
2776
2851
  textContent: idx._name,
2777
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.
2778
2857
  var tagGroup = el('div', {className: 'index-card-tags'});
2779
2858
  tagGroup.appendChild(el('span', {
2780
- className: 'tag' + (isMirror ? ' tag-mirror' : ' tag-stage'),
2781
- 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',
2782
2863
  }));
2783
2864
  if (!isMirror && idx.volatile) {
2784
2865
  tagGroup.appendChild(el('span', {
2785
- className: 'tag tag-volatile',
2786
- textContent: 'volatile',
2866
+ className: 'tag tag-letter tag-volatile',
2867
+ textContent: 'V',
2868
+ 'data-tooltip': 'volatile — uploads may overwrite '
2869
+ + 'existing versions',
2787
2870
  }));
2788
2871
  }
2789
2872
  if (!isMirror && isAnonymousAclUpload(idx.acl_upload)) {
2790
2873
  tagGroup.appendChild(el('span', {
2791
- className: 'tag tag-world-writable',
2792
- textContent: 'world-writable',
2793
- title: 'acl_upload contains :ANONYMOUS: anyone, '
2794
- + 'including unauthenticated callers, can '
2795
- + '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.',
2796
2880
  }));
2797
2881
  } else if (!isMirror && isUploadFrozen(idx.acl_upload)) {
2798
2882
  tagGroup.appendChild(el('span', {
2799
- className: 'tag tag-no-upload',
2800
- textContent: 'no upload',
2801
- title: 'acl_upload is empty nobody can publish '
2802
- + 'to this index, not even the owner or root. '
2803
- + 'Add a principal to acl_upload to enable '
2804
- + '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.',
2805
2889
  }));
2806
2890
  }
2807
2891
  cardHead.appendChild(tagGroup);
@@ -2895,6 +2979,22 @@
2895
2979
  });
2896
2980
  })(idx);
2897
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
+ }
2898
2998
  if (menuItems.length) {
2899
2999
  cardHead.appendChild(buildKebabMenu(menuItems));
2900
3000
  }
@@ -3159,6 +3259,51 @@
3159
3259
  .catch(handleApiError);
3160
3260
  }
3161
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
+
3162
3307
  // ========== PACKAGES ==========
3163
3308
 
3164
3309
  var PKG_LIMIT = 100;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.1
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`
@@ -277,6 +277,23 @@ class UnifiedIssueModalTests(unittest.TestCase):
277
277
  def test_unified_entry_point_exists(self):
278
278
  self.assertIn("function showIssueTokenModal(username, options)", self.js)
279
279
 
280
+ def test_recent_token_type_detector_present(self):
281
+ # Auto-preselect: when caller doesn't pin a type, pick the
282
+ # backend the user most recently issued against.
283
+ self.assertIn("function _detectRecentTokenType(username)", self.js)
284
+ # Admin tokens compared by issued_at, macaroons by
285
+ # not_before / expires.
286
+ self.assertIn("tokens[i].issued_at", self.js)
287
+ self.assertIn("parsed.not_before", self.js)
288
+
289
+ def test_issue_modal_uses_detector_when_caller_is_silent(self):
290
+ # `options.preselectType` short-circuits detection; absence
291
+ # triggers _detectRecentTokenType.
292
+ self.assertIn("var explicitType = options.preselectType || null;",
293
+ self.js)
294
+ self.assertIn("explicitType\n ? Promise.resolve(explicitType)\n"
295
+ " : _detectRecentTokenType(username);", self.js)
296
+
280
297
  def test_unified_listing_has_issue_button(self):
281
298
  # Single Issue entry point on the unified per-user Tokens modal —
282
299
  # no preselectType so the user picks devpi/admin in the form.
@@ -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