Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-uhashring for
openSUSE:Factory checked in at 2026-08-22 21:36:22
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-uhashring (Old)
and /work/SRC/openSUSE:Factory/.python-uhashring.new.1258 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-uhashring"
Sat Aug 22 21:36:22 2026 rev:4 rq:1373062 version:2.5
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-uhashring/python-uhashring.changes
2025-04-22 17:30:27.600568018 +0200
+++
/work/SRC/openSUSE:Factory/.python-uhashring.new.1258/python-uhashring.changes
2026-08-22 21:38:28.282215334 +0200
@@ -1,0 +2,16 @@
+Sat Aug 22 13:31:50 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 2.5:
+ * security: pypi trusted publisher
+ * codeql: add workflow and dependabot
+ * bump hatch required version for pypi metadata
+ * fix: track MetaRing per-node vnodes to prevent stale ring
+ entries
+ * fix: handle vnode hash collisions in MetaRing ring
+ maintenance
+ * ci(python): drop 3.9 EOL version and test each matrix version
+ * chore: drop leftover Python 2 compatibility code
+ * refactor: align KetamaRing._remove_node with MetaRing
+ * ci(pypy): move to PyPy 3.11
+
+-------------------------------------------------------------------
Old:
----
uhashring-2.4-gh.tar.gz
New:
----
uhashring-2.5-gh.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-uhashring.spec ++++++
--- /var/tmp/diff_new_pack.z4omDb/_old 2026-08-22 21:38:29.324252658 +0200
+++ /var/tmp/diff_new_pack.z4omDb/_new 2026-08-22 21:38:29.326252729 +0200
@@ -1,7 +1,7 @@
#
# spec file for package python-uhashring
#
-# Copyright (c) 2025 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
#
# All modifications and additions to the file contributed by third parties
# remain the property of their copyright owners, unless otherwise agreed
@@ -18,7 +18,7 @@
%{?sle15_python_module_pythons}
Name: python-uhashring
-Version: 2.4
+Version: 2.5
Release: 0
Summary: Full featured consistent hashing python library compatible
with ketama
License: BSD-3-Clause
++++++ uhashring-2.4-gh.tar.gz -> uhashring-2.5-gh.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/.github/dependabot.yml
new/uhashring-2.5/.github/dependabot.yml
--- old/uhashring-2.4/.github/dependabot.yml 1970-01-01 01:00:00.000000000
+0100
+++ new/uhashring-2.5/.github/dependabot.yml 2026-08-03 21:33:23.000000000
+0200
@@ -0,0 +1,13 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+#
https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: monthly
+ cooldown:
+ default-days: 14
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/.github/workflows/ci.yml
new/uhashring-2.5/.github/workflows/ci.yml
--- old/uhashring-2.4/.github/workflows/ci.yml 2025-04-09 16:54:50.000000000
+0200
+++ new/uhashring-2.5/.github/workflows/ci.yml 2026-08-03 21:33:23.000000000
+0200
@@ -2,6 +2,8 @@
on: [push, pull_request]
+permissions: {}
+
jobs:
test:
runs-on: ubuntu-latest
@@ -9,10 +11,8 @@
strategy:
max-parallel: 5
matrix:
- python-version: [3.9, '3.10', '3.11', '3.12', '3.13', pypy-3.10-v7.x]
+ python-version: ['3.10', '3.11', '3.12', '3.13', pypy-3.11]
include:
- - python-version: 3.9
- py: py39
- python-version: '3.10'
py: py310
- python-version: '3.11'
@@ -21,13 +21,15 @@
py: py312
- python-version: '3.13'
py: py313
- - python-version: pypy-3.10-v7.x
+ - python-version: pypy-3.11
py: pypy3
steps:
- - uses: actions/checkout@v1
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #
v7.0.0
+ with:
+ persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v2
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 #
v6.3.0
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
@@ -37,6 +39,5 @@
run: make qa
shell: bash
- name: Run tests
- run: make test
+ run: make test PY=${{ matrix.py }}
shell: bash
-
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/.github/workflows/codeql.yml
new/uhashring-2.5/.github/workflows/codeql.yml
--- old/uhashring-2.4/.github/workflows/codeql.yml 1970-01-01
01:00:00.000000000 +0100
+++ new/uhashring-2.5/.github/workflows/codeql.yml 2026-08-03
21:33:23.000000000 +0200
@@ -0,0 +1,45 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+ branches: [ "master" ]
+ schedule:
+ - cron: "11 1 * * 0"
+
+permissions: {}
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ python ]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #
v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Initialize CodeQL
+ uses:
github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
+ with:
+ languages: ${{ matrix.language }}
+ queries: +security-and-quality
+
+ - name: Autobuild
+ uses:
github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a #
v4.36.3
+
+ - name: Perform CodeQL Analysis
+ uses:
github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
+ with:
+ category: "/language:${{ matrix.language }}"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/.github/workflows/publish.yml
new/uhashring-2.5/.github/workflows/publish.yml
--- old/uhashring-2.4/.github/workflows/publish.yml 1970-01-01
01:00:00.000000000 +0100
+++ new/uhashring-2.5/.github/workflows/publish.yml 2026-08-03
21:33:23.000000000 +0200
@@ -0,0 +1,33 @@
+name: Publish to PyPI
+
+on:
+ push:
+ tags:
+ - '*'
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ environment: pypi
+ permissions:
+ id-token: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 #
v4.3.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 #
v5.6.0
+ with:
+ python-version: "3.x"
+
+ - name: Install build tool
+ run: pip install build
+
+ - name: Build package
+ run: python -m build
+
+ - name: Publish to PyPI
+ uses:
pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/.github/workflows/zizmor.yml
new/uhashring-2.5/.github/workflows/zizmor.yml
--- old/uhashring-2.4/.github/workflows/zizmor.yml 1970-01-01
01:00:00.000000000 +0100
+++ new/uhashring-2.5/.github/workflows/zizmor.yml 2026-08-03
21:33:23.000000000 +0200
@@ -0,0 +1,26 @@
+name: GitHub Actions Security Analysis with zizmor
+
+on:
+ push:
+ branches: ["master"]
+ pull_request:
+ branches: ["**"]
+
+permissions: {}
+
+jobs:
+ zizmor:
+ name: Run zizmor
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write # Required for upload-sarif (used by
zizmor-action) to upload SARIF files.
+ contents: read # Only needed for private repos. Needed to clone
the repo.
+ actions: read # Only needed for private repos. Needed for
upload-sarif to read workflow run info.
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #
v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Run zizmor
+ uses:
zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/CHANGELOG.md
new/uhashring-2.5/CHANGELOG.md
--- old/uhashring-2.4/CHANGELOG.md 2025-04-09 16:54:50.000000000 +0200
+++ new/uhashring-2.5/CHANGELOG.md 2026-08-03 21:33:23.000000000 +0200
@@ -1,5 +1,16 @@
# CHANGELOG
+## version 2.5
+* security: pypi trusted publisher
+* codeql: add workflow and dependabot
+* bump hatch required version for pypi metadata
+* fix: track MetaRing per-node vnodes to prevent stale ring entries
+* fix: handle vnode hash collisions in MetaRing ring maintenance
+* ci(python): drop 3.9 EOL version and test each matrix version
+* chore: drop leftover Python 2 compatibility code
+* refactor: align KetamaRing._remove_node with MetaRing
+* ci(pypy): move to PyPy 3.11
+
## version 2.4
* update and ship license in dist files, by Byron Sakiadis
* support for python 3.12
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/MANIFEST.in
new/uhashring-2.5/MANIFEST.in
--- old/uhashring-2.4/MANIFEST.in 2025-04-09 16:54:50.000000000 +0200
+++ new/uhashring-2.5/MANIFEST.in 1970-01-01 01:00:00.000000000 +0100
@@ -1 +0,0 @@
-include LICENSE README.rst tox.ini
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/Makefile new/uhashring-2.5/Makefile
--- old/uhashring-2.4/Makefile 2025-04-09 16:54:50.000000000 +0200
+++ new/uhashring-2.5/Makefile 2026-08-03 21:33:23.000000000 +0200
@@ -1,3 +1,5 @@
+PY ?= py310
+
qa:
hatch run style:check
@@ -11,4 +13,4 @@
hatch publish -u __token__
test:
- hatch run +py=py310 test:test
+ hatch run +py=$(PY) test:test
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/README.md new/uhashring-2.5/README.md
--- old/uhashring-2.4/README.md 2025-04-09 16:54:50.000000000 +0200
+++ new/uhashring-2.5/README.md 2026-08-03 21:33:23.000000000 +0200
@@ -306,22 +306,17 @@
> There is a big performance gap in the hash calculation between the
> ketama C binding and its pure python counterpart.
>
-> Python 3 is doing way better than python 2 thanks to its native
-> bytes/int representation.
->
> ***Quick benchmark, for 1 million generated ketama compatible keys:***
> - python_ketama C binding: 0.8427069187164307 s
-> - python 2: 5.462762832641602 s
> - python 3: 3.570068597793579 s
> - pypy: 1.6146340370178223 s
>
-> When using python 2 and ketama compatibility is not important, you can
-> get a better hashing speed using the other provided hashing.
+> When ketama compatibility is not important, the default hashing is
+> faster: just omit the `hash_fn="ketama"` argument.
>
-> hr = HashRing(nodes=[], compat=False)
+> hr = HashRing(nodes=[])
>
> ***Quick benchmark, for 1 million generated hash keys:***
-> - python 2: 3.7595579624176025 s
> - python 3: 3.268343687057495 s
> - pypy: 1.9193649291992188 s
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/pyproject.toml
new/uhashring-2.5/pyproject.toml
--- old/uhashring-2.4/pyproject.toml 2025-04-09 16:54:50.000000000 +0200
+++ new/uhashring-2.5/pyproject.toml 2026-08-03 21:33:23.000000000 +0200
@@ -1,5 +1,5 @@
[build-system]
-requires = ["hatchling"]
+requires = ["hatchling>=1.26.1"]
build-backend = "hatchling.build"
[project]
@@ -8,7 +8,7 @@
description = "Full featured consistent hashing python library compatible with
ketama."
readme = "README.md"
license = "BSD-3-Clause"
-requires-python = ">=3.8"
+requires-python = ">=3.10"
authors = [
{ name = "Ultrabug", email = "[email protected]" },
]
@@ -20,7 +20,6 @@
"Operating System :: POSIX :: Linux",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
@@ -62,7 +61,7 @@
]
[[tool.hatch.envs.test.matrix]]
-python = ["py39", "py310", "py311", "py312", "py313", "pypy3"]
+python = ["py310", "py311", "py312", "py313", "pypy3"]
type = ["default"]
[tool.hatch.envs.style]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/tests/benchmark.py
new/uhashring-2.5/tests/benchmark.py
--- old/uhashring-2.4/tests/benchmark.py 2025-04-09 16:54:50.000000000
+0200
+++ new/uhashring-2.5/tests/benchmark.py 2026-08-03 21:33:23.000000000
+0200
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""This is not part of the test suite.
"""
try:
@@ -33,7 +32,7 @@
nodes={"127.0.0.1:11211": 600, "127.0.0.1:11212": 400},
replicas=4,
vnodes=40,
- compat=True,
+ hash_fn="ketama",
)
pt = time()
for i in range(num):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/tests/benchmark_hashes.py
new/uhashring-2.5/tests/benchmark_hashes.py
--- old/uhashring-2.4/tests/benchmark_hashes.py 2025-04-09 16:54:50.000000000
+0200
+++ new/uhashring-2.5/tests/benchmark_hashes.py 2026-08-03 21:33:23.000000000
+0200
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""This is not part of the test suite.
"""
from hashlib import md5, sha1, sha256
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/tests/test_distribution.py
new/uhashring-2.5/tests/test_distribution.py
--- old/uhashring-2.4/tests/test_distribution.py 2025-04-09
16:54:50.000000000 +0200
+++ new/uhashring-2.5/tests/test_distribution.py 2026-08-03
21:33:23.000000000 +0200
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""This test is vastly inspired by Mike Bayer's article:
http://techspot.zzzeek.org/2012/07/07/the-absolutely-simplest-consistent-hashing-example/
"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/tests/test_implementation.py
new/uhashring-2.5/tests/test_implementation.py
--- old/uhashring-2.4/tests/test_implementation.py 2025-04-09
16:54:50.000000000 +0200
+++ new/uhashring-2.5/tests/test_implementation.py 2026-08-03
21:33:23.000000000 +0200
@@ -1,17 +1,14 @@
-# -*- coding: utf-8 -*-
"""
"""
-import sys
import types
from collections import Counter
+from operator import itemgetter
from uuid import uuid4
import pytest
from uhashring import HashRing
-PY3 = sys.version_info >= (3,)
-
@pytest.fixture
def ring():
@@ -21,7 +18,7 @@
@pytest.fixture
def ring_fast():
- ring = HashRing(nodes={"node1": 1, "node2": 1, "node3": 1}, compat=False)
+ ring = HashRing(nodes={"node1": 1, "node2": 1, "node3": 1})
return ring
@@ -79,23 +76,20 @@
assert h == 12707736894140473154801792860916528374
-# XXX https://docs.python.org/3/whatsnew/3.0.html#ordering-comparisons
[email protected](PY3, reason="requires python26, python27")
def test_range(ring):
- r = list(ring.range("test"))
- r.sort()
- c = ring.conf.values()
- c.sort()
- assert r == c
+ r = sorted(ring.range("test"), key=itemgetter("nodename"))
+ assert r == sorted(ring.conf.values(), key=itemgetter("nodename"))
r = list(ring.range("test", size=2, unique=True))
assert len(r) == 2
+ assert len({node["nodename"] for node in r}) == 2
r = list(ring.range("test", size=2, unique=False))
assert len(r) == 2
- r = list(ring.range("1800")) # 1800 is at position 0
- r = list(ring.range("849")) # 849 is at position -2
+ # both keys make the generator wrap around the end of the ring
+ assert len(list(ring.range("1800"))) == 3 # 1800 is at position 0
+ assert len(list(ring.range("849"))) == 3 # 849 is at position -2
r = list(ring.range("test", size=None, unique=False))
assert len(r) == ring.size
@@ -135,7 +129,7 @@
assert isinstance(ring.get("test"), dict)
assert isinstance(ring.get_instances(), list)
assert isinstance(ring.get_node("test"), str)
- assert isinstance(ring.get_nodes(), type({}.keys()) if PY3 else list)
+ assert isinstance(ring.get_nodes(), type({}.keys()))
assert isinstance(ring.get_node_hostname("test"), str)
assert isinstance(ring.get_node_port("test"), type(None))
assert isinstance(ring.get_node_pos("test"), int)
@@ -171,7 +165,7 @@
assert ring.distribution["node2"] == 160
assert ring.distribution["node3"] == 240
- ring.regenerate
+ ring.regenerate()
assert ring.distribution["node1"] == 80
assert ring.distribution["node2"] == 160
@@ -207,7 +201,7 @@
def test_ring_growth_meta(ring_fast):
- add_ring = HashRing(compat=False)
+ add_ring = HashRing()
for nodename in ring_fast.nodes:
add_ring.add_node(nodename)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/tests/test_ketama_compatibility.py
new/uhashring-2.5/tests/test_ketama_compatibility.py
--- old/uhashring-2.4/tests/test_ketama_compatibility.py 2025-04-09
16:54:50.000000000 +0200
+++ new/uhashring-2.5/tests/test_ketama_compatibility.py 2026-08-03
21:33:23.000000000 +0200
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""ketama is not released under pypi !
This test is only run on my dev machine and is not really part of the CI.
"""
@@ -45,7 +44,7 @@
nodes={"127.0.0.1:11211": 600, "127.0.0.1:11212": 400},
replicas=4,
vnodes=40,
- compat=True,
+ hash_fn="ketama",
)
continuum = ketama.Continuum(ketama_config_file)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/tests/test_metaring.py
new/uhashring-2.5/tests/test_metaring.py
--- old/uhashring-2.4/tests/test_metaring.py 1970-01-01 01:00:00.000000000
+0100
+++ new/uhashring-2.5/tests/test_metaring.py 2026-08-03 21:33:23.000000000
+0200
@@ -0,0 +1,158 @@
+"""Regression tests for MetaRing stale-vnodes bugs.
+
+MetaRing._create_ring() used to be purely additive: it appended vnodes into
+the existing `_ring`/`_distribution` and never dropped a node's previous
+points. This produced three observable bugs:
+
+1. Re-adding an existing node (e.g. to change its weight) doubled up its
+ old points on top of the new ones, corrupting `_distribution` and
+ `ring.size`.
+2. Lowering a node's weight then removing it left orphan `_ring` entries
+ (created under the old, higher weight) pointing at a node that no
+ longer exists, crashing lookups with a KeyError.
+3. `HashRing.regenerate()` called `_create_ring` again on top of the
+ existing points, doubling every node's count instead of being
+ idempotent, and could never reflect a weight decrease.
+"""
+
+from collections import Counter
+
+import pytest
+
+from uhashring import HashRing
+
+
+def test_readd_existing_node_fixes_distribution():
+ """Bug 1: re-adding a node with a different weight must not double-count
+ its previously created points."""
+ hr = HashRing(nodes=["node1", "node2"])
+ assert hr.distribution == Counter({"node1": 160, "node2": 160})
+
+ hr.add_node("node1", {"weight": 10})
+
+ assert hr.distribution == Counter({"node1": 1600, "node2": 160})
+ assert hr.size == 1760
+ assert hr.size == len(hr._keys) == len(hr._ring)
+
+
+def test_remove_node_after_weight_decrease_leaves_no_orphan_entries():
+ """Bug 2: decreasing a node's weight then removing it must not leave
+ stale ring entries (created under the old weight) pointing at the
+ removed node."""
+ hr = HashRing(nodes={"node1": 10, "node2": 1})
+ hr.add_node("node1", {"weight": 1})
+ hr.remove_node("node1")
+
+ assert "node1" not in hr._nodes
+ assert "node1" not in hr.distribution
+ assert set(hr._ring.values()) == {"node2"}
+
+ # lookups over a decent spread of keys must succeed and only ever
+ # return the still-live node.
+ for i in range(1000):
+ assert hr.get_node(str(i)) == "node2"
+
+
+def test_regenerate_is_idempotent():
+ """Bug 3: regenerate() must not double the distribution when called
+ again with unchanged node configuration."""
+ hr = HashRing(nodes=["node1", "node2"])
+ before = Counter(hr.distribution)
+
+ hr.regenerate()
+
+ assert hr.distribution == before
+ assert hr.distribution == Counter({"node1": 160, "node2": 160})
+
+ hr.regenerate()
+
+ assert hr.distribution == before
+
+
+def test_regenerate_shrinks_point_count_with_weight_fn():
+ """Bug 3: regenerate() must be able to SHRINK a node's point count when
+ weight_fn now returns a smaller weight than before -- the exact use
+ case documented in the README."""
+ weights = {"node1": 10}
+
+ def weight_fn(**conf):
+ return weights.get(conf["nodename"], 1)
+
+ hr = HashRing(nodes=["node1", "node2"], weight_fn=weight_fn)
+ assert hr.distribution == Counter({"node1": 1600, "node2": 160})
+
+ weights["node1"] = 1
+ hr.regenerate()
+
+ assert hr.distribution == Counter({"node1": 160, "node2": 160})
+ assert hr.size == 320
+ assert hr.size == len(hr._keys) == len(hr._ring)
+
+
+def test_add_then_remove_node_restores_original_state():
+ """Guard: adding a node and then removing it again must return the ring
+ to exactly its original state."""
+ hr = HashRing(nodes=["node1", "node2"])
+ original_ring = dict(hr._ring)
+ original_keys = list(hr._keys)
+ original_distribution = Counter(hr.distribution)
+
+ hr.add_node("node3", {"weight": 3})
+ hr.remove_node("node3")
+
+ assert hr._ring == original_ring
+ assert hr._keys == original_keys
+ assert hr.distribution == original_distribution
+
+
+def test_remove_unknown_node_raises_keyerror():
+ """Guard: removing a node that was never added must still raise
+ KeyError."""
+ hr = HashRing(nodes=["node1", "node2"])
+
+ with pytest.raises(KeyError):
+ hr.remove_node("does-not-exist")
+
+
+def test_readd_node_after_collision_and_removal_does_not_crash():
+ """Regression: when two nodes' vnodes hash to the same ring point,
+ `_ring` only ever holds the last writer, but both nodes still record
+ that point in `_node_points`. Removing the winning node deletes the
+ shared point from `_ring`; recreating the losing node's points must
+ then tolerate that its recorded points are no longer (or no longer
+ solely) present in `_ring`, instead of raising KeyError."""
+
+ def colliding_hash(key):
+ return int(key.rsplit("-", 1)[1])
+
+ hr = HashRing(nodes=["A"], hash_fn=colliding_hash, vnodes=3)
+ hr["B"] = {"vnodes": 3} # B's points 0,1,2 collide with A's; B wins in
_ring
+ del hr["B"] # _ring becomes {}
+
+ # must not raise KeyError due to A's stale, now-emptied points
+ hr["A"] = {"weight": 2}
+
+
+def test_remove_node_is_atomic_when_internal_state_is_desynced():
+ """Regression: `_remove_node` must resolve/validate both the `_nodes`
+ and `_node_points` lookups before mutating any state. If they ever
+ disagree, the removal must be all-or-nothing -- not pop `_nodes` and
+ then blow up reporting the node as "not found" despite having just
+ removed it."""
+ hr = HashRing(nodes=["node1", "node2"])
+ runtime = hr.runtime
+ # simulate an internal desync: "node2" known to _nodes but missing
+ # from _node_points.
+ del runtime._node_points["node2"]
+
+ original_nodes = dict(runtime._nodes)
+ original_distribution = Counter(runtime._distribution)
+ original_ring = dict(runtime._ring)
+
+ with pytest.raises(KeyError):
+ runtime._remove_node("node2")
+
+ # all-or-nothing: nothing was mutated by the failed removal
+ assert runtime._nodes == original_nodes
+ assert runtime._distribution == original_distribution
+ assert runtime._ring == original_ring
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/tests/test_monkey.py
new/uhashring-2.5/tests/test_monkey.py
--- old/uhashring-2.4/tests/test_monkey.py 2025-04-09 16:54:50.000000000
+0200
+++ new/uhashring-2.5/tests/test_monkey.py 2026-08-03 21:33:23.000000000
+0200
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/uhashring/__init__.py
new/uhashring-2.5/uhashring/__init__.py
--- old/uhashring-2.4/uhashring/__init__.py 2025-04-09 16:54:50.000000000
+0200
+++ new/uhashring-2.5/uhashring/__init__.py 2026-08-03 21:33:23.000000000
+0200
@@ -1,4 +1,4 @@
from uhashring.ring import HashRing
__all__ = ["HashRing", "monkey"]
-__version__ = "2.4"
+__version__ = "2.5"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/uhashring/ring.py
new/uhashring-2.5/uhashring/ring.py
--- old/uhashring-2.4/uhashring/ring.py 2025-04-09 16:54:50.000000000 +0200
+++ new/uhashring-2.5/uhashring/ring.py 2026-08-03 21:33:23.000000000 +0200
@@ -305,6 +305,14 @@
break
def regenerate(self):
+ """Regenerate the ring from the current nodes configuration.
+
+ Useful only when using `weight_fn`: re-evaluates it for every node
+ (preserving each node's other configuration) before rebuilding the
+ ring, so a `weight_fn` whose output changed over time (e.g. based
+ on external, mutable state) is reflected -- including a decrease.
+ """
+ self._configure_nodes(dict(self.runtime._nodes))
self.runtime._create_ring(self.runtime._nodes.items())
@property
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/uhashring/ring_ketama.py
new/uhashring-2.5/uhashring/ring_ketama.py
--- old/uhashring-2.4/uhashring/ring_ketama.py 2025-04-09 16:54:50.000000000
+0200
+++ new/uhashring-2.5/uhashring/ring_ketama.py 2026-08-03 21:33:23.000000000
+0200
@@ -14,11 +14,9 @@
self._replicas = replicas
self._ring = {}
- self._listbytes = lambda x: x
-
def hashi(self, key, replica=0):
"""Returns a ketama compatible hash from the given key."""
- dh = self._listbytes(md5(str(key).encode("utf-8")).digest())
+ dh = md5(str(key).encode("utf-8")).digest()
rd = replica * 4
return (dh[3 + rd] << 24) | (dh[2 + rd] << 16) | (dh[1 + rd] << 8) |
dh[0 + rd]
@@ -34,14 +32,6 @@
for i in range(0, self._replicas):
yield self.hashi(w_node_name, replica=i)
- @staticmethod
- def _listbytes(data):
- """Python 2 compatible int iterator from str.
-
- :param data: the string to int iterate upon.
- """
- return map(ord, data)
-
def _create_ring(self, nodes):
"""Generate a ketama compatible continuum/ring."""
_weight_sum = 0
@@ -66,11 +56,9 @@
:param node_name: the node name.
"""
- try:
- self._nodes.pop(node_name)
- except Exception:
+ if node_name not in self._nodes:
raise KeyError(
"node '{}' not found, available nodes: {}".format(node_name,
self._nodes.keys())
)
- else:
- self._create_ring(self._nodes)
+ self._nodes.pop(node_name)
+ self._create_ring(self._nodes)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/uhashring-2.4/uhashring/ring_meta.py
new/uhashring-2.5/uhashring/ring_meta.py
--- old/uhashring-2.4/uhashring/ring_meta.py 2025-04-09 16:54:50.000000000
+0200
+++ new/uhashring-2.5/uhashring/ring_meta.py 2026-08-03 21:33:23.000000000
+0200
@@ -12,6 +12,7 @@
"""
self._distribution = Counter()
self._keys = []
+ self._node_points = {}
self._nodes = {}
self._ring = {}
@@ -24,11 +25,34 @@
return self._hash_fn(key)
def _create_ring(self, nodes):
- """Generate a ketama compatible continuum/ring."""
+ """Generate a ketama compatible continuum/ring.
+
+ Idempotent per node: any points previously created for a node are
+ dropped before its fresh points are added, so calling this again
+ (e.g. to change a node's weight, or via `regenerate()`) never
+ double-counts or leaves stale entries behind.
+ """
for node_name, node_conf in nodes:
+ old_points = self._node_points.pop(node_name, None)
+ if old_points:
+ for point in old_points:
+ # Two nodes' vnodes can collide on the same ring point;
+ # `_ring` then only holds the last writer even though
+ # both nodes recorded the point in `_node_points`. Only
+ # drop the point if this node still actually owns it,
+ # so a collision can never crash this cleanup nor evict
+ # another node's live entry.
+ if self._ring.get(point) == node_name:
+ del self._ring[point]
+ self._distribution.pop(node_name, None)
+
+ new_points = []
for w in range(0, node_conf["vnodes"] * node_conf["weight"]):
self._distribution[node_name] += 1
- self._ring[self.hashi(f"{node_name}-{w}")] = node_name
+ point = self.hashi(f"{node_name}-{w}")
+ self._ring[point] = node_name
+ new_points.append(point)
+ self._node_points[node_name] = new_points
self._keys = sorted(self._ring.keys())
def _remove_node(self, node_name):
@@ -36,14 +60,22 @@
:param node_name: the node name.
"""
- try:
- node_conf = self._nodes.pop(node_name)
- except Exception:
+ if node_name not in self._nodes:
raise KeyError(
"node '{}' not found, available nodes: {}".format(node_name,
self._nodes.keys())
)
- else:
- self._distribution.pop(node_name)
- for w in range(0, node_conf["vnodes"] * node_conf["weight"]):
- del self._ring[self.hashi(f"{node_name}-{w}")]
- self._keys = sorted(self._ring.keys())
+ # Resolve both lookups before mutating any state: the removal must
+ # be all-or-nothing, otherwise a desync between `_nodes` and
+ # `_node_points` would leave `_nodes` already mutated while still
+ # raising an error that falsely claims the node was never found.
+ points = self._node_points[node_name]
+
+ self._nodes.pop(node_name)
+ self._node_points.pop(node_name)
+ self._distribution.pop(node_name)
+ for point in points:
+ # See the matching guard in `_create_ring`: only drop a point
+ # this node still owns, in case of a hash collision.
+ if self._ring.get(point) == node_name:
+ del self._ring[point]
+ self._keys = sorted(self._ring.keys())