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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ and versions are tracked in the repo-root `VERSION` file.
those policies through an explicit `CliProfile`.
- Make `Context.user_config` an opaque consumer-owned value and remove the
Base-shaped `UserConfig` types from the public package facade.
- Make command-filter normalization consumer-neutral by default. Consumers
can provide a normalizer callback for legacy prefixes or aliases.
- Make command protocol schemas consumer-owned. The generic protocol now ships
only framing and validation, with `COMMAND_PROTOCOL_V1` as its default
header; consumers can register schemas and preserve a legacy header through
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ registration. It ships with no application record types and uses
pass a compatibility `protocol_header` when it must interoperate with an
existing peer protocol.

Command filters use consumer-neutral name normalization by default. Consumers
with legacy command names can pass a `normalizer` callback to
`normalize_command_filter`, `normalize_command_filters`, and
`command_matches` to define compatibility aliases or prefixes.

Low-level implementation helpers are intentionally not included in the
module `__all__` surfaces. Downstream code should use the documented facade or
the explicitly supported symbols from those modules.
Expand Down
3 changes: 2 additions & 1 deletion lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def _resolve_version() -> str:

from . import command_filters, command_protocol, history, testing
from .app import App, argument, command, delegated_display_command, option, run_app
from .command_filters import command_matches, normalize_command_filter, normalize_command_filters
from .command_filters import CommandFilterNormalizer, command_matches, normalize_command_filter, normalize_command_filters
from .command_protocol import (
BOOLEAN,
NULLABLE_STRING,
Expand Down Expand Up @@ -64,6 +64,7 @@ def _resolve_version() -> str:
"__version__",
"BOOLEAN",
"CliProfile",
"CommandFilterNormalizer",
"CommandProtocolError",
"Context",
"ExitCode",
Expand Down
49 changes: 41 additions & 8 deletions lib/python/base_cli/command_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,69 @@

from __future__ import annotations

from collections.abc import Callable
from typing import TypeAlias


__all__ = [
"CommandFilterNormalizer",
"command_matches",
"normalize_command_filter",
"normalize_command_filters",
]


def normalize_command_filter(value: str) -> str:
"""Normalize one public or internal command name for matching."""
CommandFilterNormalizer: TypeAlias = Callable[[str], str]


def _normalize_command_name(value: str) -> str:
return value.replace("_", "-")


def normalize_command_filter(
value: str,
*,
normalizer: CommandFilterNormalizer | None = None,
) -> str:
"""Normalize one command name for matching.

``normalizer`` lets a consumer apply its own compatibility policy before
the generic underscore-to-hyphen conversion. The callback receives a
stripped, lower-case command name and must return its canonical name.
"""

normalized = value.strip().lower().removeprefix("base_")
return normalized.replace("_", "-")
normalized = value.strip().lower()
if normalizer is not None:
normalized = normalizer(normalized)
return _normalize_command_name(normalized)


def normalize_command_filters(value: str | None) -> tuple[str, ...]:
def normalize_command_filters(
value: str | None,
*,
normalizer: CommandFilterNormalizer | None = None,
) -> tuple[str, ...]:
"""Normalize a comma-separated command filter and reject empty entries."""

if value is None:
return ()
parts = value.split(",")
if any(not part.strip() for part in parts):
raise ValueError("Option '--command' expects comma-separated command names without empty entries.")
normalized = tuple(dict.fromkeys(normalize_command_filter(part) for part in parts))
normalized = tuple(
dict.fromkeys(normalize_command_filter(part, normalizer=normalizer) for part in parts)
)
if not normalized or any(not command for command in normalized):
raise ValueError("Option '--command' expects at least one command name.")
return normalized


def command_matches(value: str, command_filters: tuple[str, ...]) -> bool:
def command_matches(
value: str,
command_filters: tuple[str, ...],
*,
normalizer: CommandFilterNormalizer | None = None,
) -> bool:
"""Return whether a command value matches one of the normalized filters."""

return normalize_command_filter(value) in command_filters
return normalize_command_filter(value, normalizer=normalizer) in command_filters
36 changes: 36 additions & 0 deletions tests/test_command_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

import unittest

from base_cli.command_filters import command_matches
from base_cli.command_filters import normalize_command_filter
from base_cli.command_filters import normalize_command_filters


def strip_tool_prefix(value: str) -> str:
return value.removeprefix("tool_")


class CommandFilterTests(unittest.TestCase):
def test_default_normalization_is_consumer_neutral(self) -> None:
self.assertEqual(normalize_command_filter("build_target"), "build-target")
self.assertEqual(normalize_command_filter("base_build"), "base-build")

def test_consumer_normalizer_can_define_prefix_policy(self) -> None:
self.assertEqual(
normalize_command_filter("TOOL_BUILD", normalizer=strip_tool_prefix),
"build",
)
self.assertEqual(
normalize_command_filters("build, tool_build", normalizer=strip_tool_prefix),
("build",),
)

def test_command_matches_uses_the_same_consumer_policy(self) -> None:
filters = normalize_command_filters("build", normalizer=strip_tool_prefix)
self.assertTrue(command_matches("tool_build", filters, normalizer=strip_tool_prefix))
self.assertFalse(command_matches("tool_release", filters, normalizer=strip_tool_prefix))


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion tests/test_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def test_facade_exports_supported_modules_functions_and_types(self) -> None:
def test_module_all_surfaces_are_explicit(self) -> None:
self.assertEqual(
set(command_filters.__all__),
{"command_matches", "normalize_command_filter", "normalize_command_filters"},
{"CommandFilterNormalizer", "command_matches", "normalize_command_filter", "normalize_command_filters"},
)
self.assertEqual(
set(command_protocol.__all__),
Expand Down