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())

Reply via email to