Skip to content

Commit b4cd0c2

Browse files
authored
feat: guard against reserved mloda namespace package roots (#72)
* feat: guard against reserved mloda namespace package roots
1 parent 4618197 commit b4cd0c2

3 files changed

Lines changed: 112 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ placeholder/
6262

6363
This renames `placeholder/` to `<your-package-name>/`, updates `pyproject.toml` (`name`, `authors`, `description`, `packages.find.include`, `pytest.testpaths`), updates `.releaserc.yaml` (`message`, `repositoryUrl`), and rewrites `from placeholder.` imports across the package.
6464

65-
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.
65+
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.
6666

6767
#### 2. Verify setup
6868

bin/customize.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ if [[ "$PACKAGE" == "placeholder" ]]; then
6969
exit 2
7070
fi
7171

72+
if [[ "$PACKAGE" == "mloda" || "$PACKAGE" == "mloda_plugins" ]]; then
73+
echo "Error: package name '$PACKAGE' is reserved by the mloda namespace." >&2
74+
echo "The core mloda package is a shared PEP 420 namespace; a plugin that ships" >&2
75+
echo "mloda/__init__.py would make mloda unimportable for everyone who installs it." >&2
76+
echo "Choose a name unique to your organization (e.g. acme, my_org)." >&2
77+
exit 2
78+
fi
79+
7280
if ! [[ "$PACKAGE" =~ ^[a-z][a-z0-9_]*$ ]]; then
7381
echo "Error: package name must be lowercase letters/digits/underscores, starting with a letter." >&2
7482
echo "Got: $PACKAGE" >&2

tests/test_reserved_namespace.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Guard: a template-born plugin must never ship ``mloda/__init__.py``.
2+
3+
``mloda`` is a shared PEP 420 namespace package; a distribution that ships
4+
``mloda/__init__.py`` collapses it and makes mloda unimportable. See
5+
https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/
6+
"""
7+
8+
import os
9+
from pathlib import Path
10+
11+
# Names a plugin must not use for a package root (``mloda_plugins`` is reserved too).
12+
RESERVED_NAMESPACE_ROOTS = ("mloda", "mloda_plugins")
13+
14+
# Dirs that never ship; pruned so an installed dep (e.g. mloda under .venv) is ignored.
15+
EXCLUDED_DIRS = frozenset(
16+
{".git", ".venv", "venv", ".tox", ".mypy_cache", ".ruff_cache", ".pytest_cache", "__pycache__", "build", "dist"}
17+
)
18+
19+
REPO_ROOT = Path(__file__).resolve().parent.parent
20+
21+
22+
def find_reserved_namespace_violations(root: Path) -> list[str]:
23+
"""Flag a reserved top-level package root, or a reserved ``<name>/__init__.py``
24+
anywhere in the tree (so ``src/``-style layouts are covered too)."""
25+
violations: list[str] = []
26+
27+
for name in RESERVED_NAMESPACE_ROOTS:
28+
if (root / name).is_dir():
29+
violations.append(
30+
f"package root directory '{name}/' uses the reserved mloda namespace; "
31+
"rename it (see README 'Setup Your Plugin')"
32+
)
33+
34+
for dirpath, dirnames, _ in os.walk(root):
35+
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS and not d.endswith(".egg-info")]
36+
for name in RESERVED_NAMESPACE_ROOTS:
37+
init_file = Path(dirpath) / name / "__init__.py"
38+
if init_file.is_file():
39+
rel = init_file.relative_to(root).as_posix()
40+
violations.append(
41+
f"'{rel}' would ship in the built distribution and collapse the PEP 420 "
42+
"'mloda' namespace package, making mloda unimportable for anyone who "
43+
"installs this plugin"
44+
)
45+
46+
return violations
47+
48+
49+
def test_repo_has_no_reserved_namespace_root() -> None:
50+
"""Live guard: this repo must not ship into the mloda namespace."""
51+
violations = find_reserved_namespace_violations(REPO_ROOT)
52+
assert not violations, "Reserved namespace violation(s):\n" + "\n".join(violations)
53+
54+
55+
def test_detects_reserved_root_with_init(tmp_path: Path) -> None:
56+
pkg = tmp_path / "mloda"
57+
pkg.mkdir()
58+
(pkg / "__init__.py").write_text("")
59+
60+
violations = find_reserved_namespace_violations(tmp_path)
61+
62+
# Root-name check and shipped-__init__.py check both fire.
63+
assert len(violations) == 2
64+
assert any("__init__.py" in v for v in violations)
65+
66+
67+
def test_detects_reserved_root_without_init(tmp_path: Path) -> None:
68+
# Rejected even as a bare namespace dir (no __init__.py).
69+
(tmp_path / "mloda_plugins").mkdir()
70+
71+
violations = find_reserved_namespace_violations(tmp_path)
72+
73+
assert len(violations) == 1
74+
assert "mloda_plugins/" in violations[0]
75+
76+
77+
def test_detects_reserved_namespace_in_src_layout(tmp_path: Path) -> None:
78+
# src/ layout shipping mloda/__init__.py is still caught.
79+
pkg = tmp_path / "src" / "mloda"
80+
pkg.mkdir(parents=True)
81+
(pkg / "__init__.py").write_text("")
82+
83+
violations = find_reserved_namespace_violations(tmp_path)
84+
85+
assert len(violations) == 1
86+
assert "src/mloda/__init__.py" in violations[0]
87+
88+
89+
def test_ignores_excluded_dirs(tmp_path: Path) -> None:
90+
# A dep installed under .venv must not trip the guard.
91+
pkg = tmp_path / ".venv" / "lib" / "mloda"
92+
pkg.mkdir(parents=True)
93+
(pkg / "__init__.py").write_text("")
94+
95+
assert find_reserved_namespace_violations(tmp_path) == []
96+
97+
98+
def test_clean_tree_has_no_violations(tmp_path: Path) -> None:
99+
pkg = tmp_path / "acme"
100+
pkg.mkdir()
101+
(pkg / "__init__.py").write_text("")
102+
103+
assert find_reserved_namespace_violations(tmp_path) == []

0 commit comments

Comments
 (0)