diff --git a/pyproject.toml b/pyproject.toml index 4c28a13..67040ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,21 @@ codelinks = "sphinx_codelinks.cmd:app" "docs:rm" = "rm -rf docs/_build/html" "docs" = "sphinx-build -nW --keep-going -b html -T -c docs docs/source docs/_build/html" "docs:clean" = { chain = ["docs:rm", "docs"] } +# needextend demo +"analyse" = "codelinks analyse tests/data/configs/minimum_config.toml" +"analyse:rm" = "rm -rf output/marked_content.json" +"write" = "codelinks write rst output/marked_content.json --outpath tests/data/needextend_demo/needextend.rst" +"write:rm" = "rm -rf tests/data/needextend_demo/needextend.rst" +"demo:rm" = "rm -rf tests/data/needextend_demo/_build" +"demo:build" = "sphinx-build -nW --keep-going -b html -T -c tests/data/needextend_demo tests/data/needextend_demo tests/data/needextend_demo/_build/html" +"demo:clean" = { chain = [ + "demo:rm", + "analyse:rm", + "write:rm", + "analyse", + "write", + "demo:build", +] } [tool.ruff.lint] extend-select = [ diff --git a/src/sphinx_codelinks/analyse/models.py b/src/sphinx_codelinks/analyse/models.py index 66fb349..856f0a0 100644 --- a/src/sphinx_codelinks/analyse/models.py +++ b/src/sphinx_codelinks/analyse/models.py @@ -7,8 +7,8 @@ class MarkedContentType(str, Enum): - need = ("need",) - need_id_refs = ("need-id-refs",) + need = "need" + need_id_refs = "need-id-refs" rst = "rst" diff --git a/src/sphinx_codelinks/cmd.py b/src/sphinx_codelinks/cmd.py index 53dde99..bc0077f 100644 --- a/src/sphinx_codelinks/cmd.py +++ b/src/sphinx_codelinks/cmd.py @@ -1,8 +1,9 @@ from collections import deque +import json from os import linesep from pathlib import Path import tomllib -from typing import Annotated, cast +from typing import Annotated, TypeAlias, cast import typer @@ -13,6 +14,8 @@ CodeLinksProjectConfigType, generate_project_configs, ) +from sphinx_codelinks.logger import logger +from sphinx_codelinks.needextend_write import MarkedObjType, convert_marked_content from sphinx_codelinks.source_discover.config import ( CommentType, SourceDiscoverConfig, @@ -23,6 +26,33 @@ app = typer.Typer( no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]} ) +write_app = typer.Typer( + help="Export marked content to other formats", no_args_is_help=True +) +app.add_typer(write_app, name="write", rich_help_panel="Sub-menus") + +OptVerbose: TypeAlias = Annotated[ # noqa: UP040 # has to be TypeAlias + bool, + typer.Option( + ..., + "-v", + "--verbose", + is_flag=True, + help="Show debug information", + rich_help_panel="Logging", + ), +] +OptQuiet: TypeAlias = Annotated[ # noqa: UP040 # has to be TypeAlias + bool, + typer.Option( + ..., + "-q", + "--quiet", + is_flag=True, + help="Only show errors and warnings", + rich_help_panel="Logging", + ), +] @app.command(no_args_is_help=True) @@ -190,6 +220,79 @@ def discover( typer.echo(file_path) +@write_app.command("rst", no_args_is_help=True) +def write_rst( # noqa: PLR0913 # for CLI, so it takes as many as it requires + jsonpath: Annotated[ + Path, + typer.Argument( + ..., + help="Path of the JSON file which contains the extracted markers", + show_default=False, + dir_okay=False, + file_okay=True, + exists=True, + resolve_path=True, + ), + ], + outpath: Annotated[ + Path, + typer.Option( + "--outpath", + "-o", + help="The output path for generated rst file", + show_default=True, + dir_okay=False, + file_okay=True, + exists=False, + ), + ] = Path("needextend.rst"), + remote_url_field: Annotated[ + str, + typer.Option( + "--remote-url-field", + "-r", + help="The field name for the remote url", + show_default=True, + ), + ] = "remote_url", # to show default value in this CLI + title: Annotated[ + str | None, + typer.Option( + "--title", + "-t", + help="Give the title to the generated RST file", + show_default=True, + ), + ] = None, # to show default value in this CLI + verbose: OptVerbose = False, + quiet: OptQuiet = False, +) -> None: + """Generate needextend.rst from the extracted obj in JSON.""" + logger.configure(verbose, quiet) + try: + with jsonpath.open("r") as f: + marked_content = json.load(f) + except Exception as e: + raise typer.BadParameter( + f"Failed to load marked content from {jsonpath}: {e}" + ) from e + + marked_objs: list[MarkedObjType] = [ + obj for objs in marked_content.values() for obj in objs + ] + + needextend_texts, errors = convert_marked_content( + marked_objs, remote_url_field, title + ) + if errors: + raise typer.BadParameter( + f"Errors occurred during conversion: {linesep.join(errors)}" + ) + with outpath.open("w") as f: + f.writelines(needextend_texts) + typer.echo(f"Generated {outpath}") + + def load_config_from_toml(toml_file: Path) -> CodeLinksConfigType: try: with toml_file.open("rb") as f: diff --git a/src/sphinx_codelinks/logger.py b/src/sphinx_codelinks/logger.py new file mode 100644 index 0000000..05b3f35 --- /dev/null +++ b/src/sphinx_codelinks/logger.py @@ -0,0 +1,94 @@ +from rich.console import Console +from rich.text import Text +import typer + + +class Logger: + __slots__ = ("console", "err_console", "quiet", "verbose") + + def __init__(self, *, verbose: bool = False, quiet: bool = False) -> None: + self.verbose = verbose + self.quiet = quiet + self.console = Console() + self.err_console = Console(stderr=True) + + def configure(self, verbose: bool = False, quiet: bool = False) -> None: + self.verbose = verbose + self.quiet = quiet + + def debug( + self, + *msg: str | Text, + style: str | None = typer.colors.BRIGHT_BLACK, + highlight: bool = False, + markup: bool = False, + console: Console | None = None, + ) -> None: + """Print a debug message. + + Will only be shown if verbose mode is enabled and not in quiet mode. + """ + if self.verbose and not self.quiet: + (console or self.console).print( + *msg, style=style, highlight=highlight, markup=markup + ) + + def info( + self, + *msg: str | Text, + style: str | None = None, + highlight: bool = False, + markup: bool = False, + no_wrap: bool | None = None, + console: Console | None = None, + ) -> None: + """Print an informational message. + + Will be suppressed in quiet mode. + """ + if not self.quiet: + (console or self.console).print( + *msg, style=style, highlight=highlight, markup=markup, no_wrap=no_wrap + ) + + def result( + self, + *msg: str | Text, + style: str | None = None, + highlight: bool = False, + markup: bool = False, + console: Console | None = None, + ) -> None: + """Print a result message, like info but ignores quiet mode.""" + (console or self.console).print( + *msg, style=style, highlight=highlight, markup=markup + ) + + def warning( + self, + *msg: str | Text, + style: str | None = typer.colors.YELLOW, + highlight: bool = False, + markup: bool = False, + console: Console | None = None, + ) -> None: + """Print a warning message.""" + (console or self.console).print( + *msg, style=style, highlight=highlight, markup=markup + ) + + def error( + self, + *msg: str | Text, + style: str | None = typer.colors.RED, + highlight: bool = False, + markup: bool = False, + console: Console | None = None, + ) -> None: + """Print an error message.""" + (console or self.err_console).print( + *msg, style=style, highlight=highlight, markup=markup + ) + + +logger = Logger() diff --git a/src/sphinx_codelinks/needextend_write.py b/src/sphinx_codelinks/needextend_write.py new file mode 100644 index 0000000..ee676d4 --- /dev/null +++ b/src/sphinx_codelinks/needextend_write.py @@ -0,0 +1,218 @@ +"""Covert the generated JSON file created by CodeLinks anaylse to need-extend in RST.""" + +from collections import deque +from dataclasses import MISSING, dataclass, field, fields +from os import linesep +from string import Template +from typing import Any, TypedDict, cast + +from jsonschema import ValidationError, validate + +from sphinx_codelinks.analyse.models import MarkedContentType, SourceMap + +NEEDEXTEND_TEMPLATE = Template(""".. needextend:: $need_id + :$remote_url_field: $remote_url + +""") + + +@dataclass +class MarkedContentSchema: + @classmethod + def field_names(cls) -> set[str]: + return {item.name for item in fields(cls)} + + filepath: str = field( + metadata={"schema": {"type": "string"}}, + ) + """filepath where the marked content is located.""" + + remote_url: str | None = field( + metadata={"schema": {"type": "string", "nullable": True}} + ) + """remote url which can be directed to the the marked content.""" + + source_map: SourceMap = field( + metadata={ + "schema": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "row": {"type": "integer"}, + "column": {"type": "integer"}, + }, + "required": ["row", "column"], + }, + "end": { + "type": "object", + "properties": { + "row": {"type": "integer"}, + "column": {"type": "integer"}, + }, + "required": ["row", "column"], + }, + }, + "required": ["start", "end"], + "additionalProperties": False, + } + } + ) + """Coordinate of the marked content in a file""" + + tagged_scope: str | None = field(metadata={"schema": {"type": ["string", "null"]}}) + """The scoped tagged by the marked content""" + + type: MarkedContentType = field( + metadata={ + "schema": { + "type": "string", + "enum": [key.value for key in MarkedContentType], + } + } + ) + """Type of the marked content.""" + + marker: str | None = field( + default=None, metadata={"schema": {"type": ["string", "null"]}} + ) + """Marker of the marked content.""" + + need_ids: list[str] | None = field( + default=None, + metadata={"schema": {"type": ["array", "null"], "items": {"type": "string"}}}, + ) + """Need id refs which is associated to the need items in the documentation.""" + need: dict[str, str | list[str]] | None = field( + default=None, + metadata={ + "schema": { + "type": ["object", "null"], + "properties": {"title": {"type": "string"}, "id": {"type": "string"}}, + "required": ["title", "id"], + "additionalProperties": True, + } + }, + ) + """Definition of a need item""" + + rst: str | None = field( + default=None, metadata={"schema": {"type": ["string", "null"]}} + ) + """Extracted rst text.""" + + @classmethod + def get_schema(cls, name: str) -> dict[str, Any] | None: # type: ignore[explicit-any] + _field = next(_field for _field in fields(cls) if _field.name is name) + if _field.metadata is not MISSING and "schema" in _field.metadata: + return cast(dict[str, Any], _field.metadata["schema"]) # type: ignore[explicit-any] + return None + + def check_schema(self) -> list[str]: + errors = [] + for _field_name in self.field_names(): + schema = self.get_schema(_field_name) + value = getattr(self, _field_name) + try: + validate(instance=value, schema=schema) # type: ignore[arg-type] # validate has no type + except ValidationError as e: + errors.append( + f"Schema validation error in field '{_field_name}': {e.message}" + ) + return errors + + def check_conditional_required_fields(self) -> list[str]: + errors = [] + if self.type == MarkedContentType.need.value and not self.need: + errors.append( + "Need definition is required for marked content of type 'need'" + ) + elif self.type == MarkedContentType.need_id_refs.value: + if not self.marker: + errors.append( + "Marker is required for marked content of type 'need_id_refs'" + ) + if not self.need_ids: + errors.append( + "Need id refs are required for marked content of type 'need_id_refs'" + ) + elif self.type == MarkedContentType.need.value and not self.need_ids: + errors.append( + "Need id refs are required for marked content of type 'need_id_refs'" + ) + elif self.type == MarkedContentType.rst.value and not self.rst: + errors.append("RST text is required for marked content of type 'rst'") + return errors + + def check_loaded_objs(self) -> list[str]: + return self.check_schema() + self.check_conditional_required_fields() + + +class MarkedObjType(TypedDict): + filepath: str + remote_url: str | None + source_map: SourceMap + tagged_scope: str | None + type: MarkedContentType + need_ids: list[str] | None + need: dict[str, str | list[str]] | None + rst: str | None + + +def convert_marked_content( + marked_objs: list[MarkedObjType], + remote_url_field: str = "remote-url", + title: str | None = None, +) -> tuple[list[str], list[str]]: + """Convert marked objects extracted by anaylse CLI to needextend in RST""" + errors = [] + needextend_texts: list[str] = [] + intersted_objs = [ + obj + for obj in marked_objs + if obj["type"] == MarkedContentType.need_id_refs.value + ] + + for obj in intersted_objs: + try: + schema = MarkedContentSchema(**obj) + except TypeError as e: + errors.append(str(e)) + continue + obj_errors: deque[str] = deque() + obj_errors.extend(schema.check_schema()) + obj_errors.extend(schema.check_conditional_required_fields()) + if obj_errors: + obj_errors.appendleft(f"{obj} has the following errors:") + errors.extend(list(obj_errors)) + + if errors: + return needextend_texts, errors + + # handle N:1 mapping of need_id and remote_url + id_urls: dict[str, list[str]] = {} + for obj in intersted_objs: + if obj["type"] == MarkedContentType.need_id_refs.value and obj["need_ids"]: + for need_id in obj["need_ids"]: + if need_id not in id_urls: + id_urls[need_id] = [] + if obj["remote_url"]: + id_urls[need_id].append(obj["remote_url"]) + + if title: + needextend_texts.append(f"{title}{linesep}{'=' * len(title)}{linesep}{linesep}") + + for id, urls in id_urls.items(): + # Connect urls with comma, if there are multiple urls + if not urls: + continue + remote_url = ",".join(urls) + needextend_text = NEEDEXTEND_TEMPLATE.safe_substitute( + need_id=id, + remote_url_field=remote_url_field, + remote_url=remote_url, + ) + needextend_texts.append(needextend_text) + + return needextend_texts, errors diff --git a/tests/__snapshots__/test_analyse/test_analyse[src_dir0-src_paths0].anchors.json b/tests/__snapshots__/test_analyse/test_analyse[src_dir0-src_paths0].anchors.json index 02b3285..1904a62 100644 --- a/tests/__snapshots__/test_analyse/test_analyse[src_dir0-src_paths0].anchors.json +++ b/tests/__snapshots__/test_analyse/test_analyse[src_dir0-src_paths0].anchors.json @@ -48,10 +48,10 @@ }, "tagged_scope": "void dummy_func1(){\n //...\n }", "need_ids": [ - "need_001", - "need_002", - "need_003", - "need_004" + "NEED_001", + "NEED_002", + "NEED_003", + "NEED_004" ], "marker": "@need-ids:", "type": "need-id-refs" @@ -71,7 +71,7 @@ }, "tagged_scope": "int main() {\n std::cout << \"Starting demo_1...\" << std::endl;\n dummy_func1();\n std::cout << \"Demo_1 finished.\" << std::endl;\n return 0;\n }", "need_ids": [ - "need_003" + "NEED_003" ], "marker": "@need-ids:", "type": "need-id-refs" diff --git a/tests/data/analyse/marked_content.json b/tests/data/analyse/marked_content.json new file mode 100644 index 0000000..e8ad425 --- /dev/null +++ b/tests/data/analyse/marked_content.json @@ -0,0 +1,114 @@ +{ + "full_config": [ + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/marked_rst/dummy_1.cpp", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/marked_rst/dummy_1.cpp#L4", + "source_map": { + "start": { "row": 3, "column": 8 }, + "end": { "row": 3, "column": 61 } + }, + "tagged_scope": "void dummy_func1(){\n //...\n }", + "rst": ".. impl:: implement dummy function 1\n :id: IMPL_71\n", + "type": "rst" + }, + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/marked_rst/dummy_1.cpp", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/marked_rst/dummy_1.cpp#L14", + "source_map": { + "start": { "row": 13, "column": 7 }, + "end": { "row": 13, "column": 40 } + }, + "tagged_scope": "int main() {\n std::cout << \"Starting demo_1...\" << std::endl;\n dummy_func1();\n std::cout << \"Demo_1 finished.\" << std::endl;\n return 0;\n }", + "rst": "..impl:: implement main function ", + "type": "rst" + }, + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/need_id_refs/dummy_1.cpp", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/need_id_refs/dummy_1.cpp#L3", + "source_map": { + "start": { "row": 2, "column": 13 }, + "end": { "row": 2, "column": 51 } + }, + "tagged_scope": "void dummy_func1(){\n //...\n }", + "need_ids": ["NEED_001", "NEED_002", "NEED_003", "NEED_004"], + "marker": "@need-ids:", + "type": "need-id-refs" + }, + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/need_id_refs/dummy_1.cpp", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/need_id_refs/dummy_1.cpp#L8", + "source_map": { + "start": { "row": 7, "column": 13 }, + "end": { "row": 7, "column": 21 } + }, + "tagged_scope": "int main() {\n std::cout << \"Starting demo_1...\" << std::endl;\n dummy_func1();\n std::cout << \"Demo_1 finished.\" << std::endl;\n return 0;\n }", + "need_ids": ["NEED_003"], + "marker": "@need-ids:", + "type": "need-id-refs" + }, + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/oneline_comment_default/default_oneliners.c", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/oneline_comment_default/default_oneliners.c#L1", + "source_map": { + "start": { "row": 0, "column": 4 }, + "end": { "row": 0, "column": 24 } + }, + "tagged_scope": "void foo() {}", + "need": { + "title": "Function Foo", + "id": "IMPL_1", + "type": "impl", + "links": [] + }, + "type": "need" + }, + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/oneline_comment_default/default_oneliners.c", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/oneline_comment_default/default_oneliners.c#L4", + "source_map": { + "start": { "row": 3, "column": 4 }, + "end": { "row": 3, "column": 24 } + }, + "tagged_scope": "void bar() {}", + "need": { + "title": "Function Bar", + "id": "IMPL_2", + "type": "impl", + "links": [] + }, + "type": "need" + }, + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/oneline_comment_default/default_oneliners.c", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/oneline_comment_default/default_oneliners.c#L7", + "source_map": { + "start": { "row": 6, "column": 4 }, + "end": { "row": 6, "column": 39 } + }, + "tagged_scope": "void baz() {}", + "need": { + "title": "Function Baz, as I want it", + "id": "IMPL_3", + "type": "impl", + "links": [] + }, + "type": "need" + }, + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/oneline_comment_default/default_oneliners.c", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/oneline_comment_default/default_oneliners.c#L10", + "source_map": { + "start": { "row": 9, "column": 4 }, + "end": { "row": 9, "column": 51 } + }, + "tagged_scope": "void bar() {}", + "need": { + "title": "Function Bar,", + "id": "IMPL_4", + "type": "impl", + "links": ["SPEC_1", "SPEC_2"] + }, + "type": "need" + } + ] +} diff --git a/tests/data/need_id_refs/dummy_1.cpp b/tests/data/need_id_refs/dummy_1.cpp index 86b7b2f..6d90ed2 100644 --- a/tests/data/need_id_refs/dummy_1.cpp +++ b/tests/data/need_id_refs/dummy_1.cpp @@ -1,11 +1,11 @@ #include -// @need-ids: need_001, need_002, need_003, need_004 +// @need-ids: NEED_001, NEED_002, NEED_003, NEED_004 void dummy_func1(){ //... } - // @need-ids: need_003 + // @need-ids: NEED_003 int main() { std::cout << "Starting demo_1..." << std::endl; dummy_func1(); diff --git a/tests/data/needextend_demo/README.md b/tests/data/needextend_demo/README.md new file mode 100644 index 0000000..4070635 --- /dev/null +++ b/tests/data/needextend_demo/README.md @@ -0,0 +1,7 @@ +# Needextend Demo + +Run the following command in project root to have demo generated + +```bash +rye run demo:clean +``` diff --git a/tests/data/needextend_demo/conf.py b/tests/data/needextend_demo/conf.py new file mode 100644 index 0000000..2cef3d0 --- /dev/null +++ b/tests/data/needextend_demo/conf.py @@ -0,0 +1,38 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "test_needextend" +copyright = "2025, useblocks" +author = "team useblocks" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "furo" +# html_static_path = ["_static"] + +extensions = ["sphinx_needs", "sphinx_codelinks"] + +# for needextend to work with remote url +needs_extra_options = ["remote_url"] + +needs_string_links = { + "custom_name": { + "regex": r"^(?P.+)#L(?P.*)?", + "link_url": "{url}", + "link_name": "{{value}}#L{{lineno}}", + "options": ["remote_url"], + } +} diff --git a/tests/data/needextend_demo/index.rst b/tests/data/needextend_demo/index.rst new file mode 100644 index 0000000..13d2c1d --- /dev/null +++ b/tests/data/needextend_demo/index.rst @@ -0,0 +1,13 @@ +.. impl:: title 1 + :id: NEED_001 + +.. impl:: title 2 + :id: NEED_002 + +.. impl:: title 3 + :id: NEED_003 + +.. impl:: title 4 + :id: NEED_004 + +.. include:: needextend.rst diff --git a/tests/data/needextend_demo/needextend.rst b/tests/data/needextend_demo/needextend.rst new file mode 100644 index 0000000..5d932dd --- /dev/null +++ b/tests/data/needextend_demo/needextend.rst @@ -0,0 +1,14 @@ +.. needextend:: NEED_001 + :remote_url: https://github.com/useblocks/sphinx-codelinks/blob/1759d19e8694c049c68e7f95ce93d951d1ed0336/tests/data/need_id_refs/dummy_1.cpp#L3 + +.. needextend:: NEED_002 + :remote_url: https://github.com/useblocks/sphinx-codelinks/blob/1759d19e8694c049c68e7f95ce93d951d1ed0336/tests/data/need_id_refs/dummy_1.cpp#L3 + +.. needextend:: NEED_003 + :remote_url: https://github.com/useblocks/sphinx-codelinks/blob/1759d19e8694c049c68e7f95ce93d951d1ed0336/tests/data/need_id_refs/dummy_1.cpp#L3 + +.. needextend:: NEED_004 + :remote_url: https://github.com/useblocks/sphinx-codelinks/blob/1759d19e8694c049c68e7f95ce93d951d1ed0336/tests/data/need_id_refs/dummy_1.cpp#L3 + +.. needextend:: NEED_003 + :remote_url: https://github.com/useblocks/sphinx-codelinks/blob/1759d19e8694c049c68e7f95ce93d951d1ed0336/tests/data/need_id_refs/dummy_1.cpp#L8 diff --git a/tests/test_cmd.py b/tests/test_cmd.py index 6d5adbd..60a4c2e 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -212,3 +212,73 @@ def test_analyse_project_negative(projects, output_lines, tmp_path: Path) -> Non assert result.exit_code != 0 for line in output_lines: assert line in result.stdout + + +def test_write_rst_invalid_json(tmp_path: Path) -> None: + json_obj = { + "whatever": "json", + "not": "expected", + } + json_text = json.dumps(json_obj) + json_text = json_text.replace(",", "") + jsonpath = tmp_path / "invalid_json.json" + with jsonpath.open("w") as f: + f.write(json_text) + + options = [ + "write", + "rst", + str(jsonpath), + "--outpath", + str(tmp_path / "out.rst"), + ] + result = runner.invoke(app, options) + + assert result.exit_code != 0 + assert "Expecting" in result.stdout + + +@pytest.mark.parametrize( + ("json_objs", "output_lines"), + [ + ( + [ + { + "filepath": 123, + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/951e40e7845f06d5cfc4ca20ebb984308fdaf985/tests/data/marked_rst/dummy_1.cpp#L4", + "source_map": { + "start": {"row": 3, "column": 8}, + "end": {"row": 3, "column": 61}, + }, + "tagged_scope": "void dummy_func1(){\n //...\n }", + "rst": ".. impl:: implement dummy function 1\n :id: IMPL_71\n", + "type": "need-id-refs", + } + ], + [ + "Invalid value: Errors occurred", + "Schema validation error in field 'filepath': 123 is not of type 'string'", + "Marker is required for marked content of type 'need_id_refs'", + "Need id refs are required for marked content of type 'need_id_refs'", + ], + ), + ], +) +def test_write_rst_negative(json_objs: list[dict], output_lines, tmp_path) -> None: + to_dump = {"project_1": json_objs} + jsonpath = Path("invalid_objs.json") + with jsonpath.open("w") as f: + json.dump(to_dump, f) + outpath = tmp_path / "needextend.rst" + options = [ + "write", + "rst", + str(jsonpath), + "--outpath", + str(outpath), + ] + result = runner.invoke(app, options) + + assert result.exit_code != 0 + for line in output_lines: + assert line in result.stdout diff --git a/tests/test_needextend_write.py b/tests/test_needextend_write.py new file mode 100644 index 0000000..4a2342e --- /dev/null +++ b/tests/test_needextend_write.py @@ -0,0 +1,71 @@ +import pytest + +from sphinx_codelinks.needextend_write import convert_marked_content + + +@pytest.mark.parametrize( + ("markers", "texts"), + [ + ( + [ + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/need_id_refs/dummy_1.cpp", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L3", + "source_map": { + "start": {"row": 2, "column": 13}, + "end": {"row": 2, "column": 51}, + }, + "tagged_scope": "void dummy_func1(){\n //...\n }", + "need_ids": ["NEED_001", "NEED_002", "NEED_003", "NEED_004"], + "marker": "@need-ids:", + "type": "need-id-refs", + }, + ], + [ + ".. needextend:: NEED_001\n :remote-url: https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L3\n\n", + ".. needextend:: NEED_002\n :remote-url: https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L3\n\n", + ".. needextend:: NEED_003\n :remote-url: https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L3\n\n", + ".. needextend:: NEED_004\n :remote-url: https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L3\n\n", + ], + ), + ( + [ + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/need_id_refs/dummy_1.cpp", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L3", + "source_map": { + "start": {"row": 2, "column": 13}, + "end": {"row": 2, "column": 51}, + }, + "tagged_scope": "void dummy_func1(){\n //...\n }", + "need_ids": ["NEED_001"], + "marker": "@need-ids:", + "type": "need-id-refs", + }, + { + "filepath": "/home/jui-wen/git_repo/ub/sphinx-codelinks/tests/data/need_id_refs/dummy_1.cpp", + "remote_url": "https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L10", + "source_map": { + "start": {"row": 2, "column": 13}, + "end": {"row": 2, "column": 51}, + }, + "tagged_scope": "void dummy_func1(){\n //...\n }", + "need_ids": ["NEED_001"], + "marker": "@need-ids:", + "type": "need-id-refs", + }, + ], + [ + ".. needextend:: NEED_001\n :remote-url: https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L3,https://github.com/useblocks/sphinx-codelinks/blob/main/tests/data/need_id_refs/dummy_1.cpp#L10\n\n" + ], + ), + ], +) +def test_convert_marked_content(markers, texts): + # Normalize line endings + texts = [line.replace("\r\n", "\n").replace("\r", "\n") for line in texts] + needextend_texts, errors = convert_marked_content(markers) + + assert not errors + + assert needextend_texts == texts