From 31a23aa2d8be064ac37617a774b085bb5f53ad7b Mon Sep 17 00:00:00 2001 From: Remis Date: Thu, 12 Nov 2026 19:34:19 +0000 Subject: [PATCH] Make scalcs a regular package Without scalcs/__init__.py, scalcs was a namespace package: Python merged every directory on the path holding a scalcs folder, so a module missing from the copy under test was quietly imported from another copy - an installed release, a checkout on PYTHONPATH. With __init__.py the first copy found is used whole, and a missing module is an ImportError. __init__.py imports only the version, so scalcs.__version__ now exists, as the release workflow's comment already assumed, and importing scalcs stays cheap. What this does not fix, and the tests say so: an editable install registers a finder that resolves any scalcs.* name straight to its own source tree, ignoring which copy of scalcs was imported, and it still fills in modules the first copy lacks. That finder, not the namespace merging, caused the false pass seen while reproducing Colquhoun & Lape (2012). Nothing inside the package can stop it; testing one copy of SCALCS beside an editable install of another still needs a clean environment, or that finder removed and every module's __file__ checked. The test of the path merging removes any such finder first, so it tests the merging alone: without __init__.py the missing module came from the second copy on the path; with it, importing it fails. Co-Authored-By: Claude Opus 5.5 --- scalcs/__init__.py | 11 ++++++ tests/test_package.py | 86 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 scalcs/__init__.py create mode 100644 tests/test_package.py diff --git a/scalcs/__init__.py b/scalcs/__init__.py new file mode 100644 index 0000000..437c72b --- /dev/null +++ b/scalcs/__init__.py @@ -0,0 +1,11 @@ +"""SCALCS: Q-matrix calculations for single ion channel kinetics. + +This file makes scalcs a regular package. Without it, Python treats scalcs as +a namespace package and merges every directory on the path that holds a scalcs +folder, so a module missing from the copy being tested is quietly imported from +another copy - an editable install, say. That once made a check report success +against one version of SCALCS while running modules from another. + +Keep it light: importing scalcs should import nothing but the version. +""" +from scalcs.version import __version__ # noqa: F401 diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..1d71da0 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,86 @@ +"""scalcs must be a regular package, not a namespace package. + +Without scalcs/__init__.py, Python treats scalcs as a namespace package and +merges every directory on the path that contains a scalcs folder. A test +pointed at one copy of SCALCS - an older release, a branch under review - then +silently imports any module that copy lacks from another copy on the path: an +installed release in site-packages, a checkout on PYTHONPATH. + +With __init__.py the first scalcs found on the path is used whole, and a +module it lacks is an ImportError. + +What this does NOT prevent: an editable install (pip install -e) registers its +own finder, which resolves any scalcs.* name directly to its own source tree +and ignores which copy of scalcs was imported. It is consulted after the normal +path search, so it still fills in any module the first copy lacks. That is what +produced the false pass during the reproduction of Colquhoun & Lape (2012), and +nothing inside the package can stop it. To test one copy of SCALCS while an +editable install of another exists, use a clean environment, or remove that +finder from sys.meta_path and then check every imported module's __file__. +""" +import os +import shutil +import subprocess +import sys + +import pytest + +import scalcs +from scalcs import version + + +def test_scalcs_is_a_regular_package(): + assert scalcs.__file__ is not None + assert os.path.basename(scalcs.__file__) == '__init__.py' + assert type(scalcs.__path__) is list # a namespace package's is not + + +#: Run in a subprocess with two copies of scalcs on the path. Any editable- +#: install finder is removed first, so that only the path search is tested - +#: see the module docstring for why that finder is out of the package's reach. +_TWO_COPIES = ''' +import sys +STANDARD = ('_frozen_importlib', '_frozen_importlib_external') +sys.meta_path = [f for f in sys.meta_path + if getattr(type(f), '__module__', '').startswith(STANDARD) + or getattr(f, '__module__', '').startswith(STANDARD)] +for name in [m for m in sys.modules if m.split('.')[0] == 'scalcs']: + del sys.modules[name] +import scalcs.qmatlib +print('qmatlib came from', scalcs.qmatlib.__file__) +''' + + +def test_another_copy_on_the_path_cannot_fill_in_modules(tmp_path): + """Two copies of scalcs on the path: first a copy laid out like this one + but without qmatlib, then the real one. Importing scalcs.qmatlib must + fail, rather than quietly come from the second copy.""" + real_package = os.path.dirname(os.path.abspath(version.__file__)) + real_root = os.path.dirname(real_package) + first = tmp_path / 'scalcs' + first.mkdir() + for name in ('__init__.py', 'version.py'): + if os.path.exists(os.path.join(real_package, name)): + shutil.copy(os.path.join(real_package, name), first / name) + + env = dict(os.environ, + PYTHONPATH=os.pathsep.join([str(tmp_path), real_root])) + run = subprocess.run([sys.executable, '-c', _TWO_COPIES], cwd=tmp_path, + env=env, capture_output=True, text=True) + assert run.returncode != 0, run.stdout + assert "No module named 'scalcs.qmatlib'" in run.stderr, run.stderr + + +def test_the_version_is_on_the_package(): + assert scalcs.__version__ == version.__version__ + + +def test_importing_the_package_imports_nothing_heavy(): + """`import scalcs` must stay cheap: scalcs.schemes, for one, relies on + nothing importing matplotlib behind its back.""" + code = ('import sys, scalcs; ' + 'print(sorted(m for m in ("numpy", "scipy", "matplotlib") ' + 'if m in sys.modules))') + run = subprocess.run([sys.executable, '-c', code], capture_output=True, + text=True, check=True) + assert run.stdout.strip() == '[]'