devpi-admin 1.4.3__tar.gz → 1.4.4__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 (38) hide show
  1. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/PKG-INFO +1 -1
  2. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/_version.py +3 -3
  3. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/main.py +19 -22
  4. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/css/style.css +12 -0
  5. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/js/app.js +71 -4
  6. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/PKG-INFO +1 -1
  7. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_view_helpers.py +79 -1
  8. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/.github/workflows/publish.yml +0 -0
  9. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/.github/workflows/tests.yml +0 -0
  10. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/.gitignore +0 -0
  11. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/LICENSE +0 -0
  12. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/README.md +0 -0
  13. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/__init__.py +0 -0
  14. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/customizer.py +0 -0
  15. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/favicon.svg +0 -0
  16. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/index.html +0 -0
  17. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/js/api.js +0 -0
  18. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/js/marked.min.js +0 -0
  19. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/js/theme.js +0 -0
  20. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/tokens.py +0 -0
  21. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/SOURCES.txt +0 -0
  22. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/dependency_links.txt +0 -0
  23. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/entry_points.txt +0 -0
  24. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/requires.txt +0 -0
  25. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/top_level.txt +0 -0
  26. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/pyproject.toml +0 -0
  27. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/setup.cfg +0 -0
  28. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/__init__.py +0 -0
  29. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_acl_read.py +0 -0
  30. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_devpi_tokens_ui.py +0 -0
  31. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_filter.py +0 -0
  32. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_hooks.py +0 -0
  33. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_json_safe.py +0 -0
  34. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_package.py +0 -0
  35. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_pipconf.py +0 -0
  36. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_tokens.py +0 -0
  37. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_tween.py +0 -0
  38. {devpi_admin-1.4.3 → devpi_admin-1.4.4}/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.3
3
+ Version: 1.4.4
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
@@ -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.3'
22
- __version_tuple__ = version_tuple = (1, 4, 3)
21
+ __version__ = version = '1.4.4'
22
+ __version_tuple__ = version_tuple = (1, 4, 4)
23
23
 
24
- __commit_id__ = commit_id = 'geb4eac9d6'
24
+ __commit_id__ = commit_id = 'g312e747ee'
@@ -382,32 +382,29 @@ def _versiondata_view(request):
382
382
  return HTTPNotFound(json_body={"error": "version not found"})
383
383
  # Convert to plain dict with JSON-safe types
384
384
  result = _to_json_safe(verdata)
385
- # Include file links filter releaselinks to this version
386
- try:
387
- all_links = stage.get_releaselinks(project)
388
- except Exception:
389
- _log.warning(
390
- "Failed to get releaselinks for %s/%s/%s",
391
- user, index, project, exc_info=True)
392
- all_links = []
385
+ # Build file links from the verdata's own +elinks. Unlike
386
+ # get_releaselinks() — which reconstructs ELinks from simplelinks
387
+ # metadata and therefore has no "_log" — these carry the upload
388
+ # log entries (who/when). Mirror elinks have no "_log" at all;
389
+ # the "log" key is then simply absent.
393
390
  result["+links"] = []
394
- for link in all_links:
395
- if link.version != version:
391
+ for linkdict in result.pop("+elinks", None) or []:
392
+ if linkdict.get("rel") != "releasefile":
396
393
  continue
394
+ entrypath = linkdict.get("entrypath") or ""
395
+ hashes = linkdict.get("hashes") or {}
396
+ if hashes.get("sha256"):
397
+ hash_spec = "sha256=" + hashes["sha256"]
398
+ else:
399
+ hash_spec = linkdict.get("hash_spec") or ""
397
400
  link_info = {
398
- "href": "/" + link.relpath,
399
- "basename": link.basename,
400
- "hash_spec": link.best_available_hash_spec,
401
+ "href": "/" + entrypath,
402
+ "basename": entrypath.rsplit("/", 1)[-1],
403
+ "hash_spec": hash_spec,
401
404
  }
402
- try:
403
- log = link._log
404
- link_info["log"] = [
405
- {k: (list(v) if k == "when" else v)
406
- for k, v in entry.items()}
407
- for entry in log
408
- ]
409
- except (AttributeError, TypeError):
410
- pass
405
+ log = linkdict.get("_log")
406
+ if log:
407
+ link_info["log"] = log
411
408
  result["+links"].append(link_info)
412
409
  return _json_response({"result": result})
413
410
 
@@ -1579,6 +1579,13 @@ body {
1579
1579
  word-break: break-word;
1580
1580
  }
1581
1581
 
1582
+ .pkg-sidebar-text {
1583
+ margin-top: 4px;
1584
+ font-size: 12px;
1585
+ color: var(--text);
1586
+ word-break: break-word;
1587
+ }
1588
+
1582
1589
  .pkg-sidebar-list {
1583
1590
  list-style: none;
1584
1591
  margin-top: 4px;
@@ -1691,6 +1698,11 @@ body {
1691
1698
  color: var(--text-faint);
1692
1699
  }
1693
1700
 
1701
+ .pkg-card-updated {
1702
+ font-size: 12px;
1703
+ color: var(--text-faint);
1704
+ }
1705
+
1694
1706
  /* --- Version cards --- */
1695
1707
 
1696
1708
  .ver-grid {
@@ -3341,6 +3341,18 @@
3341
3341
  Api.get('/' + indexPath + '/' + pkg).then(function (pkgData) {
3342
3342
  var vers = Object.keys(pkgData.result).sort(compareVersions);
3343
3343
  versionEl.textContent = vers.length ? 'v' + vers[0] : 'no versions';
3344
+ // Last update across all versions of the package
3345
+ var best = null;
3346
+ for (var i = 0; i < vers.length; i++) {
3347
+ var w = lastLogWhen(pkgData.result[vers[i]]['+links']);
3348
+ if (w && (!best || cmpLogWhen(w, best) > 0)) best = w;
3349
+ }
3350
+ if (best) {
3351
+ card.appendChild(el('div', {
3352
+ className: 'pkg-card-updated',
3353
+ textContent: 'Updated ' + formatLogWhen(best),
3354
+ }));
3355
+ }
3344
3356
  }).catch(function () {
3345
3357
  versionEl.textContent = '';
3346
3358
  });
@@ -3599,7 +3611,22 @@
3599
3611
  content.appendChild(el('div', {className: 'view-header'}, [
3600
3612
  buildBreadcrumb(indexPath, [
3601
3613
  ' / ',
3602
- el('a', {href: '#package/' + indexPath + '/' + pkg, textContent: pkg}),
3614
+ el('a', {
3615
+ href: '#package/' + indexPath + '/' + pkg,
3616
+ textContent: pkg,
3617
+ onclick: function (e) {
3618
+ // Same-hash click fires no hashchange event —
3619
+ // force a reload of the package detail.
3620
+ if (e.metaKey || e.ctrlKey || e.shiftKey) return;
3621
+ e.preventDefault();
3622
+ var target = '#package/' + indexPath + '/' + pkg;
3623
+ if (window.location.hash !== target) {
3624
+ _skipHashChange = true;
3625
+ window.location.hash = target;
3626
+ }
3627
+ loadPackageDetail(indexPath, pkg);
3628
+ },
3629
+ }),
3603
3630
  ' ',
3604
3631
  el('span', {className: 'page-heading-version', textContent: 'v' + currentVer}),
3605
3632
  ]),
@@ -3636,6 +3663,18 @@
3636
3663
  }));
3637
3664
  }
3638
3665
 
3666
+ var uploadedWhen = lastLogWhen(info['+links']);
3667
+ if (uploadedWhen) {
3668
+ infoCard.appendChild(el('div', {
3669
+ className: 'pkg-sidebar-label',
3670
+ textContent: 'Uploaded',
3671
+ }));
3672
+ infoCard.appendChild(el('div', {
3673
+ className: 'pkg-sidebar-text',
3674
+ textContent: formatLogWhen(uploadedWhen),
3675
+ }));
3676
+ }
3677
+
3639
3678
  var infoRows = [];
3640
3679
  if (info.requires_python) infoRows.push(['Python', info.requires_python]);
3641
3680
  var license = info.license_expression || info.license;
@@ -3736,9 +3775,7 @@
3736
3775
  var dateStr = '';
3737
3776
  if (link.log && link.log.length) {
3738
3777
  var log = link.log[0];
3739
- var when = log.when;
3740
- dateStr = when[0] + '-' + pad(when[1]) + '-' + pad(when[2]) + ' ' +
3741
- pad(when[3]) + ':' + pad(when[4]);
3778
+ dateStr = formatLogWhen(log.when);
3742
3779
  if (log.who) dateStr = log.who + ', ' + dateStr;
3743
3780
  }
3744
3781
  if (dateStr && dateStr !== lastDate) {
@@ -3920,6 +3957,36 @@
3920
3957
  return n < 10 ? '0' + n : '' + n;
3921
3958
  }
3922
3959
 
3960
+ function formatLogWhen(when) {
3961
+ // keyfs link log 'when' is a UTC tuple [Y, M, D, h, m, s]
3962
+ return when[0] + '-' + pad(when[1]) + '-' + pad(when[2]) + ' ' +
3963
+ pad(when[3]) + ':' + pad(when[4]);
3964
+ }
3965
+
3966
+ function cmpLogWhen(a, b) {
3967
+ for (var i = 0; i < a.length && i < b.length; i++) {
3968
+ if (a[i] !== b[i]) return a[i] - b[i];
3969
+ }
3970
+ return 0;
3971
+ }
3972
+
3973
+ function lastLogWhen(links) {
3974
+ // Latest log timestamp across release links — upload, push and
3975
+ // overwrite entries all represent a content update. Mirror links
3976
+ // carry no log → returns null.
3977
+ var best = null;
3978
+ for (var i = 0; i < (links || []).length; i++) {
3979
+ var log = links[i].log || [];
3980
+ for (var j = 0; j < log.length; j++) {
3981
+ var when = log[j].when;
3982
+ if (when && (!best || cmpLogWhen(when, best) > 0)) {
3983
+ best = when;
3984
+ }
3985
+ }
3986
+ }
3987
+ return best;
3988
+ }
3989
+
3923
3990
  var _preReleaseOrder = {dev: 0, a: 1, alpha: 1, b: 2, beta: 2, rc: 3, c: 3};
3924
3991
 
3925
3992
  function _parseVersion(v) {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.3
3
+ Version: 1.4.4
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
@@ -7,7 +7,7 @@ from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPNotFound
7
7
 
8
8
  from devpi_admin.main import (
9
9
  _check_read_access, _get_stage_or_404, _refresh_mirror_cache_view,
10
- _serve_index)
10
+ _serve_index, _versiondata_view)
11
11
 
12
12
 
13
13
  class GetStageOr404Tests(unittest.TestCase):
@@ -147,5 +147,83 @@ class RefreshMirrorCacheViewTests(unittest.TestCase):
147
147
  self._make(xom, auth_user=None))
148
148
 
149
149
 
150
+ class VersiondataViewTests(unittest.TestCase):
151
+ """+links must be built from verdata +elinks (which carry _log).
152
+
153
+ Regression: get_releaselinks() reconstructs ELinks from simplelinks
154
+ metadata without "_log", so the upload timestamps never reached the
155
+ response when the view used it.
156
+ """
157
+
158
+ def _make(self, verdata):
159
+ stage = MagicMock()
160
+ stage.get_versiondata.return_value = verdata
161
+ xom = MagicMock()
162
+ xom.model.getstage.return_value = stage
163
+ req = MagicMock()
164
+ req.registry = {"xom": xom}
165
+ req.matchdict = {
166
+ "user": "alice", "index": "dev",
167
+ "project": "testpkg", "version": "1.0"}
168
+ req.has_permission.return_value = True
169
+ req.authenticated_userid = "alice"
170
+ return req
171
+
172
+ def test_links_carry_upload_log(self):
173
+ verdata = {
174
+ "name": "testpkg", "version": "1.0",
175
+ "+elinks": [
176
+ {
177
+ "rel": "releasefile",
178
+ "entrypath": "alice/dev/+f/b28/abc/testpkg-1.0.tar.gz",
179
+ "hash_spec": "md5=deadbeef",
180
+ "hashes": {"sha256": "cafe"},
181
+ "_log": [{
182
+ "what": "upload", "who": "alice",
183
+ "when": (2026, 6, 5, 8, 10, 44),
184
+ "dst": "alice/dev"}],
185
+ },
186
+ {
187
+ # non-releasefile links must be filtered out
188
+ "rel": "toxresult",
189
+ "entrypath": "alice/dev/+f/123/tox.json",
190
+ },
191
+ ],
192
+ }
193
+ body = json.loads(_versiondata_view(self._make(verdata)).body)
194
+ result = body["result"]
195
+ self.assertNotIn("+elinks", result)
196
+ self.assertEqual(len(result["+links"]), 1)
197
+ link = result["+links"][0]
198
+ self.assertEqual(
199
+ link["href"], "/alice/dev/+f/b28/abc/testpkg-1.0.tar.gz")
200
+ self.assertEqual(link["basename"], "testpkg-1.0.tar.gz")
201
+ self.assertEqual(link["hash_spec"], "sha256=cafe")
202
+ self.assertEqual(link["log"][0]["when"], [2026, 6, 5, 8, 10, 44])
203
+ self.assertEqual(link["log"][0]["who"], "alice")
204
+
205
+ def test_mirror_links_without_log(self):
206
+ # Mirror elinks carry no "_log" — the "log" key must be absent,
207
+ # not present-but-empty.
208
+ verdata = {
209
+ "name": "testpkg", "version": "1.0",
210
+ "+elinks": [{
211
+ "rel": "releasefile",
212
+ "entrypath": "root/pypi/+f/abc/testpkg-1.0.tar.gz",
213
+ "hash_spec": "md5=deadbeef",
214
+ "hashes": {},
215
+ }],
216
+ }
217
+ body = json.loads(_versiondata_view(self._make(verdata)).body)
218
+ link = body["result"]["+links"][0]
219
+ self.assertNotIn("log", link)
220
+ self.assertEqual(link["hash_spec"], "md5=deadbeef")
221
+
222
+ def test_no_elinks_yields_empty_links(self):
223
+ verdata = {"name": "testpkg", "version": "1.0"}
224
+ body = json.loads(_versiondata_view(self._make(verdata)).body)
225
+ self.assertEqual(body["result"]["+links"], [])
226
+
227
+
150
228
  if __name__ == "__main__":
151
229
  unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes