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()