From d1da21a9cead003183214a6013253f70690429e5 Mon Sep 17 00:00:00 2001 From: Dinesh Dutt Date: Tue, 7 Oct 2025 20:51:11 -0700 Subject: [PATCH 1/5] cmdbase: use new match helper function Signed-off-by: Dinesh Dutt --- nubia/internal/cmdbase.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/nubia/internal/cmdbase.py b/nubia/internal/cmdbase.py index 4d94c83..6c4c14e 100644 --- a/nubia/internal/cmdbase.py +++ b/nubia/internal/cmdbase.py @@ -264,7 +264,8 @@ async def run_interactive(self, cmd, args, raw): return 2 sub_inspection = self.subcommand_metadata(subcommand) - instance, remaining_args = self._create_subcommand_obj(args_dict) + instance, remaining_args = self._create_subcommand_obj( + args_dict) assert instance args_dict = remaining_args key_values = copy.copy(args_dict) @@ -276,7 +277,8 @@ async def run_interactive(self, cmd, args, raw): else: # not a super-command, use use the function instead fn = self._fn - positionals = parsed_dict["positionals"] if parsed.positionals != "" else [] + positionals = parsed_dict["positionals"] if parsed.positionals != "" else [ + ] # We only allow positionals for arguments that have positional=True # ِ We filter out the OrderedDict this way to ensure we don't lose the # order of the arguments. We also filter out arguments that have @@ -397,10 +399,14 @@ async def run_interactive(self, cmd, args, raw): for arg, value in args_dict.items(): choices = args_metadata[arg].choices if choices and not isinstance(choices, Callable): + # Import the pattern matching function + from nubia.internal.helpers import matches_choice_pattern + # Validate the choices in the case of values and list of # values. if is_list_type(args_metadata[arg].type): - bad_inputs = [v for v in value if v not in choices] + bad_inputs = [ + v for v in value if not matches_choice_pattern(str(v), choices)] if bad_inputs: cprint( f"Argument '{arg}' got an unexpected " @@ -409,7 +415,7 @@ async def run_interactive(self, cmd, args, raw): "red", ) return 4 - elif value not in choices: + elif not matches_choice_pattern(str(value), choices): cprint( f"Argument '{arg}' got an unexpected value " f"'{value}'. Expected one of " @@ -421,7 +427,8 @@ async def run_interactive(self, cmd, args, raw): # arguments appear to be fine, time to run the function try: # convert argument names back to match the function signature - args_dict = {args_metadata[k].arg: v for k, v in args_dict.items()} + args_dict = { + args_metadata[k].arg: v for k, v in args_dict.items()} ctx.cmd = cmd ctx.raw_cmd = raw ret = await try_await(fn(**args_dict)) From 8f211d35f6540c857aa60c88f958d4081c2fe9f2 Mon Sep 17 00:00:00 2001 From: Dinesh Dutt Date: Tue, 7 Oct 2025 20:51:28 -0700 Subject: [PATCH 2/5] completion: use new match helper to handle completion Signed-off-by: Dinesh Dutt --- nubia/internal/completion.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nubia/internal/completion.py b/nubia/internal/completion.py index 8882ccf..1868a59 100644 --- a/nubia/internal/completion.py +++ b/nubia/internal/completion.py @@ -243,8 +243,13 @@ def _prepare_args_completions( f', got {choices}') else: if parsed_token.last_value: + # Import the pattern matching function + from nubia.internal.helpers import matches_choice_pattern + + # Filter choices based on pattern matching choices = [c for c in arg.choices - if str(c).startswith(parsed_token.last_value)] + if matches_choice_pattern(parsed_token.last_value, [str(c)]) or + str(c).startswith(parsed_token.last_value)] else: choices = arg.choices From 26f5f7078d687536cb6c3ccea252c740ae31fe1b Mon Sep 17 00:00:00 2001 From: Dinesh Dutt Date: Tue, 7 Oct 2025 20:52:09 -0700 Subject: [PATCH 3/5] helpers: add fn to aceept !, ~, !~ with string instead of only matching literally Signed-off-by: Dinesh Dutt --- nubia/internal/helpers.py | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/nubia/internal/helpers.py b/nubia/internal/helpers.py index 1795add..18eec1c 100644 --- a/nubia/internal/helpers.py +++ b/nubia/internal/helpers.py @@ -226,3 +226,56 @@ def suggestions_msg(suggestions: Optional[Iterable[str]]) -> str: return "" else: return f", Did you mean {', '.join(suggestions[:-1])} or {suggestions[-1]}?" + + +def matches_choice_pattern(value: str, choices: list) -> bool: + """ + Check if a value matches any of the choices, supporting pattern matching and negation. + + The function supports two modes: + 1. If the input value contains patterns (~, !, !~), it validates the pattern syntax + 2. If the input value is literal, it matches against the choices list + + Supported input patterns: + - '!pattern' - negation (reject if pattern matches any choice) + - '~pattern' - regex pattern (accept if pattern matches any choice) + - '!~pattern' - negated regex pattern (reject if pattern matches any choice) + - Regular string matching for exact matches + + Args: + value: The value to check (can contain patterns) + choices: List of valid choices (literal values) + + Returns: + bool: True if the value is valid, False otherwise + """ + # If the input value contains patterns, validate the pattern syntax + if value.startswith('!~'): + # Negated regex pattern: !~pattern + pattern = value[2:] + try: + # Test if the regex is valid + re.compile(pattern) + return True # Valid regex syntax + except re.error: + return False # Invalid regex syntax + + elif value.startswith('~'): + # Regex pattern: ~pattern + pattern = value[1:] + try: + # Test if the regex is valid + re.compile(pattern) + return True # Valid regex syntax + except re.error: + return False # Invalid regex syntax + + elif value.startswith('!'): + # Negation pattern: !pattern + literal = value[1:] + # Check if the literal after ! exists in choices + return literal in [str(choice) for choice in choices] + + else: + # Regular literal matching + return value in [str(choice) for choice in choices] From 8926732333780ab06309477a10ebd750cd163269 Mon Sep 17 00:00:00 2001 From: Dinesh Dutt Date: Tue, 7 Oct 2025 20:52:36 -0700 Subject: [PATCH 4/5] sample_commands: Add completions to test support for non-literal matching Signed-off-by: Dinesh Dutt --- example/commands/sample_commands.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/example/commands/sample_commands.py b/example/commands/sample_commands.py index a8f1139..1dfdc64 100644 --- a/example/commands/sample_commands.py +++ b/example/commands/sample_commands.py @@ -51,6 +51,15 @@ async def async_bad_name(): """ cprint("This is async!", "green") +@command("completions") +@argument('name', choices=['harry', 'sally', 'dini', 'pinky', 'maya'], + description="the name you seek") +def completions(name: str): + "Check completions" + cprint(f"{name=}") + + return 0 + @command @argument("number", type=int) @@ -168,7 +177,7 @@ def do_stuff(self, stuff: int): def test_mac(mac): """ Test command for MAC address parsing without quotes. - + Examples: - test_mac 00:01:21:ab:cd:8f - test_mac 1234.abcd.5678 @@ -185,7 +194,7 @@ def test_mac(mac): def test_mac_pos(mac): """ Test command for MAC address parsing as positional argument. - + Examples: - test_mac_pos 00:01:21:ab:cd:8f - test_mac_pos 1234.abcd.5678 From 2c4df4fb321435192cf1bf4b03036a71c5268272 Mon Sep 17 00:00:00 2001 From: Dinesh Dutt Date: Tue, 7 Oct 2025 20:53:05 -0700 Subject: [PATCH 5/5] Additional tests for pattern match Signed-off-by: Dinesh Dutt --- tests/integration_pattern_test.py | 116 ++++++++++++++++++++ tests/pattern_matching_test.py | 169 ++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 tests/integration_pattern_test.py create mode 100644 tests/pattern_matching_test.py diff --git a/tests/integration_pattern_test.py b/tests/integration_pattern_test.py new file mode 100644 index 0000000..0ef4fa6 --- /dev/null +++ b/tests/integration_pattern_test.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 + +# Copyright (c) Facebook, Inc. and its affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# + +""" +Integration test to verify pattern matching works end-to-end with nubia. +""" + +from pattern_matching_example import pattern_demo, file_demo +from tests.util import TestShell +import asyncio +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'example')) + + +class PatternMatchingIntegrationTest: + """Integration test for pattern matching functionality.""" + + async def test_pattern_matching_integration(self): + """Test pattern matching through the full nubia framework.""" + shell = TestShell([pattern_demo, file_demo]) + + print("Testing pattern matching integration with nubia...") + + # Test cases: (command, expected_result, description) + test_cases = [ + # Valid patterns (return None or 0 for success) + ("pattern-demo pattern=a", [0, None], "literal match 'a'"), + ("pattern-demo pattern=a2", [0, None], "regex match '~a.*'"), + ("pattern-demo pattern=a2a1", [0, None], "literal match 'a2a1'"), + + # Invalid patterns (should return 4 for validation error) + ("pattern-demo pattern=a1", 4, "negated by '!a1'"), + ("pattern-demo pattern=b1", 4, "negated by '!~b.*'"), + ("pattern-demo pattern=c", 4, "no matching pattern"), + ] + + all_passed = True + + for cmd, expected_result, description in test_cases: + try: + result = await shell.run_interactive_line(cmd) + if isinstance(expected_result, list): + if result in expected_result: + print(f"✓ {description}: {cmd}") + else: + print( + f"✗ {description}: {cmd} (expected {expected_result}, got {result})") + all_passed = False + else: + if result == expected_result: + print(f"✓ {description}: {cmd}") + else: + print( + f"✗ {description}: {cmd} (expected {expected_result}, got {result})") + all_passed = False + except Exception as e: + print(f"✗ {description}: {cmd} (exception: {e})") + all_passed = False + + # Test file pattern matching + file_test_cases = [ + ("file-demo files=main.py", [0, None], "regex match '~.*\\.py$'"), + ("file-demo files=test_file.py", + [0, None], "regex match '~test_.*'"), + ("file-demo files=data.tmp", 4, "negated by '!~.*\\.tmp$'"), + ("file-demo files=backup_file", 4, "negated by '!~.*_backup'"), + ] + + print("\nTesting file pattern matching...") + for cmd, expected_result, description in file_test_cases: + try: + result = await shell.run_interactive_line(cmd) + if isinstance(expected_result, list): + if result in expected_result: + print(f"✓ {description}: {cmd}") + else: + print( + f"✗ {description}: {cmd} (expected {expected_result}, got {result})") + all_passed = False + else: + if result == expected_result: + print(f"✓ {description}: {cmd}") + else: + print( + f"✗ {description}: {cmd} (expected {expected_result}, got {result})") + all_passed = False + except Exception as e: + print(f"✗ {description}: {cmd} (exception: {e})") + all_passed = False + + return all_passed + + +async def main(): + """Run the integration test.""" + test = PatternMatchingIntegrationTest() + success = await test.test_pattern_matching_integration() + + if success: + print("\n🎉 All pattern matching integration tests passed!") + return 0 + else: + print("\n❌ Some pattern matching integration tests failed!") + return 1 + + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + sys.exit(exit_code) diff --git a/tests/pattern_matching_test.py b/tests/pattern_matching_test.py new file mode 100644 index 0000000..0524eae --- /dev/null +++ b/tests/pattern_matching_test.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 + +# Copyright (c) Facebook, Inc. and its affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# + +import unittest +from nubia.internal.helpers import matches_choice_pattern + + +class PatternMatchingTest(unittest.TestCase): + """Test cases for pattern matching functionality in choice validation.""" + + def test_literal_matching(self): + """Test basic literal string matching.""" + choices = ['a', 'a1', 'b1', 'a2a1'] + + # Should match exact literals + self.assertTrue(matches_choice_pattern('a', choices)) + self.assertTrue(matches_choice_pattern('a1', choices)) + self.assertTrue(matches_choice_pattern('b1', choices)) + self.assertTrue(matches_choice_pattern('a2a1', choices)) + + # Should not match non-existent literals + self.assertFalse(matches_choice_pattern('c', choices)) + self.assertFalse(matches_choice_pattern('a2', choices)) + + def test_negation_matching(self): + """Test negation patterns (!pattern).""" + choices = ['a', 'a1', 'b1', 'a2a1'] + + # Should match literal choices + self.assertTrue(matches_choice_pattern('a', choices)) + self.assertTrue(matches_choice_pattern('a1', choices)) + self.assertTrue(matches_choice_pattern('b1', choices)) + self.assertTrue(matches_choice_pattern('a2a1', choices)) + + # Should accept negation patterns if the negated choice exists + self.assertTrue(matches_choice_pattern( + '!a1', choices)) # a1 exists in choices + self.assertTrue(matches_choice_pattern( + '!b1', choices)) # b1 exists in choices + + # Should reject negation patterns if the negated choice doesn't exist + self.assertFalse(matches_choice_pattern('!c', choices) + ) # c doesn't exist in choices + + # Should not match non-existent literal choices + self.assertFalse(matches_choice_pattern('c', choices)) + + def test_regex_matching(self): + """Test regex patterns (~pattern).""" + choices = ['a', 'a1', 'b1', 'a2a1'] + + # Should match literal choices + self.assertTrue(matches_choice_pattern('a', choices)) + self.assertTrue(matches_choice_pattern('a1', choices)) + self.assertTrue(matches_choice_pattern('a2a1', choices)) + self.assertTrue(matches_choice_pattern('b1', choices)) + + # Should accept valid regex patterns + self.assertTrue(matches_choice_pattern('~a.*', choices)) # valid regex + self.assertTrue(matches_choice_pattern('~b.*', choices)) # valid regex + self.assertTrue(matches_choice_pattern('~.*', choices)) # valid regex + + # Should reject invalid regex patterns + self.assertFalse(matches_choice_pattern( + '~[invalid', choices)) # invalid regex + self.assertFalse(matches_choice_pattern('~(', choices)) # invalid regex + + # Should not match non-existent literal choices + self.assertFalse(matches_choice_pattern('c', choices)) + + def test_negated_regex_matching(self): + """Test negated regex patterns (!~pattern).""" + choices = ['a', 'a1', 'b1', 'a2a1'] + + # Should match literal choices + self.assertTrue(matches_choice_pattern('a', choices)) + self.assertTrue(matches_choice_pattern('a1', choices)) + self.assertTrue(matches_choice_pattern('b1', choices)) + self.assertTrue(matches_choice_pattern('a2a1', choices)) + + # Should accept valid negated regex patterns + self.assertTrue(matches_choice_pattern('!~a.*', choices)) # valid regex + self.assertTrue(matches_choice_pattern('!~b.*', choices)) # valid regex + self.assertTrue(matches_choice_pattern('!~.*', choices)) # valid regex + + # Should reject invalid negated regex patterns + self.assertFalse(matches_choice_pattern( + '!~[invalid', choices)) # invalid regex + self.assertFalse(matches_choice_pattern( + '!~(', choices)) # invalid regex + + # Should not match non-existent literal choices + self.assertFalse(matches_choice_pattern('c', choices)) + + def test_mixed_patterns(self): + """Test mixed patterns with literals, negation, and regex.""" + choices = ['a', 'a1', 'b1', 'c1', 'd1'] + + # Should match literal choices + self.assertTrue(matches_choice_pattern('a', choices)) + self.assertTrue(matches_choice_pattern('a1', choices)) + self.assertTrue(matches_choice_pattern('b1', choices)) + self.assertTrue(matches_choice_pattern('c1', choices)) + self.assertTrue(matches_choice_pattern('d1', choices)) + + # Should accept valid patterns + # negation of existing choice + self.assertTrue(matches_choice_pattern('!a1', choices)) + self.assertTrue(matches_choice_pattern('~b.*', choices)) # valid regex + self.assertTrue(matches_choice_pattern( + '!~c.*', choices)) # valid negated regex + + # Should reject invalid patterns + self.assertFalse(matches_choice_pattern('!e', choices) + ) # negation of non-existent choice + self.assertFalse(matches_choice_pattern( + '~[invalid', choices)) # invalid regex + self.assertFalse(matches_choice_pattern( + '!~[invalid', choices)) # invalid negated regex + + # Should not match non-existent literal choices + self.assertFalse(matches_choice_pattern('e', choices)) + + def test_invalid_regex_handling(self): + """Test handling of invalid regex patterns.""" + choices = ['a', 'b1'] + + # Should match literal choices + self.assertTrue(matches_choice_pattern('a', choices)) + self.assertTrue(matches_choice_pattern('b1', choices)) + + # Should reject invalid regex patterns + self.assertFalse(matches_choice_pattern( + '~[invalid', choices)) # invalid regex + self.assertFalse(matches_choice_pattern( + '!~[invalid', choices)) # invalid negated regex + + # Should not match non-existent patterns + self.assertFalse(matches_choice_pattern('c', choices)) + + def test_empty_choices(self): + """Test behavior with empty choices list.""" + choices = [] + + # Should not match anything with empty choices + self.assertFalse(matches_choice_pattern('a', choices)) + self.assertFalse(matches_choice_pattern('anything', choices)) + + def test_string_conversion(self): + """Test that non-string values are converted to strings.""" + choices = [1, '2', 3.0] + + # Should convert values to strings for matching + self.assertTrue(matches_choice_pattern('1', choices)) + self.assertTrue(matches_choice_pattern('2', choices)) + self.assertTrue(matches_choice_pattern('3.0', choices)) + + # Should not match non-existent values + self.assertFalse(matches_choice_pattern('4', choices)) + + +if __name__ == '__main__': + unittest.main()