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=">=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=">=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",