From fc34621f6145e8d999891cab0346fbaffe4ba76c Mon Sep 17 00:00:00 2001 From: Tom Kaltofen Date: Wed, 10 Jun 2026 08:15:29 +0000 Subject: [PATCH 1/3] feat: guard against reserved mloda namespace package roots A plugin scaffolded from this template that renames the package root to 'mloda' or 'mloda_plugins' would ship mloda/__init__.py and collapse the shared PEP 420 namespace, making the core framework unimportable for anyone who installs the plugin. - bin/customize.sh rejects the reserved names at rename time - tests/test_reserved_namespace.py fails (in default tox / CI) if the repo would ship a reserved namespace root - README notes the reserved names in the customization step Closes #71 --- README.md | 2 +- bin/customize.sh | 8 ++++ tests/test_reserved_namespace.py | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/test_reserved_namespace.py diff --git a/README.md b/README.md index ee11c7f..8fa9cd9 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ placeholder/ This renames `placeholder/` to `/`, updates `pyproject.toml` (`name`, `authors`, `description`, `packages.find.include`, `pytest.testpaths`), updates `.releaserc.yaml` (`message`, `repositoryUrl`), and rewrites `from placeholder.` imports across the package. -The package name must be a valid Python identifier (lowercase letters, digits, underscores; must start with a letter). All option flags are optional; if you omit them you can edit the corresponding fields by hand later. +The package name must be a valid Python identifier (lowercase letters, digits, underscores; must start with a letter). The names `mloda` and `mloda_plugins` are reserved: the core `mloda` package is a shared PEP 420 namespace, so a plugin that shipped `mloda/__init__.py` would make mloda unimportable for everyone who installs it. Both `bin/customize.sh` and a `tox`/CI check reject these names. All option flags are optional; if you omit them you can edit the corresponding fields by hand later. #### 2. Verify setup diff --git a/bin/customize.sh b/bin/customize.sh index f23f33f..f2e10f1 100755 --- a/bin/customize.sh +++ b/bin/customize.sh @@ -69,6 +69,14 @@ if [[ "$PACKAGE" == "placeholder" ]]; then exit 2 fi +if [[ "$PACKAGE" == "mloda" || "$PACKAGE" == "mloda_plugins" ]]; then + echo "Error: package name '$PACKAGE' is reserved by the mloda namespace." >&2 + echo "The core mloda package is a shared PEP 420 namespace; a plugin that ships" >&2 + echo "mloda/__init__.py would make mloda unimportable for everyone who installs it." >&2 + echo "Choose a name unique to your organization (e.g. acme, my_org)." >&2 + exit 2 +fi + if ! [[ "$PACKAGE" =~ ^[a-z][a-z0-9_]*$ ]]; then echo "Error: package name must be lowercase letters/digits/underscores, starting with a letter." >&2 echo "Got: $PACKAGE" >&2 diff --git a/tests/test_reserved_namespace.py b/tests/test_reserved_namespace.py new file mode 100644 index 0000000..cdf67ea --- /dev/null +++ b/tests/test_reserved_namespace.py @@ -0,0 +1,79 @@ +"""Guard: a plugin built from this template must never occupy the reserved mloda namespace. + +The core ``mloda`` package is a PEP 420 namespace package shared across distributions +(mloda core plus the mloda-registry packages under ``mloda.community`` / +``mloda.enterprise``). Namespace merging only works while no participating distribution +ships an ``mloda/__init__.py``. A template user who renames the ``placeholder/`` package +root to ``mloda`` (or ``mloda_plugins``) would ship exactly that file and shadow +``mloda.*`` for everyone who installs the plugin, making the core framework unimportable. + +See https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/ +""" + +from pathlib import Path + +# Directory names a plugin must not use for its package root. ``mloda`` is the shared +# namespace root; ``mloda_plugins`` is reserved by the ecosystem to avoid collisions. +RESERVED_NAMESPACE_ROOTS = ("mloda", "mloda_plugins") + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def find_reserved_namespace_violations(root: Path) -> list[str]: + """Return human-readable violations for reserved namespace roots under ``root``. + + setuptools discovers top-level packages directly under the project root + (``tool.setuptools.packages.find.where = ["."]``), so a shipped ``mloda`` package can + only come from a top-level ``mloda/`` directory here. + """ + violations: list[str] = [] + for name in RESERVED_NAMESPACE_ROOTS: + candidate = root / name + if not candidate.is_dir(): + continue + violations.append( + f"package root directory '{name}/' uses the reserved mloda namespace; " + "rename it (see README 'Setup Your Plugin')" + ) + if (candidate / "__init__.py").is_file(): + violations.append( + f"'{name}/__init__.py' would ship in the built distribution and collapse " + "the PEP 420 'mloda' namespace package, making mloda unimportable for " + "anyone who installs this plugin" + ) + return violations + + +def test_repo_has_no_reserved_namespace_root() -> None: + """The live guard: fail if this repo would ship a package into the mloda namespace.""" + violations = find_reserved_namespace_violations(REPO_ROOT) + assert not violations, "Reserved namespace violation(s):\n" + "\n".join(violations) + + +def test_detects_reserved_root_with_init(tmp_path: Path) -> None: + pkg = tmp_path / "mloda" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + + violations = find_reserved_namespace_violations(tmp_path) + + assert len(violations) == 2 + assert any("__init__.py" in v for v in violations) + + +def test_detects_reserved_root_without_init(tmp_path: Path) -> None: + # A reserved name is rejected even as a PEP 420 namespace dir (no __init__.py). + (tmp_path / "mloda_plugins").mkdir() + + violations = find_reserved_namespace_violations(tmp_path) + + assert len(violations) == 1 + assert "mloda_plugins/" in violations[0] + + +def test_clean_tree_has_no_violations(tmp_path: Path) -> None: + pkg = tmp_path / "acme" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + + assert find_reserved_namespace_violations(tmp_path) == [] From ccd50bbf64f37aef433baa49d04c4d9db2b836c3 Mon Sep 17 00:00:00 2001 From: Tom Kaltofen Date: Wed, 10 Jun 2026 08:22:24 +0000 Subject: [PATCH 2/3] test: detect reserved namespace __init__.py in non-root layouts Recursively scan the source tree (pruning venv/build/cache dirs) for a reserved 'mloda/__init__.py' or 'mloda_plugins/__init__.py', so a src/-style layout that would still collapse the namespace is caught, not just the default where=["."] root layout. --- tests/test_reserved_namespace.py | 73 +++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/tests/test_reserved_namespace.py b/tests/test_reserved_namespace.py index cdf67ea..ae3a841 100644 --- a/tests/test_reserved_namespace.py +++ b/tests/test_reserved_namespace.py @@ -10,37 +10,56 @@ See https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/ """ +import os from pathlib import Path -# Directory names a plugin must not use for its package root. ``mloda`` is the shared +# Directory names a plugin must not use for a package root. ``mloda`` is the shared # namespace root; ``mloda_plugins`` is reserved by the ecosystem to avoid collisions. RESERVED_NAMESPACE_ROOTS = ("mloda", "mloda_plugins") +# Directories that never ship in the distribution: VCS, virtualenvs, caches, build +# outputs. Pruned from the recursive scan so an installed dependency (e.g. mloda under +# .venv) never trips the guard. +EXCLUDED_DIRS = frozenset( + {".git", ".venv", "venv", ".tox", ".mypy_cache", ".ruff_cache", ".pytest_cache", "__pycache__", "build", "dist"} +) + REPO_ROOT = Path(__file__).resolve().parent.parent def find_reserved_namespace_violations(root: Path) -> list[str]: - """Return human-readable violations for reserved namespace roots under ``root``. + """Return human-readable violations for reserved namespace usage under ``root``. + + Two checks: - setuptools discovers top-level packages directly under the project root - (``tool.setuptools.packages.find.where = ["."]``), so a shipped ``mloda`` package can - only come from a top-level ``mloda/`` directory here. + * a top-level directory named ``mloda`` / ``mloda_plugins`` (the package root a + template user would create by renaming ``placeholder/`` to a reserved name); and + * any ``mloda/__init__.py`` / ``mloda_plugins/__init__.py`` anywhere in the source + tree (covers ``src/``-style layouts, not just the default ``where = ["."]``), + which is the file that would ship and collapse the shared namespace. """ violations: list[str] = [] + for name in RESERVED_NAMESPACE_ROOTS: - candidate = root / name - if not candidate.is_dir(): - continue - violations.append( - f"package root directory '{name}/' uses the reserved mloda namespace; " - "rename it (see README 'Setup Your Plugin')" - ) - if (candidate / "__init__.py").is_file(): + if (root / name).is_dir(): violations.append( - f"'{name}/__init__.py' would ship in the built distribution and collapse " - "the PEP 420 'mloda' namespace package, making mloda unimportable for " - "anyone who installs this plugin" + f"package root directory '{name}/' uses the reserved mloda namespace; " + "rename it (see README 'Setup Your Plugin')" ) + + for dirpath, dirnames, _ in os.walk(root): + # Prune excluded and egg-info directories in place so os.walk does not descend. + dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS and not d.endswith(".egg-info")] + for name in RESERVED_NAMESPACE_ROOTS: + init_file = Path(dirpath) / name / "__init__.py" + if init_file.is_file(): + rel = init_file.relative_to(root).as_posix() + violations.append( + f"'{rel}' would ship in the built distribution and collapse the PEP 420 " + "'mloda' namespace package, making mloda unimportable for anyone who " + "installs this plugin" + ) + return violations @@ -57,6 +76,7 @@ def test_detects_reserved_root_with_init(tmp_path: Path) -> None: violations = find_reserved_namespace_violations(tmp_path) + # Both the root-name check and the shipped-__init__.py check fire. assert len(violations) == 2 assert any("__init__.py" in v for v in violations) @@ -71,6 +91,27 @@ def test_detects_reserved_root_without_init(tmp_path: Path) -> None: assert "mloda_plugins/" in violations[0] +def test_detects_reserved_namespace_in_src_layout(tmp_path: Path) -> None: + # A non-default layout (src/) that ships mloda/__init__.py is still caught. + pkg = tmp_path / "src" / "mloda" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("") + + violations = find_reserved_namespace_violations(tmp_path) + + assert len(violations) == 1 + assert "src/mloda/__init__.py" in violations[0] + + +def test_ignores_excluded_dirs(tmp_path: Path) -> None: + # An installed dependency inside a virtualenv must not trip the guard. + pkg = tmp_path / ".venv" / "lib" / "mloda" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("") + + assert find_reserved_namespace_violations(tmp_path) == [] + + def test_clean_tree_has_no_violations(tmp_path: Path) -> None: pkg = tmp_path / "acme" pkg.mkdir() From f25f7709a7ebc940ebc591a14fa3959c05d85ecc Mon Sep 17 00:00:00 2001 From: Tom Kaltofen Date: Wed, 10 Jun 2026 08:25:38 +0000 Subject: [PATCH 3/3] style: trim comments in reserved-namespace guard test --- tests/test_reserved_namespace.py | 43 ++++++++++---------------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/tests/test_reserved_namespace.py b/tests/test_reserved_namespace.py index ae3a841..9ce01c7 100644 --- a/tests/test_reserved_namespace.py +++ b/tests/test_reserved_namespace.py @@ -1,25 +1,17 @@ -"""Guard: a plugin built from this template must never occupy the reserved mloda namespace. +"""Guard: a template-born plugin must never ship ``mloda/__init__.py``. -The core ``mloda`` package is a PEP 420 namespace package shared across distributions -(mloda core plus the mloda-registry packages under ``mloda.community`` / -``mloda.enterprise``). Namespace merging only works while no participating distribution -ships an ``mloda/__init__.py``. A template user who renames the ``placeholder/`` package -root to ``mloda`` (or ``mloda_plugins``) would ship exactly that file and shadow -``mloda.*`` for everyone who installs the plugin, making the core framework unimportable. - -See https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/ +``mloda`` is a shared PEP 420 namespace package; a distribution that ships +``mloda/__init__.py`` collapses it and makes mloda unimportable. See +https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/ """ import os from pathlib import Path -# Directory names a plugin must not use for a package root. ``mloda`` is the shared -# namespace root; ``mloda_plugins`` is reserved by the ecosystem to avoid collisions. +# Names a plugin must not use for a package root (``mloda_plugins`` is reserved too). RESERVED_NAMESPACE_ROOTS = ("mloda", "mloda_plugins") -# Directories that never ship in the distribution: VCS, virtualenvs, caches, build -# outputs. Pruned from the recursive scan so an installed dependency (e.g. mloda under -# .venv) never trips the guard. +# Dirs that never ship; pruned so an installed dep (e.g. mloda under .venv) is ignored. EXCLUDED_DIRS = frozenset( {".git", ".venv", "venv", ".tox", ".mypy_cache", ".ruff_cache", ".pytest_cache", "__pycache__", "build", "dist"} ) @@ -28,16 +20,8 @@ def find_reserved_namespace_violations(root: Path) -> list[str]: - """Return human-readable violations for reserved namespace usage under ``root``. - - Two checks: - - * a top-level directory named ``mloda`` / ``mloda_plugins`` (the package root a - template user would create by renaming ``placeholder/`` to a reserved name); and - * any ``mloda/__init__.py`` / ``mloda_plugins/__init__.py`` anywhere in the source - tree (covers ``src/``-style layouts, not just the default ``where = ["."]``), - which is the file that would ship and collapse the shared namespace. - """ + """Flag a reserved top-level package root, or a reserved ``/__init__.py`` + anywhere in the tree (so ``src/``-style layouts are covered too).""" violations: list[str] = [] for name in RESERVED_NAMESPACE_ROOTS: @@ -48,7 +32,6 @@ def find_reserved_namespace_violations(root: Path) -> list[str]: ) for dirpath, dirnames, _ in os.walk(root): - # Prune excluded and egg-info directories in place so os.walk does not descend. dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS and not d.endswith(".egg-info")] for name in RESERVED_NAMESPACE_ROOTS: init_file = Path(dirpath) / name / "__init__.py" @@ -64,7 +47,7 @@ def find_reserved_namespace_violations(root: Path) -> list[str]: def test_repo_has_no_reserved_namespace_root() -> None: - """The live guard: fail if this repo would ship a package into the mloda namespace.""" + """Live guard: this repo must not ship into the mloda namespace.""" violations = find_reserved_namespace_violations(REPO_ROOT) assert not violations, "Reserved namespace violation(s):\n" + "\n".join(violations) @@ -76,13 +59,13 @@ def test_detects_reserved_root_with_init(tmp_path: Path) -> None: violations = find_reserved_namespace_violations(tmp_path) - # Both the root-name check and the shipped-__init__.py check fire. + # Root-name check and shipped-__init__.py check both fire. assert len(violations) == 2 assert any("__init__.py" in v for v in violations) def test_detects_reserved_root_without_init(tmp_path: Path) -> None: - # A reserved name is rejected even as a PEP 420 namespace dir (no __init__.py). + # Rejected even as a bare namespace dir (no __init__.py). (tmp_path / "mloda_plugins").mkdir() violations = find_reserved_namespace_violations(tmp_path) @@ -92,7 +75,7 @@ def test_detects_reserved_root_without_init(tmp_path: Path) -> None: def test_detects_reserved_namespace_in_src_layout(tmp_path: Path) -> None: - # A non-default layout (src/) that ships mloda/__init__.py is still caught. + # src/ layout shipping mloda/__init__.py is still caught. pkg = tmp_path / "src" / "mloda" pkg.mkdir(parents=True) (pkg / "__init__.py").write_text("") @@ -104,7 +87,7 @@ def test_detects_reserved_namespace_in_src_layout(tmp_path: Path) -> None: def test_ignores_excluded_dirs(tmp_path: Path) -> None: - # An installed dependency inside a virtualenv must not trip the guard. + # A dep installed under .venv must not trip the guard. pkg = tmp_path / ".venv" / "lib" / "mloda" pkg.mkdir(parents=True) (pkg / "__init__.py").write_text("")