diff --git a/CHANGES.md b/CHANGES.md index f7a9a6e..a4ce4b5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,12 @@ ## 5.4.0 (unreleased) - +- Fix: A hook namespace no longer swallows packages whose name merely starts with it. + Section ownership now requires an exact `[namespace]` match or a `[namespace:subsection]` + prefix (the `:` separator cannot occur in a package name), so a hook like `uv` no longer + silently drops packages such as `uvst.addon`. Hook config sections previously written as + `[namespace-section]` must be renamed to `[namespace:section]`. + [jensens] ## 5.3.0 (2026-05-29) diff --git a/EXTENDING.md b/EXTENDING.md index 2363a96..d251da6 100644 --- a/EXTENDING.md +++ b/EXTENDING.md @@ -11,13 +11,19 @@ To avoid naming conflicts, all hook-related settings and config sections must be It is recommended to use the package name containing the hook as a namespace. +Settings keys in `[settings]` and package sections are namespaced with a `namespace-` prefix. +Dedicated hook config sections are named either exactly `[namespace]` or `[namespace:subsection]`. +The `:` separator is used on purpose: it cannot occur in a package name, so a hook namespace +can never accidentally claim a package section whose name merely starts with the same letters +(e.g. a `uv` hook must not swallow a package named `uvst.addon`). + This looks like so: ```INI [settings] myextension-global_setting = 1 -[myextension-section] +[myextension:section] setting = value [foo.bar] @@ -49,7 +55,7 @@ class MyExtension(Hook): # Example: Access hook-specific sections for section_name, section_config in state.configuration.hooks.items(): - if section_name.startswith('myextension-'): + if section_name == 'myextension' or section_name.startswith('myextension:'): # Process your hook's configuration pass @@ -100,7 +106,7 @@ Replace: - Use your package name as namespace prefix - All settings: `namespace-setting_name` -- All sections: `[namespace-section]` +- All sections: `[namespace]` or `[namespace:section]` (the `:` separator cannot occur in a package name, so it prevents collisions with package sections) - This prevents conflicts with other hooks ## Example Use Cases diff --git a/src/mxdev/config.py b/src/mxdev/config.py index 2dea2e0..61ec767 100644 --- a/src/mxdev/config.py +++ b/src/mxdev/config.py @@ -109,8 +109,14 @@ def __init__( self.ignore_keys.append(line) def is_ns_member(name) -> bool: + # A section belongs to a hook only when its name is exactly the + # hook namespace or is prefixed with ":". The colon + # cannot occur in a package name, so it unambiguously separates + # hook sections from package sections and avoids swallowing + # packages that merely start with the namespace (e.g. a "uv" hook + # must not claim a package named "uvst.addon"). for hook in hooks: - if name.startswith(hook.namespace): + if name == hook.namespace or name.startswith(f"{hook.namespace}:"): return True return False diff --git a/tests/test_config.py b/tests/test_config.py index 3fb37b4..b7a48e0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -311,3 +311,66 @@ def test_config_parse_multiple_pushurls(tmp_path): # package2 should have single pushurl (no pushurls list) assert "pushurls" not in config.packages["package2"] assert config.packages["package2"]["pushurl"] == "git@github.com:test/repo2.git" + + +def test_package_name_starting_with_hook_namespace_is_not_swallowed(tmp_path): + """A package whose name merely starts with a hook namespace stays a package. + + Regression: with the ``uv`` hook (namespace ``"uv"``) registered, packages + like ``uvst.addon`` were silently classified as hook sections and dropped + from ``config.packages`` because section ownership used an unanchored + ``str.startswith`` match. + """ + from mxdev.config import Configuration + from mxdev.hooks import Hook + + class UvHook(Hook): + namespace = "uv" + + config_content = """ +[settings] +requirements-in = requirements.txt + +[uvst.addon] +url = https://github.com/example/uvst.addon.git +""" + config_file = tmp_path / "mx.ini" + config_file.write_text(config_content) + + config = Configuration(str(config_file), hooks=[UvHook()]) + + assert "uvst.addon" in config.packages + assert "uvst.addon" not in config.hooks + + +def test_hook_section_with_namespace_delimiter_belongs_to_hook(tmp_path): + """Sections named exactly ```` or prefixed ``:`` are hook sections. + + A colon cannot occur in a package name, so it is the unambiguous delimiter + separating hook sections from package sections. + """ + from mxdev.config import Configuration + from mxdev.hooks import Hook + + class UvHook(Hook): + namespace = "uv" + + config_content = """ +[settings] +requirements-in = requirements.txt + +[uv] +some-setting = value + +[uv:sources] +another = thing +""" + config_file = tmp_path / "mx.ini" + config_file.write_text(config_content) + + config = Configuration(str(config_file), hooks=[UvHook()]) + + assert "uv" in config.hooks + assert "uv:sources" in config.hooks + assert "uv" not in config.packages + assert "uv:sources" not in config.packages