[med-svn] [Git][med-team/python-cogent][master] 3 commits: New upstream version 2026.10.15

Karsten Schöke (@karso) gitlab at salsa.debian.org
Mon Oct 19 12:35:51 BST 2026



Karsten Schöke pushed to branch master at Debian Med / python-cogent


Commits:
3606e8d9 by Karsten Schöke at 2026-10-19T13:15:36+02:00
New upstream version 2026.10.15
- - - - -
4577e3ef by Karsten Schöke at 2026-10-19T13:17:38+02:00
Update upstream source from tag 'upstream/2026.10.15'

Update to upstream version '2026.10.15'
with Debian dir fa6be6f3a1cb9da74663bf0206d711fdbe58317b

- - - - -
caca804b by Karsten Schöke at 2026-10-19T13:34:08+02:00
Delete the upstream-implemented test-init-mkdir.patch

- - - - -


24 changed files:

- .github/dependabot.yml
- .github/workflows/codeql.yml
- .github/workflows/release.yml
- .github/workflows/testing_develop.yml
- changelog.md
- debian/changelog
- debian/patches/series
- − debian/patches/test-init-mkdir.patch
- noxfile.py
- pyproject.toml
- src/cogent3/align/pairwise.py
- src/cogent3/core/alignment.py
- src/cogent3/core/alphabet.py
- src/cogent3/core/moltype.py
- src/cogent3/core/sequence.py
- src/cogent3/util/union_dict.py
- tests/test_app/test_evo.py
- tests/test_app/test_init.py
- tests/test_app/test_io.py
- tests/test_app/test_sqlite_data_store.py
- tests/test_core/test_annotation_db.py
- tests/test_evolve/test_scale_rules.py
- tests/test_util/test_union_dict.py
- uv.lock


Changes:

=====================================
.github/dependabot.yml
=====================================
@@ -1,6 +1,6 @@
 version: 2
 updates:
-  - package-ecosystem: pip
+  - package-ecosystem: uv
     directory: "/"
     schedule:
       interval: weekly
@@ -8,6 +8,10 @@ updates:
     open-pull-requests-limit: 10
     cooldown:
       default-days: 7
+    ignore:
+      # quartodoc passes parse_numpy an argument that griffe 2 removed
+      - dependency-name: griffe
+        update-types: ["version-update:semver-major"]
   - package-ecosystem: "github-actions"
     directory: "/"
     schedule:


=====================================
.github/workflows/codeql.yml
=====================================
@@ -45,7 +45,7 @@ jobs:
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init at cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+      uses: github/codeql-action/init at 24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
       with:
         languages: ${{ matrix.language }}
         # If you wish to specify custom queries, you can do so here or in a config file.
@@ -59,7 +59,7 @@ jobs:
     # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
     # If this step fails, then you should remove it and run the build manually (see below)
     - name: Autobuild
-      uses: github/codeql-action/autobuild at cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+      uses: github/codeql-action/autobuild at 24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
 
     # ℹ️ Command-line programs to run using the OS shell.
     # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -72,6 +72,6 @@ jobs:
     #   ./location_of_script_within_repo/buildscript.sh
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze at cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+      uses: github/codeql-action/analyze at 24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
       with:
         category: "/language:${{matrix.language}}"


=====================================
.github/workflows/release.yml
=====================================
@@ -13,7 +13,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.11", "3.12", "3.13", "3.14"]
+        python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
     steps:
       - uses: "actions/checkout at 3d3c42e5aac5ba805825da76410c181273ba90b1" # v7.0.1
         with:
@@ -26,20 +26,15 @@ jobs:
             python-version: "${{ matrix.python-version }}"
 
       - name: Install uv
-        uses: astral-sh/setup-uv at 20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+        uses: astral-sh/setup-uv at c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
         with:
           enable-cache: true
           cache-dependency-glob: "pyproject.toml"
 
-      - name: "Installs for ${{ matrix.python-version }}"
-        run: |
-          uv venv venv -p python${{ matrix.python-version }}
-          uv tool install -p venv nox
-
       - name: "Run nox for ${{ matrix.python-version }}"
         shell: bash
         run: |
-          nox -db uv --force-python python -s test
+          uv run --locked -p python${{ matrix.python-version }} --only-group test nox -db uv --force-python python -s test
 
 
   docbuild:
@@ -58,7 +53,7 @@ jobs:
             python-version: "3.13"
 
       - name: Install uv
-        uses: astral-sh/setup-uv at 20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+        uses: astral-sh/setup-uv at c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
         with:
           enable-cache: true
           cache-dependency-glob: "pyproject.toml"
@@ -66,13 +61,8 @@ jobs:
       - name: Install quarto
         uses: quarto-dev/quarto-actions/setup at 8a96df13519ee81fd526f2dfca5962811136661b # v2.2.0
 
-      - name: "Installs for ${{ matrix.python-version }}"
-        run: |
-          uv venv venv -p python${{ matrix.python-version }}
-          uv tool install -p venv nox
-
       - name: "test the docs code"
-        run: "nox -db uv --force-python python -s testdocs"
+        run: "uv run --locked -p python3.13 --only-group test nox -db uv --force-python python -s testdocs"
 
   build:
     name: Build wheel and sdist
@@ -100,7 +90,7 @@ jobs:
         run: python -m build --wheel --sdist
 
       - name: Upload sdist and wheel
-        uses: actions/upload-artifact at 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        uses: actions/upload-artifact at cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
         with:
           name: c3-wheel-sdist
           path: |
@@ -117,7 +107,7 @@ jobs:
 
     steps:
       - name: Download sdist and wheel
-        uses: actions/download-artifact at 3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        uses: actions/download-artifact at 9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
         with:
           name: c3-wheel-sdist
           path: ./dist
@@ -137,7 +127,7 @@ jobs:
 
     steps:
       - name: Download sdist and wheel
-        uses: actions/download-artifact at 3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        uses: actions/download-artifact at 9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
         with:
           name: c3-wheel-sdist
           path: ./dist


=====================================
.github/workflows/testing_develop.yml
=====================================
@@ -24,7 +24,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.11", "3.14"]
+        python-version: ["3.11", "3.14", "3.15"]
 
     steps:
       - uses: "actions/checkout at 3d3c42e5aac5ba805825da76410c181273ba90b1" # v7.0.1
@@ -38,22 +38,17 @@ jobs:
             python-version: "${{ matrix.python-version }}"
 
       - name: Install uv
-        uses: astral-sh/setup-uv at 20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+        uses: astral-sh/setup-uv at c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
         with:
           enable-cache: true
           cache-dependency-glob: "pyproject.toml"
 
-      - name: "Installs for ${{ matrix.python-version }}"
-        run: |
-          uv venv venv -p python${{ matrix.python-version }}
-          uv tool install -p venv nox
-
       - name: "Run nox for ${{ matrix.python-version }}"
         shell: bash
         run: |
           dn=$(pwd)
           cov="--cov-report lcov:lcov-${{matrix.os}}-${{matrix.python-version}}.lcov --cov-report term --cov-append --cov cogent3 --cov-config=${dn}/.coveragerc"
-          nox  -db uv --force-python python -s test -- $cov
+          uv run --locked -p python${{ matrix.python-version }} --only-group test nox -db uv --force-python python -s test -- $cov
 
       - name: Coveralls Parallel
         uses: coverallsapp/github-action at 8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8
@@ -89,16 +84,13 @@ jobs:
           python-version: "${{ matrix.python-version }}"
 
       - name: Install uv
-        uses: astral-sh/setup-uv at 20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+        uses: astral-sh/setup-uv at c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
         with:
           enable-cache: true
           cache-dependency-glob: "pyproject.toml"
 
       - name: "Run Type Checking for ${{ matrix.python-version }}"
-        run: |
-          uv venv venv -p python${{ matrix.python-version }}
-          uv pip install -p venv -e . --group typing
-          venv/bin/mypy src/cogent3/core/alignment.py src/cogent3/core/alphabet.py src/cogent3/core/annotation.py src/cogent3/core/genetic_code.py src/cogent3/core/location.py src/cogent3/core/moltype.py src/cogent3/core/seq_storage.py src/cogent3/core/sequence.py src/cogent3/core/seqview.py src/cogent3/core/slice_record.py src/cogent3/core/tree.py
+        run: uv run --locked -p python${{ matrix.python-version }} --only-group test nox -db uv --force-python python -s type_check
 
 
   finish:


=====================================
changelog.md
=====================================
@@ -1,4 +1,33 @@
 
+<a id='changelog-2026.10.15'></a>
+# Changes in release "2026.10.15"
+
+Minor bug fix release plus bumping py version.
+
+## Contributors
+
+- @GavinHuttley
+
+## Enhancements
+
+- Added support for Python 3.15 and numpy 2.5. The optional `cogent3-h5seqs`
+  dependency is installed only on Python 3.14 and below (for now), and
+  `piqtree` only on platforms where it publishes wheels.
+
+## Bug fixes
+
+- `UnionDict` merging no longer loops forever when both sides share a nested
+  dict two or more levels down, e.g.
+  `{"d": {"e": {"k": 1}}} | {"d": {"e": {"m": 2}}}`. Nested dicts are now
+  copied on construction and assignment, so `|` leaves both operands
+  untouched and keys can no longer share a child dict. A dict that contains
+  itself now raises `ValueError` instead of `RecursionError`.
+
+## Documentation
+
+- Removed code snippets and references to modules deleted in 2026.9.10,
+  including `cogent3.util.io`.
+
 <a id='changelog-2026.9.10'></a>
 # Changes in release "2026.9.10"
 


=====================================
debian/changelog
=====================================
@@ -1,3 +1,11 @@
+python-cogent (2026.10.15-1) UNRELEASED; urgency=medium
+
+  * Team upload.
+  * New upstream version 2026.10.15
+  * Delete the upstream-implemented test-init-mkdir.patch
+
+ -- Karsten Schöke <karsten.schoeke at geobasis-bb.de>  Mon, 19 Oct 2026 13:25:37 +0200
+
 python-cogent (2026.9.10-1) unstable; urgency=medium
 
   * Team upload.


=====================================
debian/patches/series
=====================================
@@ -1,3 +1,2 @@
 fix-lazy-imports-for-Sphinx-autodoc-builds.patch
 Fix-broken-interpreter-lines.patch
-test-init-mkdir.patch


=====================================
debian/patches/test-init-mkdir.patch deleted
=====================================
@@ -1,37 +0,0 @@
-From: =?utf-8?q?Karsten_Sch=C3=B6ke?= <karsten.schoeke at geobasis-bb.de>
-Date: Wed, 14 Oct 2026 09:28:08 +0200
-Subject: create missing temp directory in the app composables test helper
- The tests/test_app/test_init.py helper _get_all_composables() passes a path
- like <tmp>/delme to open_data_store(..., mode="w") expecting a new sqlite
- data store to be created at <tmp>/delme/delme.sqlitedb.
- .
- The scinexus version shipped in Debian (python3-scinexus 2026.10.2) does not
- create parent directories when opening a fresh sqlite data store
- (sqlite_data_store.py contains no mkdir call). sqlite itself refuses to open
- a database whose parent directory does not exist, so the whole test module
- fails during collection with:
- .
-     sqlite3.OperationalError: unable to open database file
- .
- Create the temporary subdirectory in the test helper before any of the
- data-store backed apps (write_db, write_json, write_seqs) are instantiated.
- This is a test-harness only change and does not alter runtime behaviour.
-
-Forwarded: not-needed 
----
- tests/test_app/test_init.py | 2 ++
- 1 file changed, 2 insertions(+)
-
-diff --git a/tests/test_app/test_init.py b/tests/test_app/test_init.py
-index 2fcfb65..498002a 100644
---- a/tests/test_app/test_init.py
-+++ b/tests/test_app/test_init.py
-@@ -17,6 +17,8 @@ except ImportError:
- 
- def _get_all_composables(tmp_dir_name):
-     tmp_dir_name = Path(tmp_dir_name)
-+    # Debian: scinexus does not create parent dirs for a new sqlite store
-+    tmp_dir_name.mkdir(parents=True, exist_ok=True)
-     test_model1 = get_app("model", "HKY85")
-     test_model2 = get_app("model", "GN")
-     test_hyp = get_app("hypothesis", test_model1, test_model2)


=====================================
noxfile.py
=====================================
@@ -8,7 +8,21 @@ import nox
 if sys.version_info >= (3, 12):
     os.environ["COVERAGE_CORE"] = "sysmon"
 
-_py_versions = range(11, 15)
+_py_versions = range(11, 16)
+
+_typed_modules = [
+    "src/cogent3/core/alignment.py",
+    "src/cogent3/core/alphabet.py",
+    "src/cogent3/core/annotation.py",
+    "src/cogent3/core/genetic_code.py",
+    "src/cogent3/core/location.py",
+    "src/cogent3/core/moltype.py",
+    "src/cogent3/core/seq_storage.py",
+    "src/cogent3/core/sequence.py",
+    "src/cogent3/core/seqview.py",
+    "src/cogent3/core/slice_record.py",
+    "src/cogent3/core/tree.py",
+]
 
 
 @nox.session(python=False)
@@ -17,7 +31,7 @@ def fmt(session: nox.Session) -> None:
     session.run("ruff", "format", ".", external=True)
 
 
- at nox.session(python=[f"3.{v}" for v in _py_versions])
+ at nox.session(python=[f"3.{v}" for v in _py_versions], venv_backend="uv")
 def test_slow(session):
     session.install("-e", ".", "--group", "test")
     session.chdir("tests")
@@ -28,10 +42,10 @@ def test_slow(session):
     )
 
 
- at nox.session(python=[f"3.{v}" for v in _py_versions])
+ at nox.session(python=[f"3.{v}" for v in _py_versions], venv_backend="uv")
 def test(session):
     session.install("-e", ".", "--group", "test")
-    session.run("pip", "list")
+    session.run("uv", "pip", "list", external=True)
     # doctest modules within cogent3/app
     session.chdir("src/cogent3/app")
     session.run(
@@ -39,8 +53,6 @@ def test(session):
         "-s",
         "-x",
         "--doctest-modules",
-        "--ignore=composable.py",
-        "--ignore=sqlite_data_store.py",
         ".",
     )
 
@@ -55,7 +67,13 @@ def test(session):
     )
 
 
- at nox.session(python=[f"3.{v}" for v in _py_versions])
+ at nox.session(python="3.14", venv_backend="uv")
+def type_check(session):
+    session.install("-e", ".", "--group", "typing")
+    session.run("mypy", *_typed_modules)
+
+
+ at nox.session(python=[f"3.{v}" for v in _py_versions], venv_backend="uv")
 def test_module_docs(session):
     """doctest examples in a module"""
     session.install("-e", ".", "--group", "test")
@@ -71,7 +89,7 @@ def test_module_docs(session):
     )
 
 
- at nox.session(python=[f"3.{v}" for v in _py_versions])
+ at nox.session(python=[f"3.{v}" for v in _py_versions], venv_backend="uv")
 def testdocs(session):
     """render the docs, which executes every code cell"""
     py = pathlib.Path(session.bin_paths[0]) / "python"


=====================================
pyproject.toml
=====================================
@@ -5,7 +5,7 @@ build-backend = "uv_build"
 [project]
 name = "cogent3"
 description = """COmparative GENomics Toolkit 3: genomic sequence analysis within notebooks or on compute systems with 1000s of CPUs."""
-version = "2026.9.10"
+version = "2026.10.15"
 
 authors = [{ name = "Gavin Huttley", email = "Gavin.Huttley at anu.edu.au" }]
 keywords = [
@@ -22,23 +22,23 @@ license-files = ["LICENSE"]
 dependencies = [
     "charset_normalizer",
     "citeable",
-    # prevent release candidate of numpy 2.4
-    # until numba modified to handle removal
-    # of numpy.trapz
-    "numpy<2.5",
+    # numba lags numpy releases, so cap numpy at the newest
+    # minor version that numba supports
+    "numpy<2.6",
     "numba>=0.57.0; python_version=='3.11'",
     "numba>=0.59.0; python_version=='3.12'",
     "numba>=0.61.0; python_version=='3.13'",
     "numba>=0.63.0rc1; python_version=='3.14'",
+    "numba>=0.68.0; python_version=='3.15'",
     "scipy",
-    "scinexus[loky]>=2026.5.16",
+    "scinexus[loky]>=2026.10.2",
     "stevedore",
     "tqdm",
     "typeguard>=4.0,<5",
     "typing_extensions",
 ]
 # remember to update version in requires-python and classifiers
-requires-python = ">=3.11,<3.15"
+requires-python = ">=3.11,<3.16"
 classifiers = [
     "Development Status :: 5 - Production/Stable",
     "Intended Audience :: Science/Research",
@@ -49,6 +49,7 @@ classifiers = [
     "Programming Language :: Python :: 3.12",
     "Programming Language :: Python :: 3.13",
     "Programming Language :: Python :: 3.14",
+    "Programming Language :: Python :: 3.15",
 ]
 
 [project.urls]
@@ -73,9 +74,13 @@ extra = [
 
 [dependency-groups]
 test = [
-    "cogent3-h5seqs>=0.8.0",
-    "piqtree>=0.8.0; python_version > '3.11'",
-    "ruff==0.16.5",
+    # h5py, which cogent3-h5seqs needs, has no python 3.15 wheels yet
+    "cogent3-h5seqs>=0.8.0; python_version <= '3.14'",
+    # piqtree publishes wheels only for linux x86_64, macOS arm64 and windows
+    # AMD64, and only for python 3.12 to 3.14, so restrict it to those or
+    # resolution fails elsewhere
+    "piqtree>=0.8.0; python_version > '3.11' and python_version <= '3.14' and ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'darwin' and platform_machine == 'arm64') or (sys_platform == 'win32' and platform_machine == 'AMD64'))",
+    "ruff==0.16.10",
     "kaleido",
     "pandas",
     "plotly",
@@ -83,20 +88,22 @@ test = [
     "pytest",
     "pytest-cov",
     "pytest-xdist",
-    "nox",
+    "nox>=2026.8.10",
 ]
-typing = ["mypy==2.3.1"]
+typing = ["mypy==2.4.0"]
 doc = [
     "click",
     "cogent3[extra]",
-    "cogent3-h5seqs>=0.8.0",
+    "cogent3-h5seqs>=0.8.0; python_version <= '3.14'",
     "ipykernel",
     "jupyter_client",
     "jupytext",
     "nbclient",
     "nbconvert>5.4",
     "nbformat",
-    "piqtree>=0.8.0; python_version > '3.11'",
+    # gated as in the test group: dev includes doc, so an ungated requirement
+    # here stops uv building the project environment at all
+    "piqtree>=0.8.0; python_version > '3.11' and python_version <= '3.14' and ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'darwin' and platform_machine == 'arm64') or (sys_platform == 'win32' and platform_machine == 'AMD64'))",
     "quartodoc",
     # quartodoc 0.11.1 requires only griffe>=0.33, but griffe 2 dropped the
     # allow_section_blank_line argument it passes to parse_numpy
@@ -104,7 +111,6 @@ doc = [
     "iplotx>=1.7.1",
 ]
 dev = [
-    "nox",
     "scriv",
     "types-tqdm",
     { include-group = "doc" },
@@ -144,11 +150,9 @@ filterwarnings = [
 
 [tool.uv]
 reinstall-package = ["cogent3"]
-
-[tool.uv.pip]
 exclude-newer = "7 days"
 
-[tool.uv.pip.exclude-newer-package]
+[tool.uv.exclude-newer-package]
 # but we allow other cogent3 packages to be more recent
 scinexus = false
 cogent3-h5seqs = false


=====================================
src/cogent3/align/pairwise.py
=====================================
@@ -823,8 +823,7 @@ class PairEmissionProbs:
                     else:
                         cells.append((state, (i, j)))
         probs = self.dp(TM, dp_options, cells=cells, backward=backward)
-        probs = numpy.array(probs)
-        probs.shape = (len(p_rows), N - 1, len(T) - 1)
+        probs = numpy.array(probs).reshape(len(p_rows), N - 1, len(T) - 1)
         return numpy.array([probs[p_rows.index(i)] for i in last_row])
 
     def dp(self, TM, dp_options, cells=None, backward=False):


=====================================
src/cogent3/core/alignment.py
=====================================
@@ -1225,7 +1225,7 @@ class CollectionBase(AnnotatableMixin, ABC, Generic[TSequenceOrAligned]):
             result[name] = ambig
             array = numpy.array(self.seqs[name])
             for i in numpy.where(array > cast("int", alpha.gap_index))[0]:
-                ambig[i] = alpha[array[i]]
+                ambig[int(i)] = alpha[array[i]]
         return result
 
     def counts(
@@ -3946,8 +3946,7 @@ class Alignment(CollectionBase[Aligned]):
             gap_cols = gap_array.sum(axis=0) > 0
             gap_array = gap_array[:, gap_cols]
 
-        result = gap_array.sum(axis=1)
-        result = darr.wrap(result)
+        result = darr.wrap(gap_array.sum(axis=1))
         if drawable:
             drawable = drawable.lower()
             trace_name = pathlib.Path(self.source).name if self.source else None


=====================================
src/cogent3/core/alphabet.py
=====================================
@@ -1036,7 +1036,7 @@ def seq_to_kmer_indices_overlapping(
             if gained_char >= num_states:
                 val = gap_index if gained_char == gap_char_index else missing_index
         elif idx < 0:
-            idx = (seq[i : i + k] * coeffs).sum()
+            idx = int((seq[i : i + k] * coeffs).sum())
             val = idx
             skip_until = -1
         else:


=====================================
src/cogent3/core/moltype.py
=====================================
@@ -1277,7 +1277,9 @@ class MolType(Generic[TStrOrBytes]):
         if isinstance(seq, str):
             assert self.ambiguities is not None
             rng = numpy.random.default_rng()
-            return "".join(rng.choice(list(self.ambiguities.get(n, n))) for n in seq)
+            return "".join(
+                str(rng.choice(list(self.ambiguities.get(n, n)))) for n in seq
+            )
 
         if isinstance(seq, bytes):
             return self.random_disambiguate(seq.decode("utf8")).encode("utf8")


=====================================
src/cogent3/core/sequence.py
=====================================
@@ -146,7 +146,7 @@ def count_kmers(
             continue
 
         if idx < 0:
-            idx = (seq[i : i + k] * coeffs).sum()
+            idx = int((seq[i : i + k] * coeffs).sum())
             kfreqs[idx] += 1
             continue
 


=====================================
src/cogent3/util/union_dict.py
=====================================
@@ -1,31 +1,48 @@
 """UnionDict extension of dict."""
 
+from typing import Any, Self
+
+
+def _convert_children(root: "UnionDict", source: Any) -> None:
+    on_path = {id(source)}
+    frames = [(root, iter(root.items()), id(source))]
+    while frames:
+        node, items, source_id = frames[-1]
+        for key, value in items:
+            if not isinstance(value, dict):
+                continue
+            if id(value) in on_path:
+                msg = f"cannot copy {key!r}, it refers back to a dict containing it"
+                raise ValueError(msg)
+            child = UnionDict()
+            dict.update(child, value)
+            dict.__setitem__(node, key, child)
+            on_path.add(id(value))
+            frames.append((child, iter(child.items()), id(value)))
+            break
+        else:
+            frames.pop()
+            on_path.discard(source_id)
+
 
 class UnionDict(dict):
     """dictionary class that can be updated as a union with another dict
     entries are also accessible directly as attributes on the object"""
 
-    def __init__(self, *args, **kwargs) -> None:
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
         """
 
         Parameters
         ----------
-        data : dict or None
+        data : dict
+            nested dicts are copied, never shared with data
         """
         assert not (args and kwargs)
         if args:
             assert len(args) == 1
             kwargs = args[0]
         super().__init__(kwargs)
-
-        # force all children to to UnionDicts
-        for key in self:
-            value = self._getsubattr_([], key)
-            if not isinstance(value, dict):
-                continue
-
-            if not isinstance(value, UnionDict):
-                self.update({key: UnionDict(value)})
+        _convert_children(self, kwargs)
 
     def __getattr__(self, item):
         if item in self:
@@ -37,74 +54,50 @@ class UnionDict(dict):
             msg = f"'{item}' not a key or attribute"
             raise AttributeError(msg)
 
-    def __setattr__(self, key, value) -> None:
-        if isinstance(value, dict):
-            value = UnionDict(value)
-
-        self.update({key: value})
+    def __setattr__(self, key: str, value: Any) -> None:
+        self[key] = value
 
-    def __setitem__(self, key, value) -> None:
-        if isinstance(value, dict):
+    def __setitem__(self, key: Any, value: Any) -> None:
+        # a |= on an attribute assigns the same object back, which must not copy it
+        stored_as_is = value is self.get(key) and isinstance(value, UnionDict)
+        if isinstance(value, dict) and not stored_as_is:
             value = UnionDict(value)
+        super().__setitem__(key, value)
 
-        self.update({key: value})
+    def setdefault(self, key: Any, default: Any = None) -> Any:
+        if key not in self:
+            self[key] = default
+        return self[key]
 
-    def __or__(self, other):
+    def __or__(self, other: Any) -> Self:
         result = self.__class__(self)
         result.union(other)
         return result
 
-    def __ior__(self, other):
+    def __ior__(self, other: Any) -> Self:
         self.union(other)
         return self
 
-    def union(self, other) -> None:
-        """returns the union of self with other
+    def union(self, other: Any) -> None:
+        """merge other into self in place
 
         keys unique to other are introduced, keys in self shared with other
-        are updated with the value from other"""
-
-        if not isinstance(other, UnionDict):
-            other = UnionDict(other)
-
-        for a in list(other):
-            if self.get(a) is None or not (
-                isinstance(other.get(a), dict) and isinstance(self.get(a), dict)
-            ):
-                self.update({a: other.get(a)})
-                continue
-
-            path = [a]
-            while len(path) > 0:
-                name = path.pop()
-                cs = self._getsubattr_(path, name)
-                cd = other._getsubattr_(path, name)
-                path.append(name)
-
-                for attr in list(cd):
-                    if isinstance(cd.get(attr), dict) and isinstance(
-                        cs.get(attr),
-                        dict,
-                    ):
-                        # go into dict
-                        path.append(attr)
-                    else:
-                        cs.update({attr: cd.get(attr)})
-                path.pop()
-
-    def _getsubattr_(self, path, name):
-        """returns nested values"""
-        d = self
-        for node in path:
-            d = d.get(node)
-        return d.get(name)
-
-    def update(self, *args, **kwargs) -> None:
+        are updated with the value from other, except where both values are
+        dicts, which are merged in the same way"""
+        stack: list[tuple[dict[Any, Any], dict[Any, Any]]] = [(self, UnionDict(other))]
+        while stack:
+            target, source = stack.pop()
+            for key, value in source.items():
+                current = target.get(key)
+                if isinstance(value, dict) and isinstance(current, dict):
+                    stack.append((current, value))
+                else:
+                    dict.__setitem__(target, key, value)
+
+    def update(self, *args: Any, **kwargs: Any) -> None:
         """Converts to UnionDict."""
         assert not (args and kwargs)
         if args:
             assert len(args) == 1
-            kwargs = UnionDict(args[0])
-        if not isinstance(kwargs, UnionDict):
-            kwargs = UnionDict(kwargs)
-        super().update(kwargs)
+            kwargs = args[0]
+        super().update(UnionDict(kwargs))


=====================================
tests/test_app/test_evo.py
=====================================
@@ -883,6 +883,7 @@ class TestBootstrap(TestCase):
             out_dstore = open_data_store(dirname / "delme.sqlitedb", mode="w")
             writer = io.write_db(out_dstore)
             _ = io.load_db() + evo_app.bootstrap(hyp, num_reps=2) + writer
+            out_dstore.close()
 
 
 def test_bstrap_parallel():


=====================================
tests/test_app/test_init.py
=====================================
@@ -17,6 +17,7 @@ except ImportError:
 
 def _get_all_composables(tmp_dir_name):
     tmp_dir_name = Path(tmp_dir_name)
+    tmp_dir_name.mkdir(parents=True, exist_ok=True)
     test_model1 = get_app("model", "HKY85")
     test_model2 = get_app("model", "GN")
     test_hyp = get_app("hypothesis", test_model1, test_model2)


=====================================
tests/test_app/test_io.py
=====================================
@@ -11,7 +11,6 @@ import pytest
 from numpy.testing import assert_allclose
 from scinexus.composable import (
     NotCompleted,
-    NotCompletedType,
     propagate_source,
     source_proxy,
 )
@@ -475,6 +474,8 @@ def test_write_db_load_db(fasta_dir, tmp_dir):
         assert orig == read
 
     assert data_store.record_type == get_object_provenance(orig)
+    orig_dstore.close()
+    data_store.close()
 
 
 def test_load_db_prefer_source_attr(tmp_dir):
@@ -497,6 +498,7 @@ def test_load_db_prefer_source_attr(tmp_dir):
     loader = io_app.load_db()
     got = loader(data_store[0])  # pylint: disable=not-callable
     assert "source" not in got.info
+    data_store.close()
 
 
 def test_write_read_db_not_completed(tmp_dir):
@@ -511,6 +513,7 @@ def test_write_read_db_not_completed(tmp_dir):
     reader = io_app.load_db()
     got = reader(data_store.not_completed[0])
     assert got.to_rich_dict() == nc.to_rich_dict()
+    data_store.close()
 
 
 def test_write_read_db_summary_not_completed(tmp_dir):
@@ -522,6 +525,7 @@ def test_write_read_db_summary_not_completed(tmp_dir):
     writer = get_app("write_db", data_store=data_store)
     writer.main(nc, identifier="blah")
     assert isinstance(writer.data_store.summary_not_completed, Table)
+    data_store.close()
 
 
 def test_write_db_parallel(tmp_dir, fasta_dir):
@@ -542,6 +546,8 @@ def test_write_db_parallel(tmp_dir, fasta_dir):
     got = [str(pathlib.Path(m.data_store.source) / m.unique_id) for m in result]
     assert got != []
     assert got == expect
+    dstore.close()
+    out_dstore.close()
 
 
 def test_define_data_store(fasta_dir):
@@ -611,13 +617,15 @@ def test_writer_unique_id_arg(tmp_dir, writer, data, dstore):
         name = pathlib.Path(get_data_source(source)).name
         return name.split(".")[0]
 
-    writer = get_app(writer, data_store=dstore(tmp_dir), id_from_source=uniqid)
+    out_dstore = dstore(tmp_dir)
+    writer = get_app(writer, data_store=out_dstore, id_from_source=uniqid)
     m = writer(data)
     # directory data stores have a suffix, so we create expected name using that
     suffix = getattr(writer.data_store, "suffix", "")
     suffix = f".{suffix}" if suffix else suffix
     expect = f"blah{suffix}"
     assert m.unique_id == expect
+    out_dstore.close()
 
 
 src_attr = "source"
@@ -633,13 +641,15 @@ src_attr = "source"
     ],
 )
 def test_writer_no_unique_id(tmp_dir, writer, data, attr, dstore):
-    writer = get_app(writer, data_store=dstore(tmp_dir))
+    out_dstore = dstore(tmp_dir)
+    writer = get_app(writer, data_store=out_dstore)
     # no unique id possible, so m will be NotCompleted error
     value = {} if attr == "info" else None
     setattr(data, attr, value)
-    m = writer(data)
-    assert isinstance(m, NotCompleted)
-    assert m.type is NotCompletedType.ERROR
+    with pytest.raises(ValueError, match="cannot name a record"):
+        _ = writer(data)
+
+    out_dstore.close()
 
 
 @pytest.mark.parametrize(


=====================================
tests/test_app/test_sqlite_data_store.py
=====================================
@@ -1,3 +1,4 @@
+from contextlib import closing
 from datetime import UTC, datetime
 from pathlib import Path
 
@@ -91,16 +92,18 @@ def test_open_existing(dstore_on_disk):
 
 
 def test_open_to_append(dstore_on_disk):
-    DataStoreSqlite(dstore_on_disk, mode=APPEND)
+    with closing(DataStoreSqlite(dstore_on_disk, mode=APPEND)) as _:
+        pass
 
 
 def test_open_to_write(dstore_on_disk):
-    DataStoreSqlite(dstore_on_disk, mode=OVERWRITE)
+    with closing(DataStoreSqlite(dstore_on_disk, mode=OVERWRITE)) as _:
+        pass
 
 
 def test_db_creation():
-    db = DataStoreSqlite(":memory:", mode=OVERWRITE)
-    db = db.db
+    store = DataStoreSqlite(":memory:", mode=OVERWRITE)
+    db = store.db
     result = db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
     assert len(result) == 4
     created_names = {r["name"] for r in result}
@@ -200,7 +203,7 @@ def test_limit_datastore(full_dstore_sqlite):
 
 def test_validate(full_dstore_sqlite):
     r = full_dstore_sqlite.validate()
-    assert r.shape == (4, 2)
+    assert r.shape == (5, 2)
 
 
 def test_set_record_type(full_dstore_sqlite):
@@ -268,6 +271,7 @@ def test_append_makes_logs(tmp_dir, ro_dir_dstore, name, suffix):
     out_path = tmp_dir / name
     got1 = _make_and_run_proc(out_path, suffix, ro_dir_dstore[:num])
     assert len(got1.logs) == 1
+    got1.close()
 
     # creating a separate instance should result in a
     # new log file
@@ -276,6 +280,7 @@ def test_append_makes_logs(tmp_dir, ro_dir_dstore, name, suffix):
     # should be a row for each log
     summary = got2.summary_logs
     assert summary.shape[0] == 2
+    got2.close()
 
 
 def test_summary_not_completed(nc_objects):


=====================================
tests/test_core/test_annotation_db.py
=====================================
@@ -365,7 +365,7 @@ def test_load_annotations_multi(DATA_DIR):
 def compressed_flat_file(DATA_DIR, tmp_path, request):
     src = DATA_DIR / request.param
     out_path = tmp_path / f"{request.param}.gz"
-    with cogent3.open_(out_path, mode="w") as out:
+    with cogent3.open_(out_path, mode="wb") as out:
         out.write(src.read_bytes())
     return out_path
 


=====================================
tests/test_evolve/test_scale_rules.py
=====================================
@@ -42,10 +42,9 @@ class ScaleRuleTests(unittest.TestCase):
         """Scale rule requiring matrix entries to have all pars specified"""
         model = _make_model_factory({"k": trans}, {"ts": trans, "tv": ~trans})
 
-        assert self._get_scaled_lengths(model, {"k": 6.0, "length": 4.0}) == {
-            "ts": 3.0,
-            "tv": 1.0,
-        }
+        assert self._get_scaled_lengths(
+            model, {"k": 6.0, "length": 4.0}
+        ) == pytest.approx({"ts": 3.0, "tv": 1.0})
 
     def test_scaled_or(self):
         """Scale rule where matrix entries can have any of the pars specified"""
@@ -57,7 +56,7 @@ class ScaleRuleTests(unittest.TestCase):
         assert self._get_scaled_lengths(
             model,
             {"k": 6.0, "length": 6.0, "ac": 3.0},
-        ) == {"or": 5.0, "not": 1.0}
+        ) == pytest.approx({"or": 5.0, "not": 1.0})
 
     def test_scaling(self):
         """Testing scaling calculations using Dn and Ds as an example."""


=====================================
tests/test_util/test_union_dict.py
=====================================
@@ -1,5 +1,7 @@
 """Unit tests for union_dict."""
 
+import pytest
+
 from cogent3.util.union_dict import UnionDict
 
 
@@ -97,14 +99,6 @@ def test_sub_dicts_are_union():
     assert isinstance(d.e.g, UnionDict)
 
 
-def test_get_subattr():
-    """_getsubattr_ returns nested values via key"""
-    d = UnionDict({"a": 1, "b": 2, "c": 3, "d": {"e": 5, "f": 6}})
-    assert d._getsubattr_([], "a") == 1
-    assert d._getsubattr_([], "d") == UnionDict({"e": 5, "f": 6})
-    assert d._getsubattr_(["d"], "e") == 5
-
-
 def test_setitem():
     """should work via property or key"""
     d = UnionDict()
@@ -116,3 +110,168 @@ def test_setitem():
     assert d.b.c == 42
     assert d["b"] == {"c": 42}
     assert isinstance(d.b, UnionDict)
+
+
+def test_or_with_plain_dict():
+    d = UnionDict({"a": 1, "d": {"e": 5}})
+    f = d | {"d": {"g": {"h": 8}}}
+    assert isinstance(f, UnionDict)
+    assert f.d.e == 5
+    assert f.d.g.h == 8
+    assert "g" not in d.d
+
+
+def test_two_level_shared_merge():
+    d = UnionDict({"d": {"e": {"k": 1}}})
+    f = d | {"d": {"e": {"m": 2}}}
+    assert f.d.e == {"k": 1, "m": 2}
+    assert d.d.e == {"k": 1}
+
+
+def test_or_leaves_right_operand_unaliased():
+    d = UnionDict({"a": 1})
+    e = UnionDict({"b": {"c": 1}, "p": {"q": {"r": 1}}})
+    f = d | e
+    f.b.c = 99
+    f.p.q.r = 99
+    assert e.b.c == 1
+    assert e.p.q.r == 1
+
+
+def test_union_none_replaces_dict():
+    d = UnionDict({"a": {"b": 1}})
+    d |= {"a": None}
+    assert d.a is None
+
+
+def test_union_dict_replaces_nested_value():
+    d = UnionDict({"a": {"b": 1}})
+    d |= {"a": {"b": {"c": 2}}}
+    assert isinstance(d.a.b, UnionDict)
+    assert d.a.b.c == 2
+
+
+def test_construction_leaves_input_unchanged():
+    data = {"a": {"b": {"c": 1}}}
+    d = UnionDict(data)
+    d.a.b.c = 2
+    assert type(data["a"]) is dict
+    assert type(data["a"]["b"]) is dict
+    assert data["a"]["b"]["c"] == 1
+
+
+def test_nested_ior_keeps_held_references():
+    layout = UnionDict({"xaxis": {"title": {"text": "a"}}})
+    xaxis = layout.xaxis
+    title = layout.xaxis.title
+    layout.xaxis |= {"range": [0, 1]}
+    title.text = "z"
+    assert layout.xaxis is xaxis
+    assert layout.xaxis.title.text == "z"
+
+
+def test_assigning_union_dict_copies_it():
+    d = UnionDict()
+    inner = UnionDict({"c": {"e": 1}})
+    d.b = inner
+    assert d.b == inner
+    assert d.b is not inner
+    assert d.b.c is not inner.c
+
+
+def test_construction_copies_shared_children():
+    shared = {"c": {"e": 1}}
+    d = UnionDict({"a": shared, "b": shared})
+    assert d.a == d.b
+    assert d.a is not d.b
+    assert d.a.c is not d.b.c
+
+
+def test_union_into_children_built_from_one_dict():
+    shared = {"x": 0}
+    d = UnionDict({"a": shared, "b": shared})
+    d |= {"a": {"x": 1}, "b": {"x": 2}}
+    assert d.a.x == 1
+    assert d.b.x == 2
+
+
+def test_setdefault_copies_dict():
+    shared = {"x": 0}
+    d = UnionDict()
+    d.setdefault("a", shared)
+    d.setdefault("b", shared)
+    assert isinstance(d.a, UnionDict)
+    assert d.a is not d.b
+    d |= {"a": {"x": 1}, "b": {"x": 2}}
+    assert d.a.x == 1
+    assert d.b.x == 2
+    assert shared == {"x": 0}
+
+
+def test_setdefault_returns_existing_value():
+    d = UnionDict({"a": {"x": 1}})
+    existing = d.a
+    assert d.setdefault("a", {"x": 2}) is existing
+    assert d.setdefault("b") is None
+
+
+def test_construction_from_self_referencing_dict_raises():
+    data: dict[str, object] = {"x": 1}
+    data["me"] = data
+    with pytest.raises(ValueError):
+        UnionDict(data)
+
+
+def test_union_with_self_referencing_dict_raises():
+    data: dict[str, object] = {"x": 1}
+    data["me"] = data
+    d = UnionDict({"x": 0})
+    with pytest.raises(ValueError):
+        d |= data
+
+
+def test_update_kwargs_converts_children():
+    d = UnionDict()
+    d.update(b={"c": {"e": 2}})
+    assert d.b.c.e == 2
+
+
+def test_ior_with_plain_dict_converts_children():
+    d = UnionDict({"a": 1})
+    d |= {"b": {"c": {"e": 2}}}
+    assert isinstance(d.b, UnionDict)
+    assert isinstance(d.b.c, UnionDict)
+    assert d.b.c.e == 2
+
+
+def test_union_method():
+    d = UnionDict({"d": {"e": 5, "f": 6}})
+    d.union({"d": {"f": 0}})
+    assert d.d == {"e": 5, "f": 0}
+
+
+def _make_nested(depth: int, leaf: dict[str, object]) -> dict[str, object]:
+    data = leaf
+    for _ in range(depth):
+        data = {"x": data}
+    return data
+
+
+def test_deeply_nested_construction():
+    depth = 2000
+    d = UnionDict(_make_nested(depth, {"leaf": 1}))
+    node = d
+    for _ in range(depth):
+        node = node.x
+    assert isinstance(node, UnionDict)
+    assert node.leaf == 1
+
+
+def test_deeply_nested_union():
+    depth = 2000
+    d = UnionDict(_make_nested(depth, {"a": 1}))
+    d |= _make_nested(depth, {"b": 2})
+    node = d
+    for _ in range(depth):
+        node = node.x
+    assert node == {"a": 1, "b": 2}


=====================================
uv.lock
=====================================
The diff for this file was not included because it is too large.


View it on GitLab: https://salsa.debian.org/med-team/python-cogent/-/compare/0c5257c23672230ae5fec2db8c2268439ae26c70...caca804b5bf876d6281adb891c13c937e6627125

-- 
View it on GitLab: https://salsa.debian.org/med-team/python-cogent/-/compare/0c5257c23672230ae5fec2db8c2268439ae26c70...caca804b5bf876d6281adb891c13c937e6627125
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20261019/98dbed44/attachment-0001.htm>


More information about the debian-med-commit mailing list