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.
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/PKG-INFO +1 -1
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/_version.py +3 -3
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/main.py +19 -22
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/css/style.css +12 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/js/app.js +71 -4
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/PKG-INFO +1 -1
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_view_helpers.py +79 -1
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/.github/workflows/publish.yml +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/.github/workflows/tests.yml +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/.gitignore +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/LICENSE +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/README.md +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/__init__.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/customizer.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/favicon.svg +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/index.html +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/js/api.js +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/js/marked.min.js +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/static/js/theme.js +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin/tokens.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/SOURCES.txt +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/dependency_links.txt +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/entry_points.txt +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/requires.txt +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/devpi_admin.egg-info/top_level.txt +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/pyproject.toml +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/setup.cfg +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/__init__.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_acl_read.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_devpi_tokens_ui.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_filter.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_hooks.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_json_safe.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_package.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_pipconf.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_tokens.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_tween.py +0 -0
- {devpi_admin-1.4.3 → devpi_admin-1.4.4}/tests/test_wants_html.py +0 -0
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
21
|
+
__version__ = version = '1.4.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 4)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
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
|
-
#
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
395
|
-
if
|
|
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": "/" +
|
|
399
|
-
"basename":
|
|
400
|
-
"hash_spec":
|
|
401
|
+
"href": "/" + entrypath,
|
|
402
|
+
"basename": entrypath.rsplit("/", 1)[-1],
|
|
403
|
+
"hash_spec": hash_spec,
|
|
401
404
|
}
|
|
402
|
-
|
|
403
|
-
|
|
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', {
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|