Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-devpi-server for 
openSUSE:Factory checked in at 2026-08-20 16:16:59
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-devpi-server (Old)
 and      /work/SRC/openSUSE:Factory/.python-devpi-server.new.1258 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-devpi-server"

Thu Aug 20 16:16:59 2026 rev:22 rq:1372223 version:6.20.3

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-devpi-server/python-devpi-server.changes  
2026-05-17 18:57:28.293886167 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-devpi-server.new.1258/python-devpi-server.changes
        2026-08-20 16:17:03.179978053 +0200
@@ -1,0 +2,14 @@
+Thu Aug 20 09:05:35 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 6.20.3:
+  * Fix fetching files from re-created mirrors (for example after
+    import or delete and create) without fetching simple project
+    page first. This happens when uv uses a package URL directly
+    from a lock file.
+  * Search for METADATA file if the generated name with
+    normalized project and version doesn't match. This fixes
+    serving core metadata for older wheels.
+  * Fix logic error which allowed unauthorized fetching of
+    replication data.
+
+-------------------------------------------------------------------

Old:
----
  devpi_server-6.20.1.tar.gz

New:
----
  devpi_server-6.20.3.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-devpi-server.spec ++++++
--- /var/tmp/diff_new_pack.Fhq070/_old  2026-08-20 16:17:03.826000729 +0200
+++ /var/tmp/diff_new_pack.Fhq070/_new  2026-08-20 16:17:03.828000800 +0200
@@ -26,7 +26,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-devpi-server
-Version:        6.20.1
+Version:        6.20.3
 Release:        0
 Summary:        Private PyPI caching server
 License:        MIT

++++++ devpi_server-6.20.1.tar.gz -> devpi_server-6.20.3.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/CHANGELOG 
new/devpi_server-6.20.3/CHANGELOG
--- old/devpi_server-6.20.1/CHANGELOG   2026-05-11 08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/CHANGELOG   2026-06-30 18:36:41.000000000 +0200
@@ -2,6 +2,26 @@
 
 .. towncrier release notes start
 
+6.20.3 (2026-06-30)
+===================
+
+Bug Fixes
+---------
+
+- Fix fetching files from re-created mirrors (for example after import or 
delete and create) without fetching simple project page first. This happens 
when uv uses a package URL directly from a lock file.
+
+- Search for METADATA file if the generated name with normalized project and 
version doesn't match. This fixes serving core metadata for older wheels.
+
+
+6.20.2 (2026-06-11)
+===================
+
+Bug Fixes
+---------
+
+- Fix logic error which allowed unauthorized fetching of replication data.
+
+
 6.20.1 (2026-05-11)
 ===================
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/CHANGELOG.short.rst 
new/devpi_server-6.20.3/CHANGELOG.short.rst
--- old/devpi_server-6.20.1/CHANGELOG.short.rst 2026-05-11 08:58:56.000000000 
+0200
+++ new/devpi_server-6.20.3/CHANGELOG.short.rst 2026-06-30 18:37:42.000000000 
+0200
@@ -9,69 +9,58 @@
 
 .. towncrier release notes start
 
-6.20.1 (2026-05-11)
+6.20.3 (2026-06-30)
 ===================
 
 Bug Fixes
 ---------
 
-- Pass through request headers when streaming .metadata from mirror. Refs #1018
+- Fix fetching files from re-created mirrors (for example after import or 
delete and create) without fetching simple project page first. This happens 
when uv uses a package URL directly from a lock file.
 
+- Search for METADATA file if the generated name with normalized project and 
version doesn't match. This fixes serving core metadata for older wheels.
 
-6.20.0 (2026-04-30)
-===================
 
-Features
---------
-
-- Add experimental bare bones core-metadata ([PEP 
658](https://peps.python.org/pep-0658/), [PEP 
714](https://peps.python.org/pep-0714/)) support with 
``--enable-core-metadata`` command line option and 
``mirror_provides_core_metadata`` mirror index option. Refs #1018
+6.20.2 (2026-06-11)
+===================
 
 Bug Fixes
 ---------
 
-- Update replica status when the replica is waiting for new serials using the 
streaming changelog endpoint.
+- Fix logic error which allowed unauthorized fetching of replication data.
 
 
-6.19.3 (2026-04-13)
+6.20.1 (2026-05-11)
 ===================
 
 Bug Fixes
 ---------
 
-- Fix #1112: Parse simple JSON reply even with wrong content-type in reply if 
the body seems to contain JSON.
+- Pass through request headers when streaming .metadata from mirror. Refs #1018
 
-- Return stale project list for mirrors when the lock can't be acquired within 
the timeout.
 
-- Fix importing of toxresults from devpi-server 6.5.0 to 6.9.0 where the wrong 
hash was stored.
+6.20.0 (2026-04-30)
+===================
 
+Features
+--------
 
-6.19.2 (2026-03-17)
-===================
+- Add experimental bare bones core-metadata ([PEP 
658](https://peps.python.org/pep-0658/), [PEP 
714](https://peps.python.org/pep-0714/)) support with 
``--enable-core-metadata`` command line option and 
``mirror_provides_core_metadata`` mirror index option. Refs #1018
 
 Bug Fixes
 ---------
 
-- Preserve log for documentation uploads in export.
-
-- Any missing file on mirrors will be ignored during event processing as is 
already the case in other places.
-
-- Use short timeout when project list is requested for ``has_project`` call on 
mirrors instead of the long one used for ``list_projects``. This prevents 
installers from timing out and retrying several times.
-
-- Fix error handling for proxy requests from replica to primary.
-
-Other Changes
--------------
-
-- Removed limit of reported missing files for devpi-fsck.
+- Update replica status when the replica is waiting for new serials using the 
streaming changelog endpoint.
 
 
-6.19.1 (2026-02-09)
+6.19.3 (2026-04-13)
 ===================
 
 Bug Fixes
 ---------
 
-- Pin setuptools as pyramid still requires pkg_resources.
+- Fix #1112: Parse simple JSON reply even with wrong content-type in reply if 
the body seems to contain JSON.
+
+- Return stale project list for mirrors when the lock can't be acquired within 
the timeout.
 
-- Always allow replicas to access deleted releases to get the proper ``410 
Gone`` instead of ``403 Forbidden`` when ``devpi-lockdown`` is in use.
+- Fix importing of toxresults from devpi-server 6.5.0 to 6.9.0 where the wrong 
hash was stored.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/PKG-INFO 
new/devpi_server-6.20.3/PKG-INFO
--- old/devpi_server-6.20.1/PKG-INFO    2026-05-11 08:58:56.883684600 +0200
+++ new/devpi_server-6.20.3/PKG-INFO    2026-06-30 18:37:42.541430500 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: devpi-server
-Version: 6.20.1
+Version: 6.20.3
 Summary: devpi-server: backend for hosting private package indexes and PyPI 
on-demand mirrors
 Maintainer-email: Florian Schulze <[email protected]>
 License-Expression: MIT
@@ -121,69 +121,58 @@
 
 .. towncrier release notes start
 
-6.20.1 (2026-05-11)
+6.20.3 (2026-06-30)
 ===================
 
 Bug Fixes
 ---------
 
-- Pass through request headers when streaming .metadata from mirror. Refs #1018
+- Fix fetching files from re-created mirrors (for example after import or 
delete and create) without fetching simple project page first. This happens 
when uv uses a package URL directly from a lock file.
 
+- Search for METADATA file if the generated name with normalized project and 
version doesn't match. This fixes serving core metadata for older wheels.
 
-6.20.0 (2026-04-30)
-===================
 
-Features
---------
-
-- Add experimental bare bones core-metadata ([PEP 
658](https://peps.python.org/pep-0658/), [PEP 
714](https://peps.python.org/pep-0714/)) support with 
``--enable-core-metadata`` command line option and 
``mirror_provides_core_metadata`` mirror index option. Refs #1018
+6.20.2 (2026-06-11)
+===================
 
 Bug Fixes
 ---------
 
-- Update replica status when the replica is waiting for new serials using the 
streaming changelog endpoint.
+- Fix logic error which allowed unauthorized fetching of replication data.
 
 
-6.19.3 (2026-04-13)
+6.20.1 (2026-05-11)
 ===================
 
 Bug Fixes
 ---------
 
-- Fix #1112: Parse simple JSON reply even with wrong content-type in reply if 
the body seems to contain JSON.
+- Pass through request headers when streaming .metadata from mirror. Refs #1018
 
-- Return stale project list for mirrors when the lock can't be acquired within 
the timeout.
 
-- Fix importing of toxresults from devpi-server 6.5.0 to 6.9.0 where the wrong 
hash was stored.
+6.20.0 (2026-04-30)
+===================
 
+Features
+--------
 
-6.19.2 (2026-03-17)
-===================
+- Add experimental bare bones core-metadata ([PEP 
658](https://peps.python.org/pep-0658/), [PEP 
714](https://peps.python.org/pep-0714/)) support with 
``--enable-core-metadata`` command line option and 
``mirror_provides_core_metadata`` mirror index option. Refs #1018
 
 Bug Fixes
 ---------
 
-- Preserve log for documentation uploads in export.
-
-- Any missing file on mirrors will be ignored during event processing as is 
already the case in other places.
-
-- Use short timeout when project list is requested for ``has_project`` call on 
mirrors instead of the long one used for ``list_projects``. This prevents 
installers from timing out and retrying several times.
-
-- Fix error handling for proxy requests from replica to primary.
-
-Other Changes
--------------
-
-- Removed limit of reported missing files for devpi-fsck.
+- Update replica status when the replica is waiting for new serials using the 
streaming changelog endpoint.
 
 
-6.19.1 (2026-02-09)
+6.19.3 (2026-04-13)
 ===================
 
 Bug Fixes
 ---------
 
-- Pin setuptools as pyramid still requires pkg_resources.
+- Fix #1112: Parse simple JSON reply even with wrong content-type in reply if 
the body seems to contain JSON.
+
+- Return stale project list for mirrors when the lock can't be acquired within 
the timeout.
 
-- Always allow replicas to access deleted releases to get the proper ``410 
Gone`` instead of ``403 Forbidden`` when ``devpi-lockdown`` is in use.
+- Fix importing of toxresults from devpi-server 6.5.0 to 6.9.0 where the wrong 
hash was stored.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/devpi_server/__init__.py 
new/devpi_server-6.20.3/devpi_server/__init__.py
--- old/devpi_server-6.20.1/devpi_server/__init__.py    2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/devpi_server/__init__.py    2026-06-30 
18:36:41.000000000 +0200
@@ -1 +1 @@
-__version__ = "6.20.1"
+__version__ = "6.20.3"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/devpi_server/filestore.py 
new/devpi_server-6.20.3/devpi_server/filestore.py
--- old/devpi_server-6.20.1/devpi_server/filestore.py   2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/devpi_server/filestore.py   2026-06-30 
18:36:41.000000000 +0200
@@ -9,19 +9,27 @@
 from .markers import Deleted
 from .markers import NoDefault
 from .markers import nodefault as _nodefault
+from .normalized import normalize_name
 from .readonly import DictViewReadonly
 from .readonly import ensure_deeply_readonly
 from .readonly import get_mutable_deepcopy
+from contextlib import suppress
+from devpi_common.metadata import ALLOWED_ARCHIVE_EXTS
 from devpi_common.metadata import splitbasename
+from devpi_common.metadata import splitext_archive
 from devpi_common.types import parse_hash_spec
 from devpi_server.log import threadlog
 from devpi_server.markers import absent
 from inspect import currentframe
+from io import BytesIO
+from pathlib import Path
 from typing import TYPE_CHECKING
 from typing import cast
 from typing import overload
 from urllib.parse import unquote
 from wsgiref.handlers import format_date_time
+from zipfile import BadZipFile
+from zipfile import ZipFile
 import hashlib
 import mimetypes
 import re
@@ -35,6 +43,8 @@
     from .keyfs_types import RelPath
     from .keyfs_types import TypedKey
     from .markers import Absent
+    from .normalized import NormalizedName
+    from collections.abc import Iterable
     from devpi_common.url import URL
     from typing import Any
 
@@ -78,7 +88,7 @@
             self._types.append(hash_type)
 
     @property
-    def digests(self):
+    def digests(self) -> Digests:
         if not self._digests:
             if not self._running_hashes:
                 msg = f"{self.__class__.__name__} was not started."
@@ -239,6 +249,23 @@
     return next(iter(hashes))
 
 
+def core_metadata_filenames(
+    project: NormalizedName, version: str, basename: str | None = None
+) -> list[str]:
+    results = [
+        f"{project.replace('-', '_')}-{version}.dist-info/METADATA",
+    ]
+    if hasattr(project, "original"):
+        results.append(f"{project.original}-{version}.dist-info/METADATA")
+    if basename is not None:
+        with suppress(ValueError):
+            (projectname, version, _pyver, _ext) = 
split_name_version_pyversion_ext(
+                project, basename
+            )
+            results.append(f"{projectname}-{version}.dist-info/METADATA")
+    return results
+
+
 def get_default_hash_algo():
     warnings.warn(
         "The get_default_hash_algo function is deprecated",
@@ -283,12 +310,52 @@
     return get_hashes(fp, hash_types=(hash_type,))[hash_type]
 
 
-def get_hashes(content_or_file, *, hash_types=absent, 
additional_hash_types=None):
+def get_core_metadata(
+    content_or_file: ContentOrFile,
+    project: NormalizedName,
+    version: str,
+    basename: str | None = None,
+) -> bytes | None:
+    zip_file = (
+        BytesIO(content_or_file)
+        if isinstance(content_or_file, bytes)
+        else content_or_file
+    )
+    with suppress(BadZipFile), ZipFile(zip_file) as zf:
+        for fn in core_metadata_filenames(project, version, basename=basename):
+            with suppress(KeyError):
+                return zf.read(fn)
+        for info in zf.infolist():
+            if info.is_dir() or Path(info.filename).name != "METADATA":
+                continue
+            return zf.read(info)
+    zip_file.seek(0)
+    return None
+
+
+def get_core_metadata_hashes(
+    content_or_file: ContentOrFile,
+    project: NormalizedName,
+    version: str,
+    basename: str | None = None,
+) -> Digests | None:
+    contents = get_core_metadata(content_or_file, project, version, 
basename=basename)
+    if contents is None:
+        return None
+    return get_hashes(contents, hash_types=("sha256",))
+
+
+def get_hashes(
+    content_or_file: ContentOrFile,
+    *,
+    hash_types: Iterable[str] | Absent = absent,
+    additional_hash_types: Iterable[str] | None = None,
+) -> Digests:
     if hash_types is absent:
         # in tests this is overwritten and fails if used as default in kwarg
         hash_types = DEFAULT_HASH_TYPES
     if not hash_types:
-        return {}
+        return Digests()
     if additional_hash_types:
         hash_types = (*hash_types, *additional_hash_types)
     running_hashes = RunningHashes(*hash_types)
@@ -365,6 +432,52 @@
         )
 
 
+def _split_wheel_filename(
+    project: NormalizedName, nameversion: str, ext: str
+) -> tuple[str, str, str, str]:
+    filename = f"{nameversion}{ext}"
+    if ext != ".whl":
+        raise ValueError(
+            f"Invalid wheel filename (extension must be '.whl'): {filename!r}"
+        )
+    dashes = filename.count("-")
+    if dashes not in (4, 5):
+        raise ValueError(
+            f"Invalid wheel filename (wrong number of parts): {filename!r}"
+        )
+    parts = filename.split("-", dashes - 2)
+    name_part = parts[0]
+    if normalize_name(name_part) != project:
+        raise ValueError(f"{filename!r} does not match {project}")
+    return (parts[0], parts[1], "", ext)
+
+
+def split_name_version_pyversion_ext(
+    project: NormalizedName, fn: str, *, checkarch: bool = True
+) -> tuple[str, str, str, str]:
+    (nameversion, ext) = splitext_archive(fn)
+    if checkarch and ext.lower() not in ALLOWED_ARCHIVE_EXTS:
+        raise ValueError(f"invalid archive type {ext!r} in: {fn!r}")
+    if ext == ".whl":
+        return _split_wheel_filename(project, nameversion, ext)
+    start = fn.find("-")
+    start = len(project) if start < 0 else min(len(project), start)
+    if start >= len(nameversion) and normalize_name(nameversion) == project:
+        return (nameversion, "", "", ext)
+    for i in range(start, len(nameversion)):
+        if nameversion[i] != "-":
+            continue
+        if normalize_name(nameversion[:i]) == project:
+            version = nameversion[i + 1 :]
+            if (pystart := version.find("-py")) < 0:
+                pyversion = ""
+            else:
+                pyversion = version[pystart + 1 :]
+                version = version[:pystart]
+            return (nameversion[:i], version, pyversion, ext)
+    raise ValueError(f"{fn!r} does not match {project}")
+
+
 def unicode_if_bytes(val):
     if isinstance(val, bytes):
         return val.decode('ascii')
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/devpi_server/interfaces.py 
new/devpi_server-6.20.3/devpi_server/interfaces.py
--- old/devpi_server-6.20.1/devpi_server/interfaces.py  2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/devpi_server/interfaces.py  2026-06-30 
18:36:41.000000000 +0200
@@ -315,7 +315,7 @@
     verifyObject(IStorageConnection4, unwrap_connection_obj(obj))
 
 
-_adapters = {}
+_adapters: dict = {}
 
 
 def _register_adapter(func: Callable) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/devpi_server/replica.py 
new/devpi_server-6.20.3/devpi_server/replica.py
--- old/devpi_server-6.20.1/devpi_server/replica.py     2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/devpi_server/replica.py     2026-06-30 
18:36:41.000000000 +0200
@@ -251,10 +251,8 @@
                 "expected %s as primary_uuid, replica sent %s" %
                 (primary_uuid, expected_uuid))
 
-        identity = self.request.identity
-        if identity is not None and not isinstance(identity, ReplicaIdentity):
-            raise HTTPForbidden(
-                "Authenticated identity '%r' isn't from replica." % identity)
+        if not isinstance(self.request.identity, ReplicaIdentity):
+            raise HTTPForbidden("Replication endpoint requires a replica 
identity.")
 
     @view_config(route_name="/+changelog/{serial}")
     def get_changes(self):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/devpi_server/views.py 
new/devpi_server-6.20.3/devpi_server/views.py
--- old/devpi_server-6.20.1/devpi_server/views.py       2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/devpi_server/views.py       2026-06-30 
18:36:41.000000000 +0200
@@ -6,6 +6,7 @@
 from .exceptions import lazy_format_exception_only
 from .filestore import BadGateway
 from .filestore import RunningHashes
+from .filestore import get_core_metadata
 from .filestore import get_hashes
 from .filestore import get_seekable_content_or_file
 from .fileutil import buffered_iterator
@@ -13,6 +14,7 @@
 from .log import thread_pop_log
 from .log import thread_push_log
 from .log import threadlog
+from .markers import Unknown
 from .model import InvalidIndex
 from .model import InvalidIndexconfig
 from .model import InvalidUser
@@ -25,6 +27,7 @@
 from .readonly import get_mutable_deepcopy
 from collections import defaultdict
 from devpi_common.metadata import get_pyversion_filetype
+from devpi_common.metadata import splitbasename
 from devpi_common.types import ensure_unicode
 from devpi_common.url import URL
 from devpi_common.validation import is_valid_archive_name
@@ -32,6 +35,7 @@
 from http import HTTPStatus
 from lazy import lazy
 from operator import attrgetter
+from pathlib import Path
 from pluggy import HookimplMarker
 from pyramid.authentication import b64encode
 from pyramid.httpexceptions import HTTPException
@@ -58,7 +62,6 @@
 from typing import TYPE_CHECKING
 from typing import cast
 from urllib.parse import urlparse
-from zipfile import ZipFile
 import attrs
 import contextlib
 import devpi_server
@@ -1631,11 +1634,12 @@
             return apireturn(502, e.args[0])
 
         if is_metadata:
-            metadata_filename = (
-                f"{entry.project.replace('-', 
'_')}-{entry.version}.dist-info/METADATA"
-            )
-            with entry.file_open_read() as f, ZipFile(f) as zf:
-                wheel_metadata_contents = zf.read(metadata_filename)
+            with entry.file_open_read() as f:
+                wheel_metadata_contents = get_core_metadata(
+                    f, entry.project, entry.version, basename=entry.basename
+                )
+            if wheel_metadata_contents is None:
+                abort(self.request, 404, f"no metadata found in 
{entry.relpath}")
             return Response(
                 body=wheel_metadata_contents,
                 content_type="application/octet-stream",
@@ -1682,6 +1686,24 @@
             relpath = relpath.removesuffix(".metadata")
         entry = self.xom.filestore.get_file_entry(relpath)
         if entry is None:
+            try:
+                project = splitbasename(Path(relpath).name)[0]
+            except ValueError:
+                abort(self.request, 404, "no such file")
+            stage = self.context.stage
+            project_exists = stage.has_project_perstage(project)
+            if project_exists or (
+                isinstance(project_exists, Unknown) and stage.no_project_list
+            ):
+                # force a fetch of the project as it is very unlikely
+                # that the URL is from an internal project name unless
+                # it was edited by hand or the index was changed from a mirror
+                # to a private stage with the same name
+                stage.list_versions_perstage(project)
+                project_exists = stage.has_project_perstage(project)
+            if project_exists:
+                entry = self.xom.filestore.get_file_entry(relpath)
+        if entry is None:
             abort(self.request, 404, "no such file")
         return self._pkgserv(entry, is_metadata=is_metadata)
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/devpi_server.egg-info/PKG-INFO 
new/devpi_server-6.20.3/devpi_server.egg-info/PKG-INFO
--- old/devpi_server-6.20.1/devpi_server.egg-info/PKG-INFO      2026-05-11 
08:58:56.000000000 +0200
+++ new/devpi_server-6.20.3/devpi_server.egg-info/PKG-INFO      2026-06-30 
18:37:42.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: devpi-server
-Version: 6.20.1
+Version: 6.20.3
 Summary: devpi-server: backend for hosting private package indexes and PyPI 
on-demand mirrors
 Maintainer-email: Florian Schulze <[email protected]>
 License-Expression: MIT
@@ -121,69 +121,58 @@
 
 .. towncrier release notes start
 
-6.20.1 (2026-05-11)
+6.20.3 (2026-06-30)
 ===================
 
 Bug Fixes
 ---------
 
-- Pass through request headers when streaming .metadata from mirror. Refs #1018
+- Fix fetching files from re-created mirrors (for example after import or 
delete and create) without fetching simple project page first. This happens 
when uv uses a package URL directly from a lock file.
 
+- Search for METADATA file if the generated name with normalized project and 
version doesn't match. This fixes serving core metadata for older wheels.
 
-6.20.0 (2026-04-30)
-===================
 
-Features
---------
-
-- Add experimental bare bones core-metadata ([PEP 
658](https://peps.python.org/pep-0658/), [PEP 
714](https://peps.python.org/pep-0714/)) support with 
``--enable-core-metadata`` command line option and 
``mirror_provides_core_metadata`` mirror index option. Refs #1018
+6.20.2 (2026-06-11)
+===================
 
 Bug Fixes
 ---------
 
-- Update replica status when the replica is waiting for new serials using the 
streaming changelog endpoint.
+- Fix logic error which allowed unauthorized fetching of replication data.
 
 
-6.19.3 (2026-04-13)
+6.20.1 (2026-05-11)
 ===================
 
 Bug Fixes
 ---------
 
-- Fix #1112: Parse simple JSON reply even with wrong content-type in reply if 
the body seems to contain JSON.
+- Pass through request headers when streaming .metadata from mirror. Refs #1018
 
-- Return stale project list for mirrors when the lock can't be acquired within 
the timeout.
 
-- Fix importing of toxresults from devpi-server 6.5.0 to 6.9.0 where the wrong 
hash was stored.
+6.20.0 (2026-04-30)
+===================
 
+Features
+--------
 
-6.19.2 (2026-03-17)
-===================
+- Add experimental bare bones core-metadata ([PEP 
658](https://peps.python.org/pep-0658/), [PEP 
714](https://peps.python.org/pep-0714/)) support with 
``--enable-core-metadata`` command line option and 
``mirror_provides_core_metadata`` mirror index option. Refs #1018
 
 Bug Fixes
 ---------
 
-- Preserve log for documentation uploads in export.
-
-- Any missing file on mirrors will be ignored during event processing as is 
already the case in other places.
-
-- Use short timeout when project list is requested for ``has_project`` call on 
mirrors instead of the long one used for ``list_projects``. This prevents 
installers from timing out and retrying several times.
-
-- Fix error handling for proxy requests from replica to primary.
-
-Other Changes
--------------
-
-- Removed limit of reported missing files for devpi-fsck.
+- Update replica status when the replica is waiting for new serials using the 
streaming changelog endpoint.
 
 
-6.19.1 (2026-02-09)
+6.19.3 (2026-04-13)
 ===================
 
 Bug Fixes
 ---------
 
-- Pin setuptools as pyramid still requires pkg_resources.
+- Fix #1112: Parse simple JSON reply even with wrong content-type in reply if 
the body seems to contain JSON.
+
+- Return stale project list for mirrors when the lock can't be acquired within 
the timeout.
 
-- Always allow replicas to access deleted releases to get the proper ``410 
Gone`` instead of ``403 Forbidden`` when ``devpi-lockdown`` is in use.
+- Fix importing of toxresults from devpi-server 6.5.0 to 6.9.0 where the wrong 
hash was stored.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/test_devpi_server/plugin.py 
new/devpi_server-6.20.3/test_devpi_server/plugin.py
--- old/devpi_server-6.20.1/test_devpi_server/plugin.py 2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/test_devpi_server/plugin.py 2026-06-30 
18:36:41.000000000 +0200
@@ -4,6 +4,7 @@
 from _pytest import capture
 from bs4 import BeautifulSoup
 from contextlib import closing
+from devpi_common.metadata import parse_version
 from devpi_common.terminal import TerminalWriter
 from devpi_common.url import URL
 from devpi_server import mirror
@@ -12,6 +13,7 @@
 from devpi_server.log import threadlog
 from devpi_server.main import XOM
 from devpi_server.main import parseoptions
+from devpi_server.markers import NotSet
 from devpi_server.markers import notset
 from devpi_server.normalized import normalize_name
 from io import BytesIO
@@ -40,7 +42,6 @@
 
 if TYPE_CHECKING:
     from collections.abc import Callable
-    from devpi_server.markers import NotSet
 
 
 pytest_plugins = ["test_devpi_server.reqmock"]
@@ -76,7 +77,6 @@
 
 @pytest.fixture(scope="session")
 def server_version():
-    from devpi_common.metadata import parse_version
     from devpi_server import __version__
     return parse_version(__version__)
 
@@ -164,10 +164,13 @@
 
 @pytest.fixture
 def gen_path(request, tmp_path_factory):
+    from _pytest import __version__
     from _pytest.pathlib import LOCK_TIMEOUT
     from _pytest.pathlib import make_numbered_dir_with_cleanup
 
     cache: list[str] = []
+    pytest_910 = parse_version("9.1.0")
+    pytest_version = parse_version(__version__)
 
     def gen_path(name=None):
         if not cache:
@@ -180,8 +183,15 @@
             path = basedir / name
             path.mkdir()
             return path
+        kw = {} if pytest_version < pytest_910 else dict(register=lambda *_: 
None)
         return make_numbered_dir_with_cleanup(
-            prefix="gentmp", keep=0, root=basedir, lock_timeout=LOCK_TIMEOUT, 
mode=0o700)
+            prefix="gentmp",
+            keep=0,
+            root=basedir,
+            lock_timeout=LOCK_TIMEOUT,
+            mode=0o700,
+            **kw,
+        )
 
     return gen_path
 
@@ -1689,7 +1699,7 @@
     thread.start()
     wait_for_port(host, port, 5)
     (server_host, server_port) = server.server_address  # type: ignore[misc]
-    print(f"Started simpypi server 
{ensure_unicode(server_host)}:{server_port}")
+    print(f"Started simpypi server 
{ensure_unicode(server_host)}:{server_port}")  # type: ignore[arg-type]
     return server
 
 
@@ -1745,7 +1755,7 @@
             raise TypeError
         assert md5 is notset
         if hash_spec is notset or hash_spec is True:
-            if hash_type is notset:
+            if isinstance(hash_type, NotSet):
                 hash_type = self.DEFAULT_HASH_TYPE
             hash_spec = self.get_hashes(
                 link.encode(),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/devpi_server-6.20.1/test_devpi_server/test_importexport.py 
new/devpi_server-6.20.3/test_devpi_server/test_importexport.py
--- old/devpi_server-6.20.1/test_devpi_server/test_importexport.py      
2026-05-11 08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/test_devpi_server/test_importexport.py      
2026-06-30 18:36:41.000000000 +0200
@@ -13,6 +13,7 @@
 from devpi_server.importexport import do_export
 from devpi_server.importexport import do_import
 from devpi_server.main import Fatal
+from devpi_server.normalized import normalize_name
 from io import BytesIO
 import devpi_server
 import importlib.resources
@@ -559,32 +560,41 @@
         testapp = maketestapp(mapp1.xom)
         api = mapp1.use('root/pypi')
         pypistage.mock_simple(
-            "package",
-            '<a href="/package-1.0.zip" />\n'
-            '<a 
href="/package-1.1.zip#sha256=a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
 />\n'
-            '<a 
href="/package-1.2.zip#sha256=b3a8e0e1f9ab1bfe3a36f231f676f78bb30a519d2b21e6c530c0eee8ebb4a5d0"
 data-yanked="" />\n'
-            '<a 
href="/package-2.0.zip#sha256=35a9e381b1a27567549b5f8a6f783c167ebf809f1c4d6a9e367240484d8ce281"
 data-requires-python="&gt;=3.5" />')
-        content1 = b"123"
+            "pack-age",
+            '<a href="/pack.age-0.9.tar.gz" />\n'
+            '<a href="/pack.age-0.9-1.tar.gz" />\n'
+            '<a href="/pack.age-1.0.zip" />\n'
+            '<a 
href="/pack.age-1.1.zip#sha256=a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
 />\n'
+            '<a 
href="/pack.age-1.2.zip#sha256=b3a8e0e1f9ab1bfe3a36f231f676f78bb30a519d2b21e6c530c0eee8ebb4a5d0"
 data-yanked="" />\n'
+            '<a 
href="/pack.age-2.0.zip#sha256=35a9e381b1a27567549b5f8a6f783c167ebf809f1c4d6a9e367240484d8ce281"
 data-requires-python="&gt;=3.5" />',
+        )
+        content1 = b"abc"
         hashdir1 = relpath_prefix(content1)
-        pypistage.mock_extfile("/package-1.1.zip", content1)
-        content2 = b"456"
+        pypistage.mock_extfile("/pack.age-0.9-1.tar.gz", content1)
+        content2 = b"123"
         hashdir2 = relpath_prefix(content2)
-        pypistage.mock_extfile("/package-1.2.zip", content2)
-        content3 = b"789"
+        pypistage.mock_extfile("/pack.age-1.1.zip", content2)
+        content3 = b"456"
         hashdir3 = relpath_prefix(content3)
-        pypistage.mock_extfile("/package-2.0.zip", content3)
-        r = testapp.get(api.index + "/+simple/package/")
+        pypistage.mock_extfile("/pack.age-1.2.zip", content3)
+        content4 = b"789"
+        hashdir4 = relpath_prefix(content4)
+        pypistage.mock_extfile("/pack.age-2.0.zip", content4)
+        r = testapp.get(api.index + "/+simple/pack-age/")
         assert r.status_code == 200
         # fetch some files, so they are included in the dump
-        (_, link1, link2, link3) = sorted(
-            (x.attrs['href'] for x in r.html.select('a')),
-            key=lambda x: x.split('/')[-1])
+        (link1, _, _, link2, link3, link4) = sorted(
+            (x.attrs["href"] for x in r.html.select("a")),
+            key=lambda x: x.split("/")[-1],
+        )
         baseurl = URL(r.request.url)
         r = testapp.get(baseurl.joinpath(link1).url)
-        assert r.body == b"123"
+        assert r.body == b"abc"
         r = testapp.get(baseurl.joinpath(link2).url)
-        assert r.body == b"456"
+        assert r.body == b"123"
         r = testapp.get(baseurl.joinpath(link3).url)
+        assert r.body == b"456"
+        r = testapp.get(baseurl.joinpath(link4).url)
         assert r.body == b"789"
         impexp.export()
         mapp2 = impexp.new_import()
@@ -592,15 +602,37 @@
             stage = mapp2.xom.model.getstage(api.stagename)
             stage.offline = True
             projects = stage.list_projects_perstage()
-            assert projects == {'package': 'package'}
+            assert projects == {normalize_name("pack.age"): "pack-age"}
             links = sorted(
                 (x.key, x.path, x.require_python, x.yanked)
-                for x in stage.get_simplelinks_perstage("package")
+                for x in stage.get_simplelinks_perstage("pack.age")
             )
             assert links == [
-                ('package-1.1.zip', 
f'root/pypi/+f/{hashdir1}/package-1.1.zip', None, None),
-                ('package-1.2.zip', 
f'root/pypi/+f/{hashdir2}/package-1.2.zip', None, ""),
-                ('package-2.0.zip', 
f'root/pypi/+f/{hashdir3}/package-2.0.zip', '>=3.5', None)]
+                (
+                    "pack.age-0.9-1.tar.gz",
+                    f"root/pypi/+f/{hashdir1}/pack.age-0.9-1.tar.gz",
+                    None,
+                    None,
+                ),
+                (
+                    "pack.age-1.1.zip",
+                    f"root/pypi/+f/{hashdir2}/pack.age-1.1.zip",
+                    None,
+                    None,
+                ),
+                (
+                    "pack.age-1.2.zip",
+                    f"root/pypi/+f/{hashdir3}/pack.age-1.2.zip",
+                    None,
+                    "",
+                ),
+                (
+                    "pack.age-2.0.zip",
+                    f"root/pypi/+f/{hashdir4}/pack.age-2.0.zip",
+                    ">=3.5",
+                    None,
+                ),
+            ]
 
     def test_mirrordata(self, impexp):
         hashes = get_hashes(b"content", additional_hash_types=("sha256",))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/test_devpi_server/test_mirror.py 
new/devpi_server-6.20.3/test_devpi_server/test_mirror.py
--- old/devpi_server-6.20.1/test_devpi_server/test_mirror.py    2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/test_devpi_server/test_mirror.py    2026-06-30 
18:36:41.000000000 +0200
@@ -1,3 +1,5 @@
+from devpi_server.filestore import get_hashes
+from devpi_server.filestore import make_splitdir
 from devpi_server.keyfs_types import FilePathInfo
 from devpi_server.keyfs_types import RelPath
 from devpi_server.mirror import ProjectNamesCache
@@ -529,6 +531,25 @@
         assert ret == ret2
         assert commit_serial == pypistage.keyfs.get_current_serial()
 
+    @pytest.mark.notransaction
+    @pytest.mark.parametrize("no_project_list", [False, True])
+    def test_get_simplelinks_perstage_cache_not_found(self, pypistage, 
no_project_list):
+        with pypistage.keyfs.write_transaction():
+            pypistage.modify(mirror_no_project_list=no_project_list)
+        with pypistage.keyfs.read_transaction():
+            msg = (
+                "not found on GET URL"
+                if no_project_list
+                else "project pytest not found"
+            )
+            with pytest.raises(pypistage.UpstreamNotFoundError, match=msg):
+                pypistage.get_simplelinks_perstage("pytest")
+            with pytest.raises(
+                pypistage.UpstreamNotFoundError,
+                match="cached not found for project pytest",
+            ):
+                pypistage.get_simplelinks_perstage("pytest")
+
     @pytest.mark.parametrize("errorcode", [404, -1, -2])
     def test_parse_and_scrape_error(self, pypistage, errorcode):
         pypistage.mock_simple("pytest", text='''
@@ -825,6 +846,25 @@
         r = testapp.xget(200, 
"/root/pypi/+f/123/4/foo-1.0-py3-none-any.whl.metadata")
         assert r.body == b"metadata"
 
+    @pytest.mark.notransaction
+    def test_file_server_fresh_instance(self, pypistage, testapp):
+        # uv doesn't load the simple project page if the package URL
+        # is stored in a lock file, on a fresh instance this failed,
+        # because the entry data didn't exist
+        content = b"content"
+        hash_spec = get_hashes(content).get_default_spec()
+        pypistage.mock_simple("foo", 
pkgver=f"foo-1.0-py3-none-any.whl#{hash_spec}")
+        
pypistage.url2response["https://pypi.org/foo/foo-1.0-py3-none-any.whl";] = dict(
+            status_code=200, content=b"content"
+        )
+        with pypistage.keyfs.read_transaction():
+            assert not pypistage.is_project_cached("foo")
+        hash_dir = "/".join(make_splitdir(hash_spec))
+        r = testapp.xget(200, 
f"/root/pypi/+f/{hash_dir}/foo-1.0-py3-none-any.whl")
+        assert r.body == b"content"
+        with pypistage.keyfs.read_transaction():
+            assert pypistage.is_project_cached("foo")
+
 
 class TestMirrorStageprojects:
     @pytest.mark.asyncio
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/devpi_server-6.20.1/test_devpi_server/test_replica.py 
new/devpi_server-6.20.3/test_devpi_server/test_replica.py
--- old/devpi_server-6.20.1/test_devpi_server/test_replica.py   2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/test_devpi_server/test_replica.py   2026-06-30 
18:36:41.000000000 +0200
@@ -71,19 +71,54 @@
         r = testapp.get("/+api", expect_errors=False)
         return int(r.headers["X-DEVPI-SERIAL"])
 
-    def test_accept_header(self, testapp):
+    def test_accept_header(self, auth_serializer, testapp):
         from devpi_server.replica import REPLICA_ACCEPT_STREAMING
         from devpi_server.replica import REPLICA_CONTENT_TYPE
 
-        r = testapp.get("/+changelog/0")
-        assert r.content_type == "application/octet-stream"
-        r = testapp.get("/+changelog/0", headers={"Accept": "foo"})
-        assert r.content_type == "application/octet-stream"
-        r = testapp.get("/+changelog/0", headers={"Accept": "foo/bar"})
-        assert r.content_type == "application/octet-stream"
-        r = testapp.get("/+changelog/0", headers={"Accept": 
REPLICA_CONTENT_TYPE})
-        assert r.content_type == "application/octet-stream"
-        r = testapp.get("/+changelog/0", headers={"Accept": 
REPLICA_ACCEPT_STREAMING})
+        token = auth_serializer.dumps(self.replica_uuid)
+        r = testapp.get(
+            "/+changelog/0",
+            headers={
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
+        assert r.content_type == "application/octet-stream"
+        r = testapp.get(
+            "/+changelog/0",
+            headers={
+                "Accept": "foo",
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
+        assert r.content_type == "application/octet-stream"
+        r = testapp.get(
+            "/+changelog/0",
+            headers={
+                "Accept": "foo/bar",
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
+        assert r.content_type == "application/octet-stream"
+        r = testapp.get(
+            "/+changelog/0",
+            headers={
+                "Accept": REPLICA_CONTENT_TYPE,
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
+        assert r.content_type == "application/octet-stream"
+        r = testapp.get(
+            "/+changelog/0",
+            headers={
+                "Accept": REPLICA_ACCEPT_STREAMING,
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
         assert r.content_type == "application/octet-stream"
 
     def test_get_latest_serial(self, testapp, mapp):
@@ -122,6 +157,24 @@
             serial = mcr._wait_for_serial(xom.keyfs.get_current_serial())
         assert serial == 1
 
+    def test_anonymous_access(self, testapp):
+        r = testapp.xget(
+            403,
+            "/+changelog/0",
+            headers={
+                H_EXPECTED_MASTER_ID: "",
+            },
+        )
+        assert "Replication endpoint requires a replica identity." in r.text
+        r = testapp.xget(
+            403,
+            "/+changelog/0",
+            headers={
+                H_EXPECTED_PRIMARY_ID: "",
+            },
+        )
+        assert "Replication endpoint requires a replica identity." in r.text
+
     def test_primary_id_mismatch(self, auth_serializer, testapp):
         token = auth_serializer.dumps(self.replica_uuid)
         testapp.xget(400, "/+changelog/0", headers={
@@ -160,19 +213,54 @@
         r = testapp.get("/+api", expect_errors=False)
         return int(r.headers["X-DEVPI-SERIAL"])
 
-    def test_accept_header(self, testapp):
+    def test_accept_header(self, auth_serializer, testapp):
         from devpi_server.replica import REPLICA_ACCEPT_STREAMING
         from devpi_server.replica import REPLICA_CONTENT_TYPE
 
-        r = testapp.get("/+changelog/0-")
-        assert r.content_type == "application/octet-stream"
-        r = testapp.get("/+changelog/0-", headers={"Accept": "foo"})
-        assert r.content_type == "application/octet-stream"
-        r = testapp.get("/+changelog/0-", headers={"Accept": "foo/bar"})
-        assert r.content_type == "application/octet-stream"
-        r = testapp.get("/+changelog/0-", headers={"Accept": 
REPLICA_CONTENT_TYPE})
+        token = auth_serializer.dumps(self.replica_uuid)
+        r = testapp.get(
+            "/+changelog/0-",
+            headers={
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
+        assert r.content_type == "application/octet-stream"
+        r = testapp.get(
+            "/+changelog/0-",
+            headers={
+                "Accept": "foo",
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
+        assert r.content_type == "application/octet-stream"
+        r = testapp.get(
+            "/+changelog/0-",
+            headers={
+                "Accept": "foo/bar",
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
+        assert r.content_type == "application/octet-stream"
+        r = testapp.get(
+            "/+changelog/0-",
+            headers={
+                "Accept": REPLICA_CONTENT_TYPE,
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
         assert r.content_type == REPLICA_CONTENT_TYPE
-        r = testapp.get("/+changelog/0-", headers={"Accept": 
REPLICA_ACCEPT_STREAMING})
+        r = testapp.get(
+            "/+changelog/0-",
+            headers={
+                "Accept": REPLICA_ACCEPT_STREAMING,
+                H_REPLICA_UUID: self.replica_uuid,
+                "Authorization": "Bearer %s" % token,
+            },
+        )
         assert r.content_type == REPLICA_CONTENT_TYPE
 
     @pytest.mark.usefixtures("noiter")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/devpi_server-6.20.1/test_devpi_server/test_views.py 
new/devpi_server-6.20.3/test_devpi_server/test_views.py
--- old/devpi_server-6.20.1/test_devpi_server/test_views.py     2026-05-11 
08:58:04.000000000 +0200
+++ new/devpi_server-6.20.3/test_devpi_server/test_views.py     2026-06-30 
18:36:41.000000000 +0200
@@ -466,9 +466,9 @@
     api = mapp.create_and_use()
     content_io = BytesIO()
     with ZipFile(content_io, "w") as zf:
-        zf.writestr("pkg1-2.6.dist-info/METADATA", b"metadata")
+        zf.writestr("Pkg1-2.6.dist-info/METADATA", b"metadata")
     content = content_io.getvalue()
-    mapp.upload_file_pypi("pkg1-2.6.whl", content, "pkg1", "2.6")
+    mapp.upload_file_pypi("Pkg1-2.6.whl", content, "pkg1", "2.6")
     r = testapp.xget(
         200,
         f"/{api.stagename}/+simple/pkg1",

Reply via email to