Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

## 5.4.0 (unreleased)

<!-- Add future changes here -->
- 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)
Expand Down
12 changes: 9 additions & 3 deletions EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/mxdev/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<namespace>:". 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

Expand Down
63 changes: 63 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<namespace>`` or prefixed ``<namespace>:`` 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
Loading