From 05e153575c846da40e944f67187d778d88c690ae Mon Sep 17 00:00:00 2001 From: BMH Date: Sat, 31 Jan 2026 11:22:26 +0100 Subject: [PATCH 1/4] feat: add support for shortdesc, tags and examples in searchbnf.conf --- docs/custom_search_commands.md | 29 ++++++ .../conf_files/create_searchbnf_conf.py | 3 + .../global_config_validator.py | 3 + .../schema/schema.json | 34 +++++++ .../conf_files/searchbnf_conf.template | 10 ++ .../conf_files/test_create_searchbnf_conf.py | 94 +++++++++++++++++++ tests/unit/test_global_config.py | 12 +++ tests/unit/testdata/valid_config.json | 12 +++ 8 files changed, 197 insertions(+) diff --git a/docs/custom_search_commands.md b/docs/custom_search_commands.md index 0d367dda0a..f9435b3cc0 100644 --- a/docs/custom_search_commands.md +++ b/docs/custom_search_commands.md @@ -63,7 +63,10 @@ python.version = python3 | requiredSearchAssistant | boolean | Specifies whether search assistance is required for the custom search command. Default: false. | | usage | string | Defines the usage of custom search command. It can be one of `public`, `private` and `deprecated`. | | description | string | Provide description of the custom search command. | +| shortdesc | string | A one sentence description of the search command, used for searchbnf.conf | | syntax | string | Provide syntax for custom search command | +| tags | string | One or more words that users might type into the search bar which are similar to the command name. | +| examples | array[objects] | List of example search strings, used for searchbnf.conf | To generate a custom search command, the following attributes must be defined in globalConfig: `commandName`, `commandType`, `fileName`, and `arguments`. Based on the provided commandType, UCC will generate a template Python file and integrate the user-defined logic into it. @@ -133,6 +136,26 @@ For example: ``` +## Examples (for search command usage) + +| Property | Type | Description | +| ------------------------------------------------ | ------ | ------------------------------------------------ | +| search\* | string | Example search command | +| comment\* | string | Provide description of the example search string | + +Each search command can have multiple examples, which are shown displayed in the search assistant. The Compact mode, only shows the first example. In the Full mode, the top three examples are displayed. + +For example: + +```json +"examples": [ + { + "search": "generatetextcommand count=5 text=\"Hallo There\"", + "comment": "Generates 5 \"Hallo There\" events enumerated starting by 1" + } +] +``` + ## Example ``` json @@ -161,6 +184,12 @@ For example: "name": "text", "required": true } + ], + "examples": [ + { + "search": "generatetextcommand count=5 text=\"Hallo There\"", + "comment": "Generates 5 \"Hallo There\" events enumerated starting by 1" + } ] }, ], diff --git a/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py b/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py index 00e63c7f61..b39a5a81a9 100644 --- a/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py +++ b/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py @@ -34,8 +34,11 @@ def __init__( searchbnf_dict = { "command_name": command["commandName"], "description": command["description"], + "shortdesc": command.get("shortdesc", None), "syntax": command["syntax"], "usage": command["usage"], + "tags": command.get("tags", None), + "examples": command.get("examples", []), } self.searchbnf_info.append(searchbnf_dict) diff --git a/splunk_add_on_ucc_framework/global_config_validator.py b/splunk_add_on_ucc_framework/global_config_validator.py index bbbee491f0..45b0e201f8 100644 --- a/splunk_add_on_ucc_framework/global_config_validator.py +++ b/splunk_add_on_ucc_framework/global_config_validator.py @@ -730,8 +730,11 @@ def _validate_custom_search_commands(self) -> None: if (command.get("requiredSearchAssistant", False) is False) and ( command.get("description") + or command.get("shortdesc") or command.get("usage") or command.get("syntax") + or command.get("tags") + or command.get("examples") ): logger.warning( "requiredSearchAssistant is set to false " diff --git a/splunk_add_on_ucc_framework/schema/schema.json b/splunk_add_on_ucc_framework/schema/schema.json index 6e17fa0893..b57a553390 100644 --- a/splunk_add_on_ucc_framework/schema/schema.json +++ b/splunk_add_on_ucc_framework/schema/schema.json @@ -461,6 +461,10 @@ "type": "string", "description": "Description of the custom search command. It is an required attribute for searchbnf.conf." }, + "shortdesc": { + "type": "string", + "description": "Short description or the custom search command, used as shortdesc in searchbnf.conf" + }, "syntax": { "type": "string", "maxLength": 100, @@ -475,12 +479,23 @@ "deprecated" ] }, + "tags": { + "type": "string", + "description": "One or more words that users might type into the search bar which are similar to the command name." + }, "arguments": { "type": "array", "items": { "$ref": "#/definitions/arguments" }, "minItems": 1 + }, + "examples": { + "type": "array", + "items": { + "$ref": "#/definitions/searchExample" + }, + "minItems": 1 } }, "required": [ @@ -526,6 +541,25 @@ ], "additionalProperties": false }, + "searchExample": { + "type": "object", + "description": "Search example used for searchbnf.conf", + "properties": { + "search": { + "type": "string", + "description": "Example search string" + }, + "comment": { + "type": "string", + "description": "Comment for the search string which is used for searchbnf.conf" + } + }, + "required": [ + "search", + "comment" + ], + "additionalProperties": false + }, "CustomSearchCommandValidator": { "type": "object", "description": "It is used to validate the values of arguments for custom search command", diff --git a/splunk_add_on_ucc_framework/templates/conf_files/searchbnf_conf.template b/splunk_add_on_ucc_framework/templates/conf_files/searchbnf_conf.template index 5896c68794..2cd06743c0 100644 --- a/splunk_add_on_ucc_framework/templates/conf_files/searchbnf_conf.template +++ b/splunk_add_on_ucc_framework/templates/conf_files/searchbnf_conf.template @@ -2,5 +2,15 @@ [{{info["command_name"]}}-command] syntax = {{info["syntax"]}} description = {{info["description"]}} +{% if info["shortdesc"]%} +shortdesc = {{info["shortdesc"]}} +{% endif %} usage = {{info["usage"]}} +{% if info["tags"]%} +tags = {{info["tags"]}} +{% endif %} +{% for example in info["examples"] %} +example{{ loop.index }} = {{ example["search"] }} +comment{{ loop.index }} = {{ example["comment"] }} +{% endfor -%} {% endfor -%} \ No newline at end of file diff --git a/tests/unit/generators/conf_files/test_create_searchbnf_conf.py b/tests/unit/generators/conf_files/test_create_searchbnf_conf.py index 25b18cfe96..ba1f459eeb 100644 --- a/tests/unit/generators/conf_files/test_create_searchbnf_conf.py +++ b/tests/unit/generators/conf_files/test_create_searchbnf_conf.py @@ -14,6 +14,21 @@ def custom_search_command_without_search_assistance(): ] +@fixture +def custom_search_command_without_optional_search_assistance_params(): + return [ + { + "commandName": "generatetextcommand", + "commandType": "generating", + "fileName": "generatetext.py", + "requiredSearchAssistant": True, + "description": "This command generates COUNT occurrences of a TEXT string.", + "syntax": "generatetextcommand count= text=", + "usage": "public", + } + ] + + def test_init_without_custom_command( global_config_only_configuration, input_dir, @@ -42,8 +57,20 @@ def test_init( { "command_name": "generatetextcommand", "description": "This command generates COUNT occurrences of a TEXT string.", + "shortdesc": "Command for generating string events.", "syntax": "generatetextcommand count= text=", "usage": "public", + "tags": "text generator", + "examples": [ + { + "search": '| generatetextcommand count=5 text="example string"', + "comment": 'Generates 5 events with text="example string"', + }, + { + "search": '| generatetextcommand count=10 text="another example string"', + "comment": 'Generates 10 events with text="another example string"', + }, + ], } ] @@ -65,6 +92,33 @@ def test_init_without_search_assistance( assert searchbnf_conf.searchbnf_info == [] +def test_init_without_optional_search_assistance_params( + global_config_all_json, + input_dir, + output_dir, + custom_search_command_without_optional_search_assistance_params, +): + global_config_all_json._content["customSearchCommand"] = ( + custom_search_command_without_optional_search_assistance_params + ) + searchbnf_conf = SearchbnfConf( + global_config_all_json, + input_dir, + output_dir, + ) + assert searchbnf_conf.searchbnf_info == [ + { + "command_name": "generatetextcommand", + "description": "This command generates COUNT occurrences of a TEXT string.", + "syntax": "generatetextcommand count= text=", + "usage": "public", + "examples": [], + "shortdesc": None, + "tags": None, + } + ] + + def test_generate_conf_without_custom_command( global_config_only_configuration, input_dir, @@ -82,6 +136,46 @@ def test_generate_conf_without_custom_command( def test_generate_conf(global_config_all_json, input_dir, output_dir): + ta_name = global_config_all_json.product + searchbnf_conf = SearchbnfConf( + global_config_all_json, + input_dir, + output_dir, + ) + output = searchbnf_conf.generate() + exp_fname = "searchbnf.conf" + expected_content = dedent( + """ + [generatetextcommand-command] + syntax = generatetextcommand count= text= + description = This command generates COUNT occurrences of a TEXT string. + shortdesc = Command for generating string events. + usage = public + tags = text generator + example1 = | generatetextcommand count=5 text="example string" + comment1 = Generates 5 events with text="example string" + example2 = | generatetextcommand count=10 text="another example string" + comment2 = Generates 10 events with text="another example string" + """ + ).lstrip() + assert output == [ + { + "file_name": exp_fname, + "file_path": f"{output_dir}/{ta_name}/default/{exp_fname}", + "content": expected_content, + } + ] + + +def test_generate_conf_without_optional_search_assistance_params( + global_config_all_json, + input_dir, + output_dir, + custom_search_command_without_optional_search_assistance_params, +): + global_config_all_json._content["customSearchCommand"] = ( + custom_search_command_without_optional_search_assistance_params + ) ta_name = global_config_all_json.product searchbnf_conf = SearchbnfConf( global_config_all_json, diff --git a/tests/unit/test_global_config.py b/tests/unit/test_global_config.py index 64744a82e0..9b136a143f 100644 --- a/tests/unit/test_global_config.py +++ b/tests/unit/test_global_config.py @@ -76,8 +76,10 @@ def test_global_config_custom_search_commands(global_config_all_json): "commandType": "generating", "requiredSearchAssistant": True, "description": "This command generates COUNT occurrences of a TEXT string.", + "shortdesc": "Command for generating string events.", "syntax": "generatetextcommand count= text=", "usage": "public", + "tags": "text generator", "arguments": [ { "name": "count", @@ -86,6 +88,16 @@ def test_global_config_custom_search_commands(global_config_all_json): }, {"name": "text", "required": True}, ], + "examples": [ + { + "search": '| generatetextcommand count=5 text="example string"', + "comment": 'Generates 5 events with text="example string"', + }, + { + "search": '| generatetextcommand count=10 text="another example string"', + "comment": 'Generates 10 events with text="another example string"', + }, + ], } ] assert expected_result == custom_search_commands diff --git a/tests/unit/testdata/valid_config.json b/tests/unit/testdata/valid_config.json index 2857bb8913..a62aa8ce23 100644 --- a/tests/unit/testdata/valid_config.json +++ b/tests/unit/testdata/valid_config.json @@ -1377,8 +1377,10 @@ "commandType": "generating", "requiredSearchAssistant": true, "description": "This command generates COUNT occurrences of a TEXT string.", + "shortdesc": "Command for generating string events.", "syntax": "generatetextcommand count= text=", "usage": "public", + "tags": "text generator", "arguments": [ { "name": "count", @@ -1393,6 +1395,16 @@ "name": "text", "required": true } + ], + "examples": [ + { + "search": "| generatetextcommand count=5 text=\"example string\"", + "comment": "Generates 5 events with text=\"example string\"" + }, + { + "search": "| generatetextcommand count=10 text=\"another example string\"", + "comment": "Generates 10 events with text=\"another example string\"" + } ] } ] From 1abdf457bd49fa15d9eda8d8231ad6907a59863b Mon Sep 17 00:00:00 2001 From: BMH Date: Thu, 5 Feb 2026 12:29:42 +0100 Subject: [PATCH 2/4] feat: added support for the validators Set, Match, List, Map and Duration for custom command arguments. --- docs/custom_search_commands.md | 53 +++++++- .../create_custom_command_python.py | 13 ++ .../schema/schema.json | 120 ++++++++++++++++++ .../test_create_custom_command_python.py | 91 +++++++++++++ tests/unit/test_global_config.py | 19 +++ tests/unit/testdata/valid_config.json | 30 +++++ 6 files changed, 322 insertions(+), 4 deletions(-) diff --git a/docs/custom_search_commands.md b/docs/custom_search_commands.md index f9435b3cc0..92bb61931b 100644 --- a/docs/custom_search_commands.md +++ b/docs/custom_search_commands.md @@ -86,9 +86,9 @@ If `requiredSearchAssistant` is set to True, the `syntax`, `description`, and `u | name\* | string | Name of the argument | | defaultValue | string/number | Default value of the argument. | | required | boolean | Specify if the argument is required or not. | -| validate | object | Specify validation for the argument. It can be any of `Integer`, `Float`, `Boolean`, `RegularExpression` or `FieldName`. | +| validate | object | Specify validation for the argument. It can be any of `Integer`, `Float`, `Boolean`, `RegularExpression`, `FieldName`, `Set`, `Match`, `List`, `Map`, `Duration`. | -UCC currently supports five types of validations provided by `splunklib` library: +UCC currently supports some types of validations provided by `splunklib` library: - IntegerValidator + you can optionally define `minimum` and `maximum` properties. @@ -98,10 +98,26 @@ UCC currently supports five types of validations provided by `splunklib` library + no additional properties required. - RegularExpressionValidator + no additional properties required. + + validates if the argument value is a valid regex expression. - FieldnameValidator + no additional properties required. +- SetValidator + + the property `values` is required, which is a list of allowed strings. + + validates if the values list contains the argument value. +- MatchValidator + + the properties `name` and `pattern` is required, where the name is only used for error messages and the pattern must be a valid regex pattern. + + validates of the argument value matches the specified regex expression. +- ListValidator + + no additional properties required. + + validates if the argument value is a valid list and passes the parsed list to the property. +- MapValidator + + the property `map` is required, where the map must be a dictionary of key value pairs where the key must be a string and the value must either be a string, a number or a boolean. + + validates if the argument matches a key of the dictionary and passes the corresponding value to the property. +- DurationValidator + + no additional properties required. + -For more information, refer [splunklib API docs](https://splunk-python-sdk.readthedocs.io/en/latest/searchcommands.html) +For more information, refer [splunklib API docs](https://splunk-python-sdk.readthedocs.io/en/latest/searchcommands.html) or [validators.py source](https://github.com/splunk/splunk-sdk-python/blob/develop/splunklib/searchcommands/validators.py). For example: @@ -131,9 +147,38 @@ For example: "minimum": "85.5" } + }, + { + "name": "animals", + "validate": { + "type": "Set", + "values": [ + "cat", + "dog", + "wombat" + ] + } + }, + { + "name": "name", + "validate": { + "type": "Match", + "name": "Name pattern", + "pattern": "^[A-Z][a-z]+$" + } + }, + { + "name": "urgency", + "validate": { + "type": "Map", + "map": { + "high": 3, + "medium": 2, + "low": 1 + } + } } ] - ``` ## Examples (for search command usage) diff --git a/splunk_add_on_ucc_framework/generators/python_files/create_custom_command_python.py b/splunk_add_on_ucc_framework/generators/python_files/create_custom_command_python.py index 40d7c7a5fd..d459a09905 100644 --- a/splunk_add_on_ucc_framework/generators/python_files/create_custom_command_python.py +++ b/splunk_add_on_ucc_framework/generators/python_files/create_custom_command_python.py @@ -92,6 +92,18 @@ def argument_generator( if args else f", validate=validators.{validate_type}()" ) + elif validate_type == "Set": + allowed_values = validate.get("values") + validate_str = ( + f", validate=validators.Set({str(allowed_values).strip('[]')})" + ) + elif validate_type == "Map": + option_map = validate.get("map") + validate_str = f", validate=validators.Map(**{str(option_map)})" + elif validate_type == "Match": + name = validate.get("name") + pattern = validate.get("pattern") + validate_str = f", validate=validators.Match('{name}', '{pattern}')" else: validate_str = f", validate=validators.{validate_type}()" @@ -108,6 +120,7 @@ def argument_generator( f"{validate_str}, " f"default='{arg.get('default', '')}')" ) + argument_list.append(arg_str) return argument_list diff --git a/splunk_add_on_ucc_framework/schema/schema.json b/splunk_add_on_ucc_framework/schema/schema.json index b57a553390..63ef5c29e0 100644 --- a/splunk_add_on_ucc_framework/schema/schema.json +++ b/splunk_add_on_ucc_framework/schema/schema.json @@ -578,6 +578,21 @@ }, { "$ref": "#/definitions/CustomBooleanValidator" + }, + { + "$ref": "#/definitions/CustomSetValidator" + }, + { + "$ref": "#/definitions/CustomMatchValidator" + }, + { + "$ref": "#/definitions/CustomListValidator" + }, + { + "$ref": "#/definitions/CustomMapValidator" + }, + { + "$ref": "#/definitions/CustomDurationValidator" } ] }, @@ -667,6 +682,111 @@ ], "additionalProperties": false }, + "CustomSetValidator": { + "type": "object", + "properties": { + "type": { + "const": "Set", + "type": "string", + "description": "Validates value against a set of allowed values." + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of allowed values." + } + }, + "required": [ + "type", + "values" + ], + "additionalProperties": false + }, + "CustomMatchValidator": { + "type": "object", + "properties": { + "type": { + "const": "Match", + "type": "string", + "description": "Validates option values by regex pattern" + }, + "name": { + "type": "string", + "description": "Name for the pattern, which is used for the error message." + }, + "pattern": { + "type": "string", + "description": "Regular expression pattern to validate against." + } + }, + "required": [ + "type", + "pattern" + ], + "additionalProperties": false + }, + "CustomListValidator": { + "type": "object", + "properties": { + "type": { + "const": "List", + "type": "string", + "description": "Validates a list of strings." + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "CustomMapValidator": { + "type": "object", + "properties": { + "type": { + "const": "Map", + "type": "string", + "description": "Validates map option values where the value must be a valid key which is replaced by the value.." + }, + "map": { + "type": "object", + "description": "Map which is used to validate and translate option values.", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } + }, + "required": [ + "type", + "map" + ], + "additionalProperties": false + }, + "CustomDurationValidator": { + "type": "object", + "properties": { + "type": { + "const": "Duration", + "type": "string", + "description": "Validates duration option values." + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, "ConfigurationPage": { "type": "object", "properties": { diff --git a/tests/unit/generators/python_files/test_create_custom_command_python.py b/tests/unit/generators/python_files/test_create_custom_command_python.py index 400ec2b9c8..105bc56e6b 100644 --- a/tests/unit/generators/python_files/test_create_custom_command_python.py +++ b/tests/unit/generators/python_files/test_create_custom_command_python.py @@ -66,6 +66,63 @@ def transforming_custom_search_command(): ] +@pytest.fixture +def custom_search_command_validators(): + return [ + { + "commandName": "testcommand", + "commandType": "generating", + "fileName": "test.py", + "requiredSearchAssistant": True, + "description": "This is test command", + "syntax": "testcommand count= text=", + "usage": "public", + "arguments": [ + { + "name": "count", + "required": True, + "validate": {"type": "Integer", "minimum": 5, "maximum": 10}, + }, + { + "name": "max_word", + "validate": {"type": "Integer", "maximum": 100}, + }, + { + "name": "age", + "validate": {"type": "Integer", "minimum": 18}, + }, + {"name": "text", "required": True, "defaultValue": "test_text"}, + {"name": "contains"}, + {"name": "fieldname", "validate": {"type": "Fieldname"}}, + { + "name": "animals", + "validate": {"type": "Set", "values": ["cat", "dog", "wombat"]}, + }, + { + "name": "name", + "validate": { + "type": "Match", + "name": "Name pattern", + "pattern": "^[A-Z][a-z]+$", + }, + }, + { + "name": "urgency", + "validate": { + "type": "Map", + "map": {"high": 3, "medium": 2.2, "low": "one"}, + }, + }, + { + "name": "volume", + "validate": {"type": "Float", "minimum": 2.2, "maximum": 197.45}, + "required": True, + }, + ], + } + ] + + def test_for_transforming_command_with_error( transforming_custom_search_command, global_config_all_json, @@ -147,6 +204,37 @@ def test_for_transforming_command_without_map( ] +def test_for_search_command_validators( + global_config_all_json, + input_dir, + output_dir, + custom_search_command_validators, +): + global_config_all_json._content["customSearchCommand"] = ( + custom_search_command_validators + ) + custom_command_py = CustomCommandPy( + global_config_all_json, + input_dir, + output_dir, + ) + + assert custom_command_py.commands_info[0]["list_arg"] == [ + "count = Option(name='count', require=True, " + "validate=validators.Integer(minimum=5, maximum=10))", + "max_word = Option(name='max_word', require=False, validate=validators.Integer(maximum=100))", + "age = Option(name='age', require=False, validate=validators.Integer(minimum=18))", + "text = Option(name='text', require=True, default='test_text')", + "contains = Option(name='contains', require=False)", + "fieldname = Option(name='fieldname', require=False, validate=validators.Fieldname())", + "animals = Option(name='animals', require=False, validate=validators.Set('cat', 'dog', 'wombat'))", + "name = Option(name='name', require=False, validate=validators.Match('Name pattern', '^[A-Z][a-z]+$'))", + "urgency = Option(name='urgency', require=False, " + "validate=validators.Map(**{'high': 3, 'medium': 2.2, 'low': 'one'}))", + "volume = Option(name='volume', require=True, validate=validators.Float(minimum=2.2, maximum=197.45))", + ] + + def test_init_without_custom_command( global_config_only_configuration, input_dir, @@ -240,6 +328,9 @@ class GeneratetextcommandCommand(GeneratingCommand): """ count = Option(name='count', require=True, validate=validators.Integer(minimum=5, maximum=10)) text = Option(name='text', require=True) + animals = Option(name='animals', require=False, validate=validators.Set('cat', 'dog', 'wombat')) + name = Option(name='name', require=False, validate=validators.Match('Name pattern', '^[A-Z][a-z]+$')) + urgency = Option(name='urgency', require=False, validate=validators.Map(**{'high': 3, 'medium': 2.2, 'low': 'one'})) def generate(self): return generate(self) diff --git a/tests/unit/test_global_config.py b/tests/unit/test_global_config.py index 9b136a143f..63a3088e8e 100644 --- a/tests/unit/test_global_config.py +++ b/tests/unit/test_global_config.py @@ -87,6 +87,25 @@ def test_global_config_custom_search_commands(global_config_all_json): "validate": {"type": "Integer", "minimum": 5, "maximum": 10}, }, {"name": "text", "required": True}, + { + "name": "animals", + "validate": {"type": "Set", "values": ["cat", "dog", "wombat"]}, + }, + { + "name": "name", + "validate": { + "type": "Match", + "name": "Name pattern", + "pattern": "^[A-Z][a-z]+$", + }, + }, + { + "name": "urgency", + "validate": { + "type": "Map", + "map": {"high": 3, "medium": 2.2, "low": "one"}, + }, + }, ], "examples": [ { diff --git a/tests/unit/testdata/valid_config.json b/tests/unit/testdata/valid_config.json index a62aa8ce23..1b6399ff5f 100644 --- a/tests/unit/testdata/valid_config.json +++ b/tests/unit/testdata/valid_config.json @@ -1394,6 +1394,36 @@ { "name": "text", "required": true + }, + { + "name": "animals", + "validate": { + "type": "Set", + "values": [ + "cat", + "dog", + "wombat" + ] + } + }, + { + "name": "name", + "validate": { + "type": "Match", + "name": "Name pattern", + "pattern": "^[A-Z][a-z]+$" + } + }, + { + "name": "urgency", + "validate": { + "type": "Map", + "map": { + "high": 3, + "medium": 2.2, + "low": "one" + } + } } ], "examples": [ From b49a2c86c368181bc494151eeba9233e43ea1c2d Mon Sep 17 00:00:00 2001 From: BMH Date: Thu, 5 Feb 2026 23:13:17 +0100 Subject: [PATCH 3/4] feat: added automatic syntax generation for custom commands --- docs/custom_search_commands.md | 19 +-- .../conf_files/create_searchbnf_conf.py | 50 +++++++- .../global_config_validator.py | 4 +- .../schema/schema.json | 10 +- .../conf_files/test_create_searchbnf_conf.py | 114 ++++++++++++++++++ 5 files changed, 184 insertions(+), 13 deletions(-) diff --git a/docs/custom_search_commands.md b/docs/custom_search_commands.md index 92bb61931b..2a8aaa2bc5 100644 --- a/docs/custom_search_commands.md +++ b/docs/custom_search_commands.md @@ -64,13 +64,13 @@ python.version = python3 | usage | string | Defines the usage of custom search command. It can be one of `public`, `private` and `deprecated`. | | description | string | Provide description of the custom search command. | | shortdesc | string | A one sentence description of the search command, used for searchbnf.conf | -| syntax | string | Provide syntax for custom search command | +| syntax | string | Syntax for custom search commands will be automatically generated based on the command name and the parameters. If the syntax attribute is specified, the provided string is used instead. | | tags | string | One or more words that users might type into the search bar which are similar to the command name. | | examples | array[objects] | List of example search strings, used for searchbnf.conf | To generate a custom search command, the following attributes must be defined in globalConfig: `commandName`, `commandType`, `fileName`, and `arguments`. Based on the provided commandType, UCC will generate a template Python file and integrate the user-defined logic into it. -If `requiredSearchAssistant` is set to True, the `syntax`, `description`, and `usage` attributes are mandatory, as they are essential for generating `searchbnf.conf`. For more information about these attributes please refer to the [searchbnf.conf docs](https://docs.splunk.com/Documentation/Splunk/9.4.2/Admin/Searchbnfconf) +If `requiredSearchAssistant` is set to True, `description`, and `usage` attributes are mandatory, as they are essential for generating `searchbnf.conf`. The command syntax is automatically derived from the command specification. For more information about these attributes please refer to the [searchbnf.conf docs](https://docs.splunk.com/Documentation/Splunk/9.4.2/Admin/Searchbnfconf) **NOTE:** The user-defined Python file must include specific functions based on the command type: @@ -87,6 +87,8 @@ If `requiredSearchAssistant` is set to True, the `syntax`, `description`, and `u | defaultValue | string/number | Default value of the argument. | | required | boolean | Specify if the argument is required or not. | | validate | object | Specify validation for the argument. It can be any of `Integer`, `Float`, `Boolean`, `RegularExpression`, `FieldName`, `Set`, `Match`, `List`, `Map`, `Duration`. | +| syntax | string | Syntax for arguments is automatically generated based on the validation. If the syntax attribute for an argument is specified, the syntax value is used for the parameter value instead. The syntax string must only specify the value not the argument name. | +| syntaxGeneration | boolean | Specifies if the parameter should be added to the syntax. If `syntaxGeneration` is false, the parameter is omitted. Default: true. | UCC currently supports some types of validations provided by `splunklib` library: @@ -145,8 +147,8 @@ For example: "validate": { "type": "Float", "minimum": "85.5" - } - + }, + "syntaxGeneration": false }, { "name": "animals", @@ -160,12 +162,13 @@ For example: } }, { - "name": "name", + "name": "last", "validate": { "type": "Match", - "name": "Name pattern", - "pattern": "^[A-Z][a-z]+$" - } + "name": "Day duration", + "pattern": "^[0-9]+(d|m|y)?$" + }, + "syntax": "(d|m|y)?" }, { "name": "urgency", diff --git a/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py b/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py index b39a5a81a9..5302acd13d 100644 --- a/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py +++ b/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py @@ -31,11 +31,59 @@ def __init__( if global_config.has_custom_search_commands(): for command in global_config.custom_search_commands: if command.get("requiredSearchAssistant", False): + if "syntax" in command: + syntax = command["syntax"] + else: + params_syntax = [] + for param in command["arguments"]: + if param.get("syntaxGeneration", True): + validator = param.get("validate", {}).get("type", None) + if "syntax" in param: + param_syntax = f"{param['name']}={param['syntax']}" + elif validator and validator in ( + "Set", + "Integer", + "Float", + "Boolean", + "List", + "Duration", + "Map", + ): + if validator in ("Integer", "Float", "Duration"): + param_syntax = f"{param['name']}=" + if validator == "Boolean": + param_syntax = f"{param['name']}=" + if validator == "Set": + param_syntax = f"{param['name']}=({'|'.join(param['validate']['values'])})" + if validator == "List": + param_syntax = ( + f"{param['name']}=(,)*" + ) + if validator == "Map": + param_syntax = f"{param['name']}=({'|'.join(param['validate']['map'].keys())})" + else: + param_syntax = f"{param['name']}=" + if param.get("required", False): + params_syntax.append(param_syntax) + else: + params_syntax.append(f"({param_syntax})?") + + syntax = f"{command['commandName']} {' '.join(params_syntax)}" + if len(syntax) > 120: + syntax = syntax.split(" ") + syntax_lines = [syntax[0]] + for part in syntax[1:]: + if len(syntax_lines[-1]) < 100: + syntax_lines[-1] += f" {part}" + else: + syntax_lines.append(part) + syntax = " \\\n".join(syntax_lines) + searchbnf_dict = { "command_name": command["commandName"], "description": command["description"], "shortdesc": command.get("shortdesc", None), - "syntax": command["syntax"], + "syntax": syntax, "usage": command["usage"], "tags": command.get("tags", None), "examples": command.get("examples", []), diff --git a/splunk_add_on_ucc_framework/global_config_validator.py b/splunk_add_on_ucc_framework/global_config_validator.py index 45b0e201f8..53b3821ee8 100644 --- a/splunk_add_on_ucc_framework/global_config_validator.py +++ b/splunk_add_on_ucc_framework/global_config_validator.py @@ -741,9 +741,7 @@ def _validate_custom_search_commands(self) -> None: "but attributes required for 'searchbnf.conf' is defined which is not required." ) if (command.get("requiredSearchAssistant", False) is True) and not ( - command.get("description") - and command.get("usage") - and command.get("syntax") + command.get("description") and command.get("usage") ): raise GlobalConfigValidatorException( "One of the attributes among `description`, `usage`, `syntax`" diff --git a/splunk_add_on_ucc_framework/schema/schema.json b/splunk_add_on_ucc_framework/schema/schema.json index 63ef5c29e0..fb5d891f91 100644 --- a/splunk_add_on_ucc_framework/schema/schema.json +++ b/splunk_add_on_ucc_framework/schema/schema.json @@ -467,7 +467,6 @@ }, "syntax": { "type": "string", - "maxLength": 100, "description": "Syntax for the custom search command. It is an required attribute for searchbnf.conf." }, "usage": { @@ -534,6 +533,15 @@ ] }, "description": "Provide default value to the arguments passed for custom search command" + }, + "syntaxGeneration": { + "type": "boolean", + "default": true, + "description": "Generate parameter syntax" + }, + "syntax": { + "type": "string", + "description": "Syntax string for the value of the parameter" } }, "required": [ diff --git a/tests/unit/generators/conf_files/test_create_searchbnf_conf.py b/tests/unit/generators/conf_files/test_create_searchbnf_conf.py index ba1f459eeb..592c4719c1 100644 --- a/tests/unit/generators/conf_files/test_create_searchbnf_conf.py +++ b/tests/unit/generators/conf_files/test_create_searchbnf_conf.py @@ -29,6 +29,54 @@ def custom_search_command_without_optional_search_assistance_params(): ] +@fixture +def custom_search_command_syntax_autogeneration(): + return [ + { + "commandName": "generatetextcommand", + "commandType": "generating", + "fileName": "generatetext.py", + "requiredSearchAssistant": True, + "description": "This command generates COUNT occurrences of a TEXT string.", + "usage": "public", + "arguments": [ + { + "name": "count", + "required": True, + "validate": {"type": "Integer", "minimum": 1, "maximum": 10}, + "default": 5, + }, + {"name": "test", "required": True, "validate": {"type": "Fieldname"}}, + { + "name": "percent", + "validate": {"type": "Float", "minimum": "85.5"}, + "syntaxGeneration": False, + }, + { + "name": "animals", + "validate": {"type": "Set", "values": ["cat", "dog", "wombat"]}, + }, + { + "name": "last", + "validate": { + "type": "Match", + "name": "Day duration", + "pattern": "^[0-9]+(d|m|y)?$", + }, + "syntax": "(d|m|y)?", + }, + { + "name": "urgency", + "validate": { + "type": "Map", + "map": {"high": 3, "medium": 2, "low": 1}, + }, + }, + ], + } + ] + + def test_init_without_custom_command( global_config_only_configuration, input_dir, @@ -119,6 +167,37 @@ def test_init_without_optional_search_assistance_params( ] +def test_init_search_command_syntax_autogeneration( + global_config_all_json, + input_dir, + output_dir, + custom_search_command_syntax_autogeneration, +): + global_config_all_json._content["customSearchCommand"] = ( + custom_search_command_syntax_autogeneration + ) + searchbnf_conf = SearchbnfConf( + global_config_all_json, + input_dir, + output_dir, + ) + assert searchbnf_conf.searchbnf_info == [ + { + "command_name": "generatetextcommand", + "description": "This command generates COUNT occurrences of a TEXT string.", + "syntax": ( + "generatetextcommand count= test= " + "(animals=(cat|dog|wombat))? (last=(d|m|y)?)? " + "(urgency=(high|medium|low))?" + ), + "usage": "public", + "examples": [], + "shortdesc": None, + "tags": None, + } + ] + + def test_generate_conf_without_custom_command( global_config_only_configuration, input_dir, @@ -199,3 +278,38 @@ def test_generate_conf_without_optional_search_assistance_params( "content": expected_content, } ] + + +def test_generate_conf_with_search_command_syntax_autogeneration( + global_config_all_json, + input_dir, + output_dir, + custom_search_command_syntax_autogeneration, +): + global_config_all_json._content["customSearchCommand"] = ( + custom_search_command_syntax_autogeneration + ) + ta_name = global_config_all_json.product + searchbnf_conf = SearchbnfConf( + global_config_all_json, + input_dir, + output_dir, + ) + output = searchbnf_conf.generate() + exp_fname = "searchbnf.conf" + expected_content = dedent( + """ + [generatetextcommand-command] + syntax = generatetextcommand count= test= """ + + """(animals=(cat|dog|wombat))? (last=(d|m|y)?)? (urgency=(high|medium|low))? + description = This command generates COUNT occurrences of a TEXT string. + usage = public + """ + ).lstrip() + assert output == [ + { + "file_name": exp_fname, + "file_path": f"{output_dir}/{ta_name}/default/{exp_fname}", + "content": expected_content, + } + ] From ad5575bd898db810d6f146bb76240cc6ce4d133e Mon Sep 17 00:00:00 2001 From: BMH Date: Sun, 8 Feb 2026 16:17:35 +0100 Subject: [PATCH 4/4] feat: added custom command decription array for better readability in json format --- docs/custom_search_commands.md | 2 +- .../conf_files/create_searchbnf_conf.py | 6 +- .../create_custom_command_python.py | 6 +- .../schema/schema.json | 12 ++- .../conf_files/test_create_searchbnf_conf.py | 55 +++++++++++++ .../test_create_custom_command_python.py | 81 +++++++++++++++++++ 6 files changed, 158 insertions(+), 4 deletions(-) diff --git a/docs/custom_search_commands.md b/docs/custom_search_commands.md index 2a8aaa2bc5..193d080d55 100644 --- a/docs/custom_search_commands.md +++ b/docs/custom_search_commands.md @@ -62,7 +62,7 @@ python.version = python3 | arguments\* | array[objects] | Arguments which can be passed to custom search command. | | requiredSearchAssistant | boolean | Specifies whether search assistance is required for the custom search command. Default: false. | | usage | string | Defines the usage of custom search command. It can be one of `public`, `private` and `deprecated`. | -| description | string | Provide description of the custom search command. | +| description | string or array[string] | Provide description of the custom search command. To increase the readability of a comprehensive description in json, it is possible to split it in an array of strings. | | shortdesc | string | A one sentence description of the search command, used for searchbnf.conf | | syntax | string | Syntax for custom search commands will be automatically generated based on the command name and the parameters. If the syntax attribute is specified, the provided string is used instead. | | tags | string | One or more words that users might type into the search bar which are similar to the command name. | diff --git a/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py b/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py index 5302acd13d..7c78ca6f62 100644 --- a/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py +++ b/splunk_add_on_ucc_framework/generators/conf_files/create_searchbnf_conf.py @@ -79,9 +79,13 @@ def __init__( syntax_lines.append(part) syntax = " \\\n".join(syntax_lines) + description = command["description"] + if isinstance(description, list): + description = " \\\n".join(description) + searchbnf_dict = { "command_name": command["commandName"], - "description": command["description"], + "description": description, "shortdesc": command.get("shortdesc", None), "syntax": syntax, "usage": command["usage"], diff --git a/splunk_add_on_ucc_framework/generators/python_files/create_custom_command_python.py b/splunk_add_on_ucc_framework/generators/python_files/create_custom_command_python.py index d459a09905..c4cebdbf19 100644 --- a/splunk_add_on_ucc_framework/generators/python_files/create_custom_command_python.py +++ b/splunk_add_on_ucc_framework/generators/python_files/create_custom_command_python.py @@ -58,12 +58,16 @@ def __init__( "default": argument.get("defaultValue"), } self.argument_generator(argument_list, argument_dict) + + description = command.get("description") + if description and isinstance(description, list): + description = "\n ".join(description) self.commands_info.append( { "imported_file_name": imported_file_name, "file_name": command["commandName"], "class_name": command["commandName"].title(), - "description": command.get("description"), + "description": description, "syntax": command.get("syntax"), "template": template, "list_arg": argument_list, diff --git a/splunk_add_on_ucc_framework/schema/schema.json b/splunk_add_on_ucc_framework/schema/schema.json index fb5d891f91..cc8e1b345c 100644 --- a/splunk_add_on_ucc_framework/schema/schema.json +++ b/splunk_add_on_ucc_framework/schema/schema.json @@ -458,7 +458,17 @@ "description": "Specifies if search assistant is required or not. If yes then searchbnf.conf will be generated." }, "description": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], "description": "Description of the custom search command. It is an required attribute for searchbnf.conf." }, "shortdesc": { diff --git a/tests/unit/generators/conf_files/test_create_searchbnf_conf.py b/tests/unit/generators/conf_files/test_create_searchbnf_conf.py index 592c4719c1..1ca0428613 100644 --- a/tests/unit/generators/conf_files/test_create_searchbnf_conf.py +++ b/tests/unit/generators/conf_files/test_create_searchbnf_conf.py @@ -77,6 +77,25 @@ def custom_search_command_syntax_autogeneration(): ] +@fixture +def custom_search_command_with_multiline_description(): + return [ + { + "commandName": "generatetextcommand", + "commandType": "generating", + "fileName": "generatetext.py", + "requiredSearchAssistant": True, + "description": [ + "This command generates COUNT occurrences of a TEXT string.", + "This might be additional information.", + "Here we can mention something like wombats are cool.", + ], + "syntax": "generatetextcommand count= text=", + "usage": "public", + } + ] + + def test_init_without_custom_command( global_config_only_configuration, input_dir, @@ -313,3 +332,39 @@ def test_generate_conf_with_search_command_syntax_autogeneration( "content": expected_content, } ] + + +def test_generate_conf_with_multiline_description( + global_config_all_json, + input_dir, + output_dir, + custom_search_command_with_multiline_description, +): + global_config_all_json._content["customSearchCommand"] = ( + custom_search_command_with_multiline_description + ) + ta_name = global_config_all_json.product + searchbnf_conf = SearchbnfConf( + global_config_all_json, + input_dir, + output_dir, + ) + output = searchbnf_conf.generate() + exp_fname = "searchbnf.conf" + expected_content = dedent( + """ + [generatetextcommand-command] + syntax = generatetextcommand count= text= + description = This command generates COUNT occurrences of a TEXT string. \\ + This might be additional information. \\ + Here we can mention something like wombats are cool. + usage = public + """ + ).lstrip() + assert output == [ + { + "file_name": exp_fname, + "file_path": f"{output_dir}/{ta_name}/default/{exp_fname}", + "content": expected_content, + } + ] diff --git a/tests/unit/generators/python_files/test_create_custom_command_python.py b/tests/unit/generators/python_files/test_create_custom_command_python.py index 105bc56e6b..054d6ba75d 100644 --- a/tests/unit/generators/python_files/test_create_custom_command_python.py +++ b/tests/unit/generators/python_files/test_create_custom_command_python.py @@ -66,6 +66,33 @@ def transforming_custom_search_command(): ] +@pytest.fixture +def transforming_custom_search_command_with_multiline_description(): + return [ + { + "commandName": "generatetext", + "commandType": "generating", + "fileName": "generatetextcommand.py", + "description": [ + "This is a transforming command", + "This might be additional information.", + "Here we can mention something like wombats are cool.", + ], + "syntax": "generatetext action=", + "arguments": [ + { + "name": "action", + "required": True, + "validate": {"type": "Fieldname"}, + }, + { + "name": "test", + }, + ], + } + ] + + @pytest.fixture def custom_search_command_validators(): return [ @@ -341,3 +368,57 @@ def generate(self): assert normalize_code(output[0]["content"]) == normalize_code(expected_content) assert output[0]["file_name"] == exp_fname assert output[0]["file_path"] == f"{output_dir}/{ta_name}/bin/{exp_fname}" + + +def test_generate_python_with_multiline_description( + global_config_all_json, + input_dir, + output_dir, + transforming_custom_search_command_with_multiline_description, +): + exp_fname = "generatetext.py" + ta_name = global_config_all_json.meta["name"] + + global_config_all_json._content["customSearchCommand"] = ( + transforming_custom_search_command_with_multiline_description + ) + custom_command_py = CustomCommandPy( + global_config_all_json, + input_dir, + output_dir, + ) + output = custom_command_py.generate() + expected_content = ''' +import sys +import import_declare_test + +from splunklib.searchcommands import \\ + dispatch, GeneratingCommand, Configuration, Option, validators +from generatetextcommand import generate + +@Configuration() +class GeneratetextCommand(GeneratingCommand): + """ + + ##Syntax + generatetext action= + + ##Description + This is a transforming command + This might be additional information. + Here we can mention something like wombats are cool. + + """ + action = Option(name='action', require=True, validate=validators.Fieldname()) + test = Option(name='test', require=False) + + + def generate(self): + return generate(self) + +dispatch(GeneratetextCommand, sys.argv, sys.stdin, sys.stdout, __name__) + ''' + assert output is not None + assert normalize_code(output[0]["content"]) == normalize_code(expected_content) + assert output[0]["file_name"] == exp_fname + assert output[0]["file_path"] == f"{output_dir}/{ta_name}/bin/{exp_fname}"