diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py index aae15a286..d00b864a7 100644 --- a/sphinx_needs/config.py +++ b/sphinx_needs/config.py @@ -39,6 +39,10 @@ class ExtraOptionParams: description: str """A description of the option.""" + dynamic_functions: bool | None + """If True, allow dynamic functions when defining this field.""" + variant_functions: bool | None + """If True, allow variant functions when defining this field.""" schema: ( ExtraOptionStringSchemaType | ExtraOptionBooleanSchemaType @@ -106,6 +110,8 @@ def add_extra_option( name: str, description: str, *, + dynamic_functions: bool | None = None, + variant_functions: bool | None = None, schema: ExtraOptionStringSchemaType | ExtraOptionBooleanSchemaType | ExtraOptionIntegerSchemaType @@ -139,9 +145,19 @@ def add_extra_option( ) raise NeedsApiConfigWarning(f"Option {name} already registered.") + if not isinstance(dynamic_functions, bool | None): + raise NeedsApiConfigWarning( + f"dynamic_functions for {name!r} must be a bool or None" + ) + if not isinstance(variant_functions, bool | None): + raise NeedsApiConfigWarning( + f"variant_functions for {name!r} must be a bool or None" + ) self._extra_options[name] = ExtraOptionParams( description=description, schema=schema, + dynamic_functions=dynamic_functions, + variant_functions=variant_functions, ) @property @@ -270,6 +286,10 @@ class LinkOptionsType(TypedDict, total=False): The schema is applied locally on unresolved links, i.e. on the list of string ids. For more granular control and graph traversal, use the `needs_schema_definitions` configuration. """ + dynamic_functions: NotRequired[bool] + """If True, allow dynamic functions when defining this field (default: True).""" + variant_functions: NotRequired[bool] + """If True, allow variant functions when defining this field (default: False).""" class NeedType(TypedDict): @@ -300,6 +320,19 @@ class NeedExtraOption(TypedDict): If given, the schema will apply to all needs that use this option. For more granular control, use the `needs_schema_definitions` configuration. """ + dynamic_functions: NotRequired[bool] + """If True, allow dynamic functions when defining this field (default: True).""" + variant_functions: NotRequired[bool] + """If True, allow variant functions when defining this field (default: False).""" + + +class NeedCoreOption(TypedDict): + """Defines an override for a core option for needs""" + + dynamic_functions: NotRequired[bool] + """If True, allow dynamic functions when defining this field (default: True).""" + variant_functions: NotRequired[bool] + """If True, allow variant functions when defining this field (default: False).""" class NeedStatusesOption(TypedDict): @@ -542,10 +575,14 @@ def get_default(cls, name: str) -> Any: default=30, metadata={"rebuild": "html", "types": (int,)} ) """Maximum length of the title in the need role output.""" + core_options: Mapping[str, NeedCoreOption] = field( + default_factory=dict, metadata={"rebuild": "html", "types": (dict,)} + ) + """Override certain core fields for needs.""" _extra_options: list[str | NeedExtraOption] = field( default_factory=list, metadata={"rebuild": "html", "types": (list,)} ) - """List of extra options for needs, that get added as directive options and need fields.""" + """List of extra fields for needs, that get added as directive options and need fields.""" @property def extra_options(self) -> Mapping[str, ExtraOptionParams]: diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index c6f40502e..914a381a7 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -495,6 +495,8 @@ def load_config(app: Sphinx, *_args: Any) -> None: for option in needs_config._extra_options: description = "Added by needs_extra_options config" schema = None + dynamic_functions = None + variant_functions = None if isinstance(option, str): name = option elif isinstance(option, dict): @@ -510,6 +512,8 @@ def load_config(app: Sphinx, *_args: Any) -> None: continue description = option.get("description", description) schema = option.get("schema") + dynamic_functions = option.get("dynamic_functions") + variant_functions = option.get("variant_functions") else: log_warning( LOGGER, @@ -519,7 +523,14 @@ def load_config(app: Sphinx, *_args: Any) -> None: ) continue - _NEEDS_CONFIG.add_extra_option(name, description, schema=schema, override=True) + _NEEDS_CONFIG.add_extra_option( + name, + description, + schema=schema, + override=True, + dynamic_functions=dynamic_functions, + variant_functions=variant_functions, + ) # ensure options for `needgantt` functionality are added to the extra options for option in (needs_config.duration_option, needs_config.completion_option): @@ -718,6 +729,17 @@ def check_configuration(app: Sphinx, config: Config) -> None: " Please use another name in your config (needs_extra_links)." ) + # check for correct keys in needs_core_options + allowed_core_options = { + key + for key, val in NeedsCoreFields.items() + if val.get("add_to_field_schema", False) + } + if disallowed_keys := set(needs_config.core_options) - allowed_core_options: + raise NeedsConfigException( + f"Keys in needs_core_options are not allowed: {disallowed_keys}" + ) + # Check if option and link are using the same name for link in link_types: if link in extra_options: @@ -731,29 +753,20 @@ def check_configuration(app: Sphinx, config: Config) -> None: " This is not allowed.".format(link + "_back") ) - external_variants = needs_config.variants - external_variant_options = needs_config.variant_options - for value in external_variants.values(): + for value in needs_config.variants.values(): # Check if external filter values is really a string if not isinstance(value, str): raise NeedsConfigException( - f"Variant filter value: {value} from needs_variants {external_variants} is not a string." + f"Variant filter value: {value} from needs_variants {needs_config.variants} is not a string." ) - allowed_internal_variants = { - k for k, v in NeedsCoreFields.items() if v.get("allow_variants") - } - for option in external_variant_options: - # Check variant option is added to an allowed field - if option in link_types: - raise NeedsConfigException( - f"Variant option `{option}` is a link type. This is not allowed." - ) - if option not in extra_options and option not in allowed_internal_variants: - raise NeedsConfigException( - f"Variant option `{option}` is not added in extra options. " - "This is not allowed." - ) + if needs_config.variant_options: + log_warning( + LOGGER, + 'Config option "needs_variant_options" is deprecated. Please enable "variant_functions" in "needs_core_options" or "needs_extra_options" instead.', + "deprecated", + None, + ) validate_schemas_config(needs_config) @@ -764,6 +777,7 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N for name, data in NeedsCoreFields.items(): if not data.get("add_to_field_schema", False): continue + overrides = needs_config.core_options.get(name, {}) type_ = data["schema"]["type"] nullable = False if isinstance(type_, list): @@ -771,6 +785,29 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N type_ = type_[0] nullable = True default = data["schema"].get("default", None) + + allow_variants = overrides.get("variant_functions", False) + if name in needs_config.variant_options: + allow_variants = True + if allow_variants and not data.get("allow_variants"): + log_warning( + LOGGER, + f"Core field {name!r} cannot be configured to allow variant functions, as it does not support them.", + "config", + None, + ) + allow_variants = False + + allow_df = overrides.get("dynamic_functions", data.get("allow_df", False)) + if allow_df and not data.get("allow_df"): + log_warning( + LOGGER, + f"Core field {name!r} cannot be configured to allow dynamic functions, as it does not support them.", + "config", + None, + ) + allow_df = False + field = FieldSchema( name=name, description=data["description"], @@ -780,10 +817,8 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N default=None if default is None else FieldLiteralValue(default), allow_defaults=data.get("allow_default", False), allow_extend=data.get("allow_extend", False), - allow_dynamic_functions=data.get("allow_df", False), - allow_variant_functions=name in needs_config.variant_options - if data.get("allow_variants", False) - else False, + allow_dynamic_functions=allow_df, + allow_variant_functions=allow_variants, directive_option=name != "title", ) try: @@ -804,6 +839,15 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N type = extra.schema.get("type", "string") if type == "array": item_type = extra.schema.get("items", {}).get("type", "string") # type: ignore[attr-defined] + + allow_variants = ( + extra.variant_functions + if extra.variant_functions is not None + else False + ) + if name in needs_config.variant_options: + allow_variants = True + field = FieldSchema( name=name, description=extra.description, @@ -816,8 +860,10 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N default=None if extra.schema is not None else FieldLiteralValue(""), allow_defaults=True, allow_extend=True, - allow_dynamic_functions=True, - allow_variant_functions=name in needs_config.variant_options, + allow_dynamic_functions=True + if extra.dynamic_functions is None + else extra.dynamic_functions, + allow_variant_functions=allow_variants, directive_option=True, ) schema.add_extra_field(field) @@ -839,8 +885,8 @@ def create_schema(app: Sphinx, env: BuildEnvironment, _docnames: list[str]) -> N default=LinksLiteralValue([]), allow_defaults=True, allow_extend=True, - allow_dynamic_functions=True, - allow_variant_functions=name in needs_config.variant_options, + allow_dynamic_functions=link.get("dynamic_functions", True), + allow_variant_functions=link.get("variant_functions", False), directive_option=True, ) schema.add_link_field(link_field) diff --git a/tests/__snapshots__/test_variants.ambr b/tests/__snapshots__/test_variants.ambr index cab6f9ad2..4db17a207 100644 --- a/tests/__snapshots__/test_variants.ambr +++ b/tests/__snapshots__/test_variants.ambr @@ -27,6 +27,121 @@ }) # --- # name: test_variant_options_html[test_app0] + dict({ + 'current_version': '', + 'versions': dict({ + '': dict({ + 'needs': dict({ + 'CV_0002': dict({ + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'CV_0002', + 'lineno': 19, + 'section_name': 'Variant Handling Test', + 'sections': list([ + 'Variant Handling Test', + ]), + 'status': 'open', + 'tags': list([ + 'commence', + 'start', + 'begin', + ]), + 'title': 'Custom Variant', + 'type': 'spec', + 'type_name': 'Specification', + 'value': 'start', + }), + 'SPEC_003': dict({ + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'SPEC_003', + 'lineno': 26, + 'section_name': 'Variant Handling Test', + 'sections': list([ + 'Variant Handling Test', + ]), + 'status': 'open', + 'title': 'Variant Specification', + 'type': 'spec', + 'type_name': 'Specification', + }), + 'SPEC_004': dict({ + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'SPEC_004', + 'lineno': 30, + 'section_name': 'Variant Handling Test', + 'sections': list([ + 'Variant Handling Test', + ]), + 'status': 'unknown', + 'title': 'Unknown Variant', + 'type': 'spec', + 'type_name': 'Specification', + }), + 'SP_38823': dict({ + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'SP_38823', + 'lineno': 4, + 'section_name': 'Variant Handling Test', + 'sections': list([ + 'Variant Handling Test', + ]), + 'status': 'progress', + 'tags': list([ + 'school', + 'extension', + 'needs', + ]), + 'title': 'No ID', + 'type': 'spec', + 'type_name': 'Specification', + }), + 'ST_001': dict({ + 'author': 'Daniel Woste', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'ST_001', + 'lineno': 13, + 'links_back': list([ + 'VA_003', + ]), + 'section_name': 'Variant Handling Test', + 'sections': list([ + 'Variant Handling Test', + ]), + 'status': 'close', + 'title': 'Test story', + 'type': 'story', + 'type_name': 'User Story', + }), + 'VA_003': dict({ + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'VA_003', + 'lineno': 8, + 'links': list([ + 'ST_001', + ]), + 'section_name': 'Variant Handling Test', + 'sections': list([ + 'Variant Handling Test', + ]), + 'status': 'tags_implemented', + 'title': 'Tags Example', + 'type': 'spec', + 'type_name': 'Specification', + }), + }), + 'needs_amount': 6, + 'needs_defaults_removed': True, + }), + }), + }) +# --- +# name: test_variant_options_html_old[test_app0] dict({ 'current_version': '', 'versions': dict({ diff --git a/tests/doc_test/parallel_doc/conf.py b/tests/doc_test/parallel_doc/conf.py index f6f03d869..277d5e1bb 100644 --- a/tests/doc_test/parallel_doc/conf.py +++ b/tests/doc_test/parallel_doc/conf.py @@ -31,6 +31,13 @@ }, ] needs_variants = {"change_author": "assignee == 'Randy Duodu'"} -needs_variant_options = ["status", "author"] needs_filter_data = {"assignee": "Randy Duodu"} -needs_extra_options = ["my_extra_option", "another_option", "author", "comment"] +needs_core_options = { + "status": {"variant_functions": True}, +} +needs_extra_options = [ + "my_extra_option", + "another_option", + {"name": "author", "variant_functions": True}, + "comment", +] diff --git a/tests/doc_test/variant_doc/conf.py b/tests/doc_test/variant_doc/conf.py index fa8735395..507d39618 100644 --- a/tests/doc_test/variant_doc/conf.py +++ b/tests/doc_test/variant_doc/conf.py @@ -38,12 +38,22 @@ }, ] needs_variants = {"change_author": "assignee == 'Randy Duodu'"} -needs_variant_options = ["status", "author"] needs_filter_data = {"assignee": "Randy Duodu"} +needs_core_options = { + "status": {"variant_functions": True}, +} +needs_extra_links = [ + { + "option": "links", + "variant_functions": True, + "incoming": "is linked by", + "outgoing": "links to", + }, +] needs_extra_options = [ "my_extra_option", "another_option", - "author", + {"name": "author", "variant_functions": True}, "comment", "amount", "hours", diff --git a/tests/doc_test/variant_doc/index.rst b/tests/doc_test/variant_doc/index.rst index 4fac3d41c..9f701be99 100644 --- a/tests/doc_test/variant_doc/index.rst +++ b/tests/doc_test/variant_doc/index.rst @@ -8,6 +8,7 @@ Variant Handling Test .. spec:: Tags Example :id: VA_003 :status: <<[all(x in build_tags for x in ['tag_a', 'tag_b'])]:tags_implemented, closed>> + :links: <<[all(x in build_tags for x in ['tag_a', 'tag_b'])]:ST_001, unknown>> .. story:: Test story :id: ST_001 diff --git a/tests/doc_test/variant_doc_old/conf.py b/tests/doc_test/variant_doc_old/conf.py new file mode 100644 index 000000000..e5fac29d1 --- /dev/null +++ b/tests/doc_test/variant_doc_old/conf.py @@ -0,0 +1,58 @@ +tags.add("tag_b") # noqa: F821 + +extensions = ["sphinx_needs", "sphinxcontrib.plantuml"] + +# note, the plantuml executable command is set globally in the test suite +plantuml_output_format = "svg" + +needs_id_regex = "^[A-Za-z0-9_]" + +needs_types = [ + { + "directive": "story", + "title": "User Story", + "prefix": "US_", + "color": "#BFD8D2", + "style": "node", + }, + { + "directive": "spec", + "title": "Specification", + "prefix": "SP_", + "color": "#FEDCD2", + "style": "node", + }, + { + "directive": "impl", + "title": "Implementation", + "prefix": "IM_", + "color": "#DF744A", + "style": "node", + }, + { + "directive": "test", + "title": "Test Case", + "prefix": "TC_", + "color": "#DCB239", + "style": "node", + }, +] +needs_variants = {"change_author": "assignee == 'Randy Duodu'"} +needs_filter_data = {"assignee": "Randy Duodu"} +needs_variant_options = ["author", "status"] +needs_extra_options = [ + "my_extra_option", + "another_option", + "author", + "comment", + "amount", + "hours", + "image", + "config", + "github", + "value", + "unit", +] + +needs_build_json = True +needs_json_remove_defaults = True diff --git a/tests/doc_test/variant_doc_old/index.rst b/tests/doc_test/variant_doc_old/index.rst new file mode 100644 index 000000000..4fac3d41c --- /dev/null +++ b/tests/doc_test/variant_doc_old/index.rst @@ -0,0 +1,38 @@ +Variant Handling Test +===================== + +.. spec:: No ID + :status: <<['church' in tags]:open, ['extension' in tags]:progress, close>> + :tags: school, extension, needs + +.. spec:: Tags Example + :id: VA_003 + :status: <<[all(x in build_tags for x in ['tag_a', 'tag_b'])]:tags_implemented, closed>> + +.. story:: Test story + :id: ST_001 + :status: close + :author: <> + + +.. spec:: Custom Variant + :id: CV_0002 + :status: <<[value in tags]:open, close>> + :value: start + :tags: commence, start, begin + + +.. spec:: Variant Specification + :id: SPEC_003 + :status: <<['tag_a' in build_tags]:open, unknown>> + +.. spec:: Unknown Variant + :id: SPEC_004 + :status: <<['tag_c' in build_tags]:open, unknown>> + +.. needtable:: + :filter: status in ("open", "close", "progress") + +.. toctree:: + :maxdepth: 2 + :caption: Contents: diff --git a/tests/doc_test/variant_options/conf.py b/tests/doc_test/variant_options/conf.py index 2b11a3569..37838efee 100644 --- a/tests/doc_test/variant_options/conf.py +++ b/tests/doc_test/variant_options/conf.py @@ -38,8 +38,10 @@ }, ] needs_variants = {"change_author": "assignee == 'Randy Duodu'"} -needs_variant_options = ["status"] needs_filter_data = {"assignee": "Randy Duodu"} +needs_core_options = { + "status": {"variant_functions": True}, +} needs_extra_options = [ "my_extra_option", "another_option", diff --git a/tests/test_variants.py b/tests/test_variants.py index 65b031781..07bad565a 100644 --- a/tests/test_variants.py +++ b/tests/test_variants.py @@ -55,6 +55,26 @@ def test_match_variants(option, context, variants, expected): assert match_variants(option, context, variants) == expected +@pytest.mark.parametrize( + "test_app", + [{"buildername": "html", "srcdir": "doc_test/variant_doc_old", "tags": ["tag_a"]}], + indirect=True, +) +def test_variant_options_html_old(test_app, snapshot): + app = test_app + app.build() + + warnings = strip_colors(app._warning.getvalue()).splitlines() + assert warnings == [ + 'WARNING: Config option "needs_variant_options" is deprecated. Please enable "variant_functions" in "needs_core_options" or "needs_extra_options" instead. [needs.deprecated]', + ] + + needs = json.loads(Path(app.outdir, "needs.json").read_text()) + assert needs == snapshot( + exclude=props("created", "project", "creator", "needs_schema") + ) + + @pytest.mark.parametrize( "test_app", [{"buildername": "html", "srcdir": "doc_test/variant_doc", "tags": ["tag_a"]}],