From c22dd0cee8d3ddb1eea776577319802ccf0e913f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:26:23 +0000 Subject: [PATCH 1/6] Wire up every NfDocsConfig option NfDocsConfig advertised seven options through get_example_config(), but only ignore_config_prefixes did anything. Most were aspirational from the start. strip_readme_badges was not: it was wired when the config module landed, then dropped by 65aa08f ("Try fixing the HTML styling regression"), which reverted extractor.py to a pre-config state while chasing a CSS bug. The same commit removed the `nf-docs config` command and undid an unrelated exception-narrowing change. 2fb2a9b restored the ignore_config_prefixes filter but not the rest. - ignore_input_prefixes and include_hidden_params now filter schema inputs. Parameters dropped this way are also suppressed from the Configuration section, rather than reappearing under a different heading. - strip_readme_badges is consulted again. - max_readme_length truncates README content on a line boundary, before images are embedded so the limit measures prose rather than data URIs. - exclude_patterns is appended to the language server's exclusions rather than replacing them; the built-in list must stay non-empty or the server never initialises the workspace. - default_format is read by the CLI when -f/--format is not given, with a warning and an html fallback for an unknown value. - Restored the `nf-docs config` command, without which get_example_config() is unreachable from the shipped tool. include_hidden_params defaults to true rather than the documented false. Hidden parameters have always been shown, so honouring the field as written would have deleted them from every existing user's output. These options only stay honoured because the cache is keyed on the config, which the parent commit added; NfDocsConfig.cache_key() hashes to_dict(), so the newly-live fields are covered without further change. Verified by changing an option against a warm cache. Each option gets a test that fails if it stops being consulted; the existing tests only round-trip values through the dataclass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012iDGBm7Vt6rZ5FsZd1oZux --- CHANGELOG.md | 11 +++ docs/python-api.md | 20 +++++- src/nf_docs/cli.py | 139 ++++++++++++++++++++++++++++++++--- src/nf_docs/config.py | 39 +++++----- src/nf_docs/extractor.py | 75 +++++++++++++++++-- src/nf_docs/lsp_client.py | 35 +++++++-- src/nf_docs/output.py | 3 + tests/conftest.py | 3 +- tests/test_cli.py | 100 +++++++++++++++++++++++++- tests/test_config.py | 24 +++---- tests/test_extractor.py | 148 ++++++++++++++++++++++++++++++++++++++ tests/test_lsp_client.py | 37 ++++++++++ 12 files changed, 582 insertions(+), 52 deletions(-) create mode 100644 tests/test_lsp_client.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa61c5..9ada617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,18 @@ - `PipelineExtractor` accepts a `config=` argument, so callers can supply an `NfDocsConfig` instead of relying on the global instance - New "Python API" documentation page +- Every `NfDocsConfig` option now takes effect. `ignore_input_prefixes`, `include_hidden_params`, + `max_readme_length` and `exclude_patterns` were documented but never consulted; `default_format` + was ignored in favour of a hardcoded `html` +- Restored the `nf-docs config` command (`--path`, `--show-example`, `--init`), which had been + removed by accident along with the config wiring ### Fixed +- `strip_readme_badges` is consulted again when parsing a README. It was wired up when the option + was introduced, then dropped by an unrelated revert in the same release +- A parameter excluded by `include_hidden_params` or `ignore_input_prefixes` no longer reappears in + the Configuration section - The extraction cache is now keyed on the configuration as well as the pipeline contents. Previously a CLI run (which loads `~/.config/nf-docs/config.yaml`) and a library call (which uses defaults) shared a cache entry for the same unchanged pipeline and returned each other's results @@ -23,6 +32,8 @@ ### Changed +- `include_hidden_params` now defaults to `true`, matching the behaviour nf-docs has always had of + showing hidden schema parameters. Set it to `false` to leave them out - Output path policy (format aliases, single-module detection, default filenames) moved from `cli.py` into a new `nf_docs.output` module so the CLI and the Python API share one implementation - Library callers now get `NfDocsConfig()` defaults rather than the user's diff --git a/docs/python-api.md b/docs/python-api.md index 2649ad1..119cdae 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -206,9 +206,23 @@ config = nf_docs.NfDocsConfig(ignore_config_prefixes=["genomes.", "test."]) nf_docs.extract("./my_pipeline", config=config) ``` -`ignore_config_prefixes` drops matching parameters from the Configuration section, and defaults to -`["genomes."]` for nf-core pipelines. The other `NfDocsConfig` fields exist but aren't yet wired -into extraction, so don't rely on them. +The fields: + +| Field | Default | Effect | +| ------------------------- | -------------- | ------------------------------------------------------------------------------- | +| `ignore_config_prefixes` | `["genomes."]` | Drops matching parameters from the Configuration section | +| `ignore_input_prefixes` | `[]` | Drops matching parameters from the Parameters section | +| `include_hidden_params` | `True` | Set `False` to leave out parameters marked `hidden` in `nextflow_schema.json` | +| `max_readme_length` | `0` | Trims README content to this many characters, on a line boundary. `0` = no limit | +| `strip_readme_badges` | `True` | Set `False` to keep the badge lines below a README's title | +| `exclude_patterns` | `[]` | Extra paths the Language Server skips when indexing the workspace | +| `default_format` | `"html"` | CLI only — the format `nf-docs generate` uses without `-f/--format` | + +A parameter dropped by `include_hidden_params` or `ignore_input_prefixes` doesn't reappear under +Configuration; it's left out of the documentation entirely. + +`exclude_patterns` is added to the exclusions nf-docs always sends (`.git`, `.nf-test`, `work`) +rather than replacing them. ## Caching diff --git a/src/nf_docs/cli.py b/src/nf_docs/cli.py index c5b947e..59883ab 100644 --- a/src/nf_docs/cli.py +++ b/src/nf_docs/cli.py @@ -23,11 +23,18 @@ from nf_docs import __version__ from nf_docs.api import extract as extract_pipeline -from nf_docs.config import load_config +from nf_docs.config import ( + DEFAULT_CONFIG, + NfDocsConfig, + get_config_path, + get_example_config, + load_config, +) from nf_docs.extractor import ExtractionError, PipelineExtractor from nf_docs.lsp_client import LSPError from nf_docs.output import ( DIRECTORY_FORMATS, + SUPPORTED_FORMATS, default_output_dir, normalize_format, resolve_data_file_path, @@ -177,6 +184,36 @@ def _display_path(path: Path) -> str: return str(path) +def _resolve_output_format(output_format: str | None, config: NfDocsConfig) -> str: + """ + Work out which output format to use. + + An explicit ``-f/--format`` always wins. With no flag we fall back to the + config file's ``default_format``, warning and using the built-in default if + that value isn't a format we can render. + + Args: + output_format: The value of ``-f/--format``, or None if it wasn't given + config: The user's loaded configuration + + Returns: + A canonical format name + """ + if output_format is not None: + return normalize_format(output_format) + + configured = normalize_format(config.default_format) + if configured in SUPPORTED_FORMATS: + return configured + + fallback = normalize_format(DEFAULT_CONFIG["default_format"]) + console.print( + f"[yellow]Ignoring unknown default_format {config.default_format!r} " + f"in {get_config_path()}, using {fallback}[/yellow]" + ) + return fallback + + @main.command() @click.argument("pipeline_path", type=click.Path(exists=True, path_type=Path)) @click.option( @@ -184,8 +221,11 @@ def _display_path(path: Path) -> str: "-f", "output_format", type=click.Choice(["json", "yaml", "markdown", "md", "html", "table"], case_sensitive=False), - default="html", - help="Output format: json, yaml, markdown (or md), html, table (default: html)", + default=None, + help=( + "Output format: json, yaml, markdown (or md), html, table " + "(default: the config file's default_format, otherwise html)" + ), ) @click.option( "--output", @@ -253,7 +293,12 @@ def generate( """ setup_logging(verbose) - output_format = normalize_format(output_format) + # The CLI honours the user's ~/.config/nf-docs/config.yaml; the Python API + # deliberately does not (see nf_docs.api.extract). + user_config = load_config() + + # An explicit --format wins; otherwise fall back to the configured default. + output_format = _resolve_output_format(output_format, user_config) # Decide single-file vs directory mode up front. A directory holding only a # module-style main.nf is auto-detected as a single module. @@ -266,10 +311,6 @@ def generate( if single_file_mode: logging.getLogger("nf_docs").info("Generating single module documentation") - # The CLI honours the user's ~/.config/nf-docs/config.yaml; the Python API - # deliberately does not (see nf_docs.api.extract). - user_config = load_config() - try: with ExtractionProgressDisplay(console) as progress_display: pipeline = extract_pipeline( @@ -586,5 +627,87 @@ def clear_cache(clear_all: bool, pipeline_path: Path | None) -> None: raise SystemExit(1) +@main.command() +@click.option( + "--init", + "init_config", + is_flag=True, + help="Create an example config file at the default location", +) +@click.option( + "--show-example", + is_flag=True, + help="Print an example config file to stdout", +) +@click.option( + "--path", + is_flag=True, + help="Print the config file path", +) +def config(init_config: bool, show_example: bool, path: bool) -> None: + """ + Show or manage nf-docs configuration. + + Without options, shows the current configuration values. + + Examples: + + # Show current config + nf-docs config + + # Show config file path + nf-docs config --path + + # Print example config + nf-docs config --show-example + + # Create config file with defaults + nf-docs config --init + """ + config_path = get_config_path() + + if path: + console.print(str(config_path)) + return + + if show_example: + click.echo(get_example_config()) + return + + if init_config: + if config_path.exists(): + console.print(f"[yellow]Config file already exists at {config_path}[/yellow]") + console.print("Edit it directly or delete it first to recreate.") + return + + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(get_example_config(), encoding="utf-8") + console.print(f"[green]Created config file at {config_path}[/green]") + return + + # Default: show current config + console.print(f"[bold]Config file:[/bold] {config_path}") + if config_path.exists(): + console.print("[dim] (file exists)[/dim]") + else: + console.print("[dim] (using defaults - file does not exist)[/dim]") + + console.print() + console.print("[bold]Current settings:[/bold]") + + for key, value in load_config().to_dict().items(): + if isinstance(value, list): + if value: + console.print(f" {key}:") + for item in value: + console.print(f" - {item}") + else: + console.print(f" {key}: []") + elif isinstance(value, bool): + console.print(f" {key}: {str(value).lower()}") + else: + console.print(f" {key}: {value}") + + if __name__ == "__main__": main() diff --git a/src/nf_docs/config.py b/src/nf_docs/config.py index af1ade0..580e895 100644 --- a/src/nf_docs/config.py +++ b/src/nf_docs/config.py @@ -27,14 +27,14 @@ # Prefixes for config parameters to ignore in inputs "ignore_input_prefixes": [], # Whether to include hidden parameters in documentation - "include_hidden_params": False, - # Default output format + "include_hidden_params": True, + # Default output format for the CLI "default_format": "html", # Maximum README length (in characters) to include. 0 = no limit "max_readme_length": 0, # Whether to strip badge lines from the top of README files "strip_readme_badges": True, - # Patterns for files to exclude from LSP analysis + # Extra paths for the Language Server to skip when indexing the workspace "exclude_patterns": [], } @@ -65,19 +65,22 @@ class NfDocsConfig: Parameters starting with any of these prefixes will be excluded from the Parameters section. Default: []. include_hidden_params: Whether to include parameters marked as hidden - in the nextflow_schema.json. Default: False. - default_format: Default output format when not specified. Default: "html". + in the nextflow_schema.json. Default: True. + default_format: Default output format for the ``nf-docs generate`` command + when ``-f/--format`` is not given. Only the CLI reads this; the Python + API takes its format as an argument. Default: "html". max_readme_length: Maximum README content length to include (0 = no limit). Default: 0. strip_readme_badges: Whether to strip badge lines (images/links at the top) from README files. Default: True. - exclude_patterns: Glob patterns for files to exclude from LSP analysis. - Default: []. + exclude_patterns: Extra paths for the Language Server to skip when indexing + the workspace, added to the built-in exclusions rather than replacing + them. Default: []. """ ignore_config_prefixes: list[str] = field(default_factory=lambda: ["genomes."]) ignore_input_prefixes: list[str] = field(default_factory=list) - include_hidden_params: bool = False + include_hidden_params: bool = True default_format: str = "html" max_readme_length: int = 0 strip_readme_badges: bool = True @@ -217,11 +220,12 @@ def get_example_config() -> str: # Whether to include parameters marked as "hidden" in nextflow_schema.json. # Hidden parameters are typically advanced options not needed by most users. -# Default: false -include_hidden_params: false +# Set to false to leave them out of the documentation entirely. +# Default: true +include_hidden_params: true -# Default output format when -f/--format is not specified. -# Options: html, json, yaml, markdown, md +# Default output format for `nf-docs generate` when -f/--format is not specified. +# Options: html, json, yaml, markdown (or md), table # Default: html default_format: html @@ -236,10 +240,13 @@ def get_example_config() -> str: # Default: true strip_readme_badges: true -# Glob patterns for files to exclude from LSP analysis. -# Useful for excluding test files, examples, etc. +# Extra paths for the Nextflow Language Server to skip when indexing. +# Useful for excluding test data, examples, etc. These are added to the paths +# nf-docs always excludes (.git, .nf-test, work) rather than replacing them. +# Entries are passed through to the language server's `nextflow.files.exclude` +# setting, so directory names are the safest thing to put here. # Default: [] exclude_patterns: [] -# - "tests/**/*.nf" -# - "examples/**/*.nf" +# - "tests" +# - "examples" """ diff --git a/src/nf_docs/extractor.py b/src/nf_docs/extractor.py index d6bc1aa..a90a3eb 100644 --- a/src/nf_docs/extractor.py +++ b/src/nf_docs/extractor.py @@ -30,6 +30,7 @@ Function, FunctionParam, Pipeline, + PipelineInput, PipelineMetadata, Process, ProcessInput, @@ -245,6 +246,12 @@ def extract(self) -> Pipeline: ) return pipeline + # Names of every schema parameter, including any dropped by config + # filtering below. Config params matching one of these are suppressed + # from the Configuration section, so a parameter the user asked to hide + # doesn't simply reappear under a different heading. + schema_param_names: set[str] = set() + # Extract from schema (has highest priority for inputs and metadata) schema_file = find_schema_file(self.workspace_path) if schema_file: @@ -259,7 +266,8 @@ def extract(self) -> Pipeline: try: schema_metadata, schema_inputs = parse_schema(schema_file) pipeline.metadata = schema_metadata - pipeline.inputs = schema_inputs + schema_param_names = {inp.name for inp in schema_inputs} + pipeline.inputs = self._filter_inputs(schema_inputs) except Exception as e: logger.warning(f"Failed to parse schema: {e}") @@ -275,11 +283,11 @@ def extract(self) -> Pipeline: # Merge metadata (schema takes priority) pipeline.metadata = self._merge_metadata(pipeline.metadata, config_metadata) # Filter config params to exclude those already in inputs and ignored prefixes - input_names = {inp.name for inp in pipeline.inputs} pipeline.config_params = [ p for p in config_params - if p.name not in input_names and not self.config.should_ignore_config_param(p.name) + if p.name not in schema_param_names + and not self.config.should_ignore_config_param(p.name) ] except Exception as e: logger.warning(f"Failed to parse config: {e}") @@ -333,6 +341,30 @@ def extract(self) -> Pipeline: return pipeline + def _filter_inputs(self, inputs: list[PipelineInput]) -> list[PipelineInput]: + """ + Drop schema inputs the configuration asks us to leave out. + + Removes parameters marked ``hidden`` in the schema unless + ``include_hidden_params`` is set, and any parameter matching + ``ignore_input_prefixes``. + + Args: + inputs: Parameters as parsed from ``nextflow_schema.json`` + + Returns: + The parameters that should appear in the documentation + """ + kept = [ + inp + for inp in inputs + if (self.config.include_hidden_params or not inp.hidden) + and not self.config.should_ignore_input_param(inp.name) + ] + if len(kept) < len(inputs): + logger.debug(f"Filtered out {len(inputs) - len(kept)} of {len(inputs)} schema inputs") + return kept + def _merge_metadata( self, primary: PipelineMetadata, secondary: PipelineMetadata ) -> PipelineMetadata: @@ -351,7 +383,8 @@ def _extract_readme_content(self) -> str: """ Extract full README content after the first h1 heading. - Also converts local images to base64 data URIs for portability. + Also truncates to ``max_readme_length`` and converts local images to + base64 data URIs for portability. """ readme_candidates = [ self.workspace_path / "README.md", @@ -364,6 +397,9 @@ def _extract_readme_content(self) -> str: try: content = readme_path.read_text(encoding="utf-8") parsed_content = self._parse_readme_content(content) + # Truncate before embedding images, so the limit applies to + # the prose rather than to base64 data URIs + parsed_content = self._truncate_readme_content(parsed_content) # Convert local images to base64 return self._convert_images_to_base64(parsed_content, readme_path.parent) except Exception as e: @@ -371,17 +407,41 @@ def _extract_readme_content(self) -> str: return "" + def _truncate_readme_content(self, content: str) -> str: + """ + Trim README content to ``max_readme_length`` characters. + + Cuts at the last line break at or before the limit so that markdown + constructs aren't severed mid-line, falling back to a hard cut for + content with no line break in range. A limit of 0 means no limit. + + Args: + content: Parsed README content + + Returns: + The content, truncated if it exceeded the configured limit + """ + limit = self.config.max_readme_length + if limit <= 0 or len(content) <= limit: + return content + + cut = content.rfind("\n", 0, limit + 1) + truncated = content[:cut] if cut > 0 else content[:limit] + logger.debug(f"Truncated README from {len(content)} to {len(truncated)} characters") + return truncated + def _parse_readme_content(self, content: str) -> str: """ Parse README content, returning everything after the first h1 heading. - Strips the title (markdown # or HTML

) and any badge lines immediately following. + Strips the title (markdown # or HTML

) and, when + ``strip_readme_badges`` is set, any badge lines immediately following. """ lines = content.split("\n") result_lines: list[str] = [] found_title = False in_html_h1 = False - skip_badges = True + skip_badges = self.config.strip_readme_badges for line in lines: stripped = line.strip() @@ -410,7 +470,7 @@ def _parse_readme_content(self, content: str) -> str: # Still looking for title continue - # Skip badge lines immediately after title + # Skip badge lines immediately after title (if configured) if skip_badges: # Badge patterns: [![...], ![...], [!..., or lines containing "badge" if ( @@ -531,6 +591,7 @@ def _extract_from_lsp(self, pipeline: Pipeline, git_info: GitInfo | None = None) lsp_root, server_jar=self.language_server_jar, progress_callback=self._progress, + exclude_patterns=self.config.exclude_patterns, ) as client: # Try workspace symbols first to see what the LSP knows about workspace_symbols = client.get_workspace_symbols("") diff --git a/src/nf_docs/lsp_client.py b/src/nf_docs/lsp_client.py index 4f4d31b..78a1bf6 100644 --- a/src/nf_docs/lsp_client.py +++ b/src/nf_docs/lsp_client.py @@ -47,6 +47,31 @@ def get_xdg_data_home() -> Path: LANGUAGE_SERVER_REPO = "nextflow-io/language-server" LANGUAGE_SERVER_JAR = "language-server-all.jar" +# Paths always excluded from the language server's workspace scan. This list +# must stay non-empty: the server only initialises a workspace when the +# configuration it receives differs from its defaults (see initialize()). +DEFAULT_LSP_EXCLUDES = [".git", ".nf-test", "work"] + + +def build_exclude_list(exclude_patterns: list[str] | None = None) -> list[str]: + """ + Combine the built-in exclusions with any the user configured. + + User patterns are appended rather than substituted, so that configuring + them never removes the built-in exclusions or empties the list. + + Args: + exclude_patterns: Extra paths/patterns from ``NfDocsConfig.exclude_patterns`` + + Returns: + The exclusions to send to the language server, de-duplicated and in order + """ + excludes: list[str] = [] + for entry in [*DEFAULT_LSP_EXCLUDES, *(exclude_patterns or [])]: + if entry and entry not in excludes: + excludes.append(entry) + return excludes + class LSPError(Exception): """Exception raised for LSP-related errors.""" @@ -69,6 +94,7 @@ def __init__( java_path: str = "java", auto_download: bool = True, progress_callback: ProgressCallbackType | None = None, + exclude_patterns: list[str] | None = None, ): """ Initialize the LSP client. @@ -79,10 +105,13 @@ def __init__( java_path: Path to Java executable auto_download: Whether to auto-download the language server if not found progress_callback: Optional callback for progress updates + exclude_patterns: Extra paths to exclude from the workspace scan, added + to :data:`DEFAULT_LSP_EXCLUDES` """ self.workspace_path = Path(workspace_path).resolve() self.java_path = java_path self.auto_download = auto_download + self.exclude_patterns = build_exclude_list(exclude_patterns) self._progress = progress_callback or null_progress self._process: subprocess.Popen | None = None self._request_id = 0 @@ -467,15 +496,13 @@ def _initialize(self) -> None: # Send configuration to trigger workspace initialization. # The LSP only initializes workspaces when didChangeConfiguration is received # AND the configuration differs from defaults in specific fields. - # We set excludePatterns to a non-empty list to trigger initialization. + # The exclude list is always non-empty, which is what triggers initialization. self._send_notification( "workspace/didChangeConfiguration", { "settings": { "nextflow": { - "files": { - "exclude": [".git", ".nf-test", "work"] # Non-empty to trigger init - }, + "files": {"exclude": self.exclude_patterns}, "formatting": {"harshilAlignment": False}, "java": {"home": ""}, "suppressFutureWarnings": False, diff --git a/src/nf_docs/output.py b/src/nf_docs/output.py index e5946b6..e0e8cb4 100644 --- a/src/nf_docs/output.py +++ b/src/nf_docs/output.py @@ -35,6 +35,9 @@ # Formats that render as a directory of files rather than a single document. DIRECTORY_FORMATS: frozenset[str] = frozenset({"markdown", "html", "table"}) +# Every canonical format name, i.e. what ``normalize_format`` may return. +SUPPORTED_FORMATS: frozenset[str] = frozenset(SINGLE_FILE_OUTPUT_POLICY) + # Filename stem used for json/yaml output of a whole pipeline. DATA_FILE_STEM = "pipeline" diff --git a/tests/conftest.py b/tests/conftest.py index de1afd0..ddb04c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,8 @@ def isolate_xdg_dirs(tmp_path_factory, monkeypatch) -> None: Without this, any test that extracts with caching left entries in the developer's real ``~/.cache/nf-docs/`` - and because the Language Server is mocked out in tests, those entries are empty results that a later real run - could pick up. + could pick up. It also keeps anything reaching ``load_config()`` independent + of whether the person running the suite has a real config file. """ monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path_factory.mktemp("xdg_cache"))) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path_factory.mktemp("xdg_config"))) diff --git a/tests/test_cli.py b/tests/test_cli.py index c0eeff9..6379d19 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,8 @@ import pytest from click.testing import CliRunner -from nf_docs.cli import generate, inspect, main +from nf_docs.cli import config, generate, inspect, main +from nf_docs.config import get_config_path from nf_docs.extractor import PipelineExtractor @@ -265,3 +266,100 @@ def test_inspect_verbose(self, runner: CliRunner, sample_pipeline: Path): with patch.object(PipelineExtractor, "_extract_from_lsp"): result = runner.invoke(inspect, [str(sample_pipeline), "--verbose"]) assert result.exit_code == 0 + + +class TestDefaultFormatConfig: + """ + The config file's default_format is what `generate` uses without -f. + + ``isolate_xdg_dirs`` (conftest) points XDG_CONFIG_HOME at a scratch + directory, so these write a real config file and exercise ``load_config()``. + """ + + def _write_config(self, body: str) -> Path: + config_file = get_config_path() + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(body, encoding="utf-8") + return config_file + + def test_default_format_from_config_is_used(self, runner: CliRunner, sample_pipeline: Path): + """With no -f, the configured format wins over the built-in html.""" + self._write_config("default_format: json\n") + + with patch.object(PipelineExtractor, "_extract_from_lsp"): + result = runner.invoke(generate, [str(sample_pipeline)]) + + assert result.exit_code == 0 + assert '"pipeline"' in result.output + + def test_explicit_format_flag_wins(self, runner: CliRunner, sample_pipeline: Path): + """An explicit -f overrides the configured default.""" + self._write_config("default_format: json\n") + + with patch.object(PipelineExtractor, "_extract_from_lsp"): + result = runner.invoke(generate, [str(sample_pipeline), "--format", "yaml"]) + + assert result.exit_code == 0 + assert "pipeline:" in result.output + + def test_no_config_file_still_defaults_to_html( + self, runner: CliRunner, sample_pipeline: Path, tmp_path: Path + ): + """Without a config file the default is unchanged.""" + output = tmp_path / "site" + + with patch.object(PipelineExtractor, "_extract_from_lsp"): + result = runner.invoke(generate, [str(sample_pipeline), "-o", str(output)]) + + assert result.exit_code == 0 + assert (output / "index.html").exists() + + def test_unknown_default_format_warns_and_falls_back( + self, runner: CliRunner, sample_pipeline: Path, tmp_path: Path + ): + """A bad config value is a warning and html, not a traceback.""" + self._write_config("default_format: nonsense\n") + output = tmp_path / "site" + + with patch.object(PipelineExtractor, "_extract_from_lsp"): + result = runner.invoke(generate, [str(sample_pipeline), "-o", str(output)]) + + assert result.exit_code == 0 + assert "nonsense" in result.output + assert (output / "index.html").exists() + + +class TestConfigCommand: + def test_shows_current_settings(self, runner: CliRunner): + result = runner.invoke(config, []) + assert result.exit_code == 0 + assert "ignore_config_prefixes" in result.output + + def test_path(self, runner: CliRunner): + result = runner.invoke(config, ["--path"]) + assert result.exit_code == 0 + assert "config.yaml" in result.output + + def test_show_example(self, runner: CliRunner): + result = runner.invoke(config, ["--show-example"]) + assert result.exit_code == 0 + assert "ignore_config_prefixes:" in result.output + + def test_init_creates_file(self, runner: CliRunner): + result = runner.invoke(config, ["--init"]) + assert result.exit_code == 0 + + created = get_config_path() + assert created.exists() + assert "ignore_config_prefixes:" in created.read_text() + + def test_init_does_not_overwrite(self, runner: CliRunner): + existing = get_config_path() + existing.parent.mkdir(parents=True) + existing.write_text("default_format: json\n") + + result = runner.invoke(config, ["--init"]) + + assert result.exit_code == 0 + assert "already exists" in result.output + assert existing.read_text() == "default_format: json\n" diff --git a/tests/test_config.py b/tests/test_config.py index 126ac95..38fde6a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,7 +49,7 @@ def test_default_values(self) -> None: config = NfDocsConfig() assert config.ignore_config_prefixes == ["genomes."] assert config.ignore_input_prefixes == [] - assert config.include_hidden_params is False + assert config.include_hidden_params is True assert config.default_format == "html" assert config.max_readme_length == 0 assert config.strip_readme_badges is True @@ -86,7 +86,7 @@ def test_from_dict_full(self) -> None: data = { "ignore_config_prefixes": ["custom."], "ignore_input_prefixes": ["private."], - "include_hidden_params": True, + "include_hidden_params": False, "default_format": "json", "max_readme_length": 5000, "strip_readme_badges": False, @@ -96,7 +96,7 @@ def test_from_dict_full(self) -> None: assert config.ignore_config_prefixes == ["custom."] assert config.ignore_input_prefixes == ["private."] - assert config.include_hidden_params is True + assert config.include_hidden_params is False assert config.default_format == "json" assert config.max_readme_length == 5000 assert config.strip_readme_badges is False @@ -105,13 +105,13 @@ def test_from_dict_full(self) -> None: def test_from_dict_partial(self) -> None: """Test from_dict with only some values specified.""" data = { - "include_hidden_params": True, + "include_hidden_params": False, "default_format": "yaml", } config = NfDocsConfig.from_dict(data) # Specified values - assert config.include_hidden_params is True + assert config.include_hidden_params is False assert config.default_format == "yaml" # Default values @@ -129,13 +129,13 @@ def test_to_dict(self) -> None: """Test to_dict conversion.""" config = NfDocsConfig( ignore_config_prefixes=["foo."], - include_hidden_params=True, + include_hidden_params=False, default_format="markdown", ) result = config.to_dict() assert result["ignore_config_prefixes"] == ["foo."] - assert result["include_hidden_params"] is True + assert result["include_hidden_params"] is False assert result["default_format"] == "markdown" assert "ignore_input_prefixes" in result assert "max_readme_length" in result @@ -151,7 +151,7 @@ def test_load_default_when_file_missing(self, tmp_path: Path) -> None: config = load_config(tmp_path / "nonexistent" / "config.yaml") assert config.ignore_config_prefixes == ["genomes."] - assert config.include_hidden_params is False + assert config.include_hidden_params is True @pytest.mark.parametrize( "contents", @@ -175,7 +175,7 @@ def test_load_from_file(self, tmp_path: Path) -> None: ignore_config_prefixes: - "custom." - "other." -include_hidden_params: true +include_hidden_params: false default_format: json """ ) @@ -183,7 +183,7 @@ def test_load_from_file(self, tmp_path: Path) -> None: config = load_config(config_file) assert config.ignore_config_prefixes == ["custom.", "other."] - assert config.include_hidden_params is True + assert config.include_hidden_params is False assert config.default_format == "json" # Non-specified values should use defaults assert config.strip_readme_badges is True @@ -197,7 +197,7 @@ def test_load_empty_file(self, tmp_path: Path) -> None: # Should use all defaults assert config.ignore_config_prefixes == ["genomes."] - assert config.include_hidden_params is False + assert config.include_hidden_params is True def test_load_invalid_yaml(self, tmp_path: Path) -> None: """Test that invalid YAML returns defaults with warning.""" @@ -208,7 +208,7 @@ def test_load_invalid_yaml(self, tmp_path: Path) -> None: # Should use defaults on error assert config.ignore_config_prefixes == ["genomes."] - assert config.include_hidden_params is False + assert config.include_hidden_params is True class TestGetExampleConfig: diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 2ea71c2..5017fd2 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -1,10 +1,13 @@ """Tests for the pipeline extractor.""" +import json from pathlib import Path from unittest.mock import patch from nf_docs.cache import PipelineCache +from nf_docs.config import NfDocsConfig from nf_docs.extractor import PipelineExtractor, find_pipeline_root +from nf_docs.models import ConfigParam, Pipeline, PipelineMetadata class TestPipelineExtractor: @@ -228,6 +231,151 @@ def test_inputs_grouped_correctly(self, sample_pipeline: Path): assert "Reference genome options" in groups +class TestConfigOptions: + """ + Each NfDocsConfig option changes what comes out of extraction. + + These assert both settings of every option, so they fail whether an option + stops being consulted or starts being applied backwards. + """ + + def _write_schema(self, workspace: Path) -> None: + """Write a schema with one hidden parameter and one prefixed parameter.""" + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test Pipeline", + "$defs": { + "options": { + "title": "Options", + "type": "object", + "properties": { + "input": {"type": "string", "description": "Samplesheet"}, + "tracedir": { + "type": "string", + "description": "Trace directory", + "hidden": True, + }, + "internal_debug": {"type": "boolean", "description": "Debug"}, + }, + } + }, + } + (workspace / "nextflow_schema.json").write_text(json.dumps(schema)) + + def _extract(self, workspace: Path, config: NfDocsConfig) -> Pipeline: + """Extract from ``workspace`` with the LSP and cache out of the way.""" + with patch.object(PipelineExtractor, "_extract_from_lsp"): + extractor = PipelineExtractor(workspace_path=workspace, config=config, use_cache=False) + return extractor.extract() + + def test_include_hidden_params_keeps_hidden_by_default(self, tmp_path: Path): + """Hidden schema parameters are included unless the option says otherwise.""" + self._write_schema(tmp_path) + + pipeline = self._extract(tmp_path, NfDocsConfig()) + + assert "tracedir" in {inp.name for inp in pipeline.inputs} + + def test_include_hidden_params_false_drops_hidden(self, tmp_path: Path): + """Setting include_hidden_params to False removes hidden parameters.""" + self._write_schema(tmp_path) + + pipeline = self._extract(tmp_path, NfDocsConfig(include_hidden_params=False)) + + input_names = {inp.name for inp in pipeline.inputs} + assert "tracedir" not in input_names + # Visible parameters are untouched + assert "input" in input_names + + def test_ignore_input_prefixes_drops_matching_params(self, tmp_path: Path): + """Inputs matching ignore_input_prefixes are excluded.""" + self._write_schema(tmp_path) + + kept = self._extract(tmp_path, NfDocsConfig()) + assert "internal_debug" in {inp.name for inp in kept.inputs} + + dropped = self._extract(tmp_path, NfDocsConfig(ignore_input_prefixes=["internal_"])) + input_names = {inp.name for inp in dropped.inputs} + assert "internal_debug" not in input_names + assert "input" in input_names + + def test_filtered_inputs_do_not_reappear_as_config_params(self, tmp_path: Path): + """A parameter hidden from the inputs section isn't shown as a config param.""" + self._write_schema(tmp_path) + config_params = [ConfigParam(name="tracedir", default="./trace")] + + with patch( + "nf_docs.extractor.parse_config", return_value=(PipelineMetadata(), config_params) + ): + pipeline = self._extract(tmp_path, NfDocsConfig(include_hidden_params=False)) + + assert "tracedir" not in {p.name for p in pipeline.config_params} + + def test_ignore_config_prefixes_drops_matching_params(self, tmp_path: Path): + """Config params matching ignore_config_prefixes are excluded.""" + config_params = [ + ConfigParam(name="genomes.GRCh38.fasta", default="ref.fa"), + ConfigParam(name="max_cpus", default=16), + ] + + with patch( + "nf_docs.extractor.parse_config", return_value=(PipelineMetadata(), config_params) + ): + kept = self._extract(tmp_path, NfDocsConfig(ignore_config_prefixes=[])) + dropped = self._extract(tmp_path, NfDocsConfig(ignore_config_prefixes=["genomes."])) + + assert "genomes.GRCh38.fasta" in {p.name for p in kept.config_params} + dropped_names = {p.name for p in dropped.config_params} + assert "genomes.GRCh38.fasta" not in dropped_names + assert "max_cpus" in dropped_names + + def test_strip_readme_badges_false_keeps_badges(self, tmp_path: Path): + """Badge lines survive when strip_readme_badges is turned off.""" + (tmp_path / "README.md").write_text( + "# My Pipeline\n\n" + "[![Build Status](https://example.com/badge.svg)](https://example.com)\n\n" + "This is the actual description.\n" + ) + + stripped = self._extract(tmp_path, NfDocsConfig()) + kept = self._extract(tmp_path, NfDocsConfig(strip_readme_badges=False)) + + assert "badge" not in stripped.metadata.readme_content.lower() + assert "badge" in kept.metadata.readme_content.lower() + # Either way the prose is preserved + assert "actual description" in kept.metadata.readme_content.lower() + + def test_max_readme_length_truncates_at_a_line_break(self, tmp_path: Path): + """README content is trimmed to the configured length, on a line boundary.""" + body = "\n".join(f"Line {i} of the readme." for i in range(50)) + (tmp_path / "README.md").write_text(f"# My Pipeline\n\n{body}\n") + + full = self._extract(tmp_path, NfDocsConfig()).metadata.readme_content + truncated = self._extract(tmp_path, NfDocsConfig(max_readme_length=100)) + content = truncated.metadata.readme_content + + assert len(full) > 100 + assert len(content) <= 100 + assert full.startswith(content) + # Cut on a line boundary, so no line is left half-written + assert content.splitlines()[-1] in full.splitlines() + + def test_exclude_patterns_reach_the_language_server(self, tmp_path: Path): + """Configured exclude patterns are passed through to the LSP client.""" + (tmp_path / "main.nf").write_text("process FOO {}\n") + + with patch("nf_docs.extractor.LSPClient") as mock_lsp_cls: + mock_lsp_cls.return_value.__enter__.return_value.get_workspace_symbols.return_value = [] + extractor = PipelineExtractor( + workspace_path=tmp_path, + config=NfDocsConfig(exclude_patterns=["testdata"]), + use_cache=False, + ) + extractor.extract() + + assert mock_lsp_cls.call_args.kwargs["exclude_patterns"] == ["testdata"] + + class TestSymbolNameParsing: """Test parsing of Nextflow LSP symbol names.""" diff --git a/tests/test_lsp_client.py b/tests/test_lsp_client.py new file mode 100644 index 0000000..4ee6c51 --- /dev/null +++ b/tests/test_lsp_client.py @@ -0,0 +1,37 @@ +"""Tests for the Nextflow Language Server client.""" + +from nf_docs.lsp_client import DEFAULT_LSP_EXCLUDES, build_exclude_list + + +class TestBuildExcludeList: + """Tests for build_exclude_list.""" + + def test_defaults_when_nothing_configured(self): + """With no user patterns we send exactly the built-in exclusions.""" + assert build_exclude_list() == DEFAULT_LSP_EXCLUDES + assert build_exclude_list([]) == DEFAULT_LSP_EXCLUDES + + def test_user_patterns_are_appended(self): + """User patterns are added to the built-ins rather than replacing them.""" + result = build_exclude_list(["testdata", "examples"]) + + assert result[: len(DEFAULT_LSP_EXCLUDES)] == DEFAULT_LSP_EXCLUDES + assert result[len(DEFAULT_LSP_EXCLUDES) :] == ["testdata", "examples"] + + def test_duplicates_are_dropped(self): + """Repeating a built-in doesn't duplicate it.""" + result = build_exclude_list(["work", "testdata", "testdata"]) + + assert result.count("work") == 1 + assert result.count("testdata") == 1 + + def test_never_empty(self): + """ + The list must stay non-empty. + + The language server only initialises a workspace when the configuration + it receives differs from its defaults, so an empty exclude list means no + indexing at all. + """ + assert build_exclude_list([]) != [] + assert build_exclude_list([""]) != [] From ad0d053e06421d96e6074d825cfa8c6c818df8d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:56:34 +0000 Subject: [PATCH 2/6] Clean up the config-wiring commit Quality pass over 9e7397a. No behaviour change: the no-op comparison against the base branch is still byte-identical apart from the timestamp. - Drop SUPPORTED_FORMATS. It answered "is this renderable?" from SINGLE_FILE_OUTPUT_POLICY, but the authority is RENDERERS in renderers/__init__.py, and get_renderer() already raises for unknown formats. output.py imports nothing from the package, so it structurally could not consult the registry - a format in one table and not the other would have passed the guard and then raised anyway. - Derive the --format choices from RENDERERS + FORMAT_ALIASES rather than a hand-written literal, so adding a format is one edit fewer. - Use NfDocsConfig().default_format for the fallback instead of importing DEFAULT_CONFIG to say "html". - Print `nf-docs config` settings with yaml.safe_dump. The isinstance chain reproduced YAML's own bool and empty-list spellings by hand, and sent anything else through repr - not pasteable into config.yaml, which is the whole point of that output. - Move the config-param filter rationale onto the code it describes; the comment there still claimed the criterion was "already in inputs", which this work had made untrue. - build_exclude_list uses dict.fromkeys for the order-preserving dedupe. - LSPClient stores the merged list as file_excludes: the attribute was named after the constructor parameter but did not hold it. - Tests: assert the truncation boundary exactly rather than by line membership, check badge stripping preserves prose on the stripped side too, and share one config-writing helper instead of three copies. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012iDGBm7Vt6rZ5FsZd1oZux --- src/nf_docs/cli.py | 49 ++++++++++++++------------------------- src/nf_docs/extractor.py | 14 +++++------ src/nf_docs/lsp_client.py | 11 ++++----- src/nf_docs/output.py | 3 --- tests/test_cli.py | 24 +++++++++---------- tests/test_extractor.py | 5 ++-- 6 files changed, 44 insertions(+), 62 deletions(-) diff --git a/src/nf_docs/cli.py b/src/nf_docs/cli.py index 59883ab..1d6ef0c 100644 --- a/src/nf_docs/cli.py +++ b/src/nf_docs/cli.py @@ -10,6 +10,7 @@ from pathlib import Path import rich_click as click +import yaml from rich.console import Console from rich.logging import RichHandler from rich.progress import ( @@ -23,18 +24,12 @@ from nf_docs import __version__ from nf_docs.api import extract as extract_pipeline -from nf_docs.config import ( - DEFAULT_CONFIG, - NfDocsConfig, - get_config_path, - get_example_config, - load_config, -) +from nf_docs.config import NfDocsConfig, get_config_path, get_example_config, load_config from nf_docs.extractor import ExtractionError, PipelineExtractor from nf_docs.lsp_client import LSPError from nf_docs.output import ( DIRECTORY_FORMATS, - SUPPORTED_FORMATS, + FORMAT_ALIASES, default_output_dir, normalize_format, resolve_data_file_path, @@ -43,7 +38,7 @@ streams_by_default, ) from nf_docs.progress import ProgressUpdate -from nf_docs.renderers import get_renderer +from nf_docs.renderers import RENDERERS, get_renderer console = Console() @@ -202,16 +197,17 @@ def _resolve_output_format(output_format: str | None, config: NfDocsConfig) -> s if output_format is not None: return normalize_format(output_format) - configured = normalize_format(config.default_format) - if configured in SUPPORTED_FORMATS: - return configured + try: + get_renderer(config.default_format) + except ValueError: + fallback = NfDocsConfig().default_format + console.print( + f"[yellow]Ignoring unknown default_format {config.default_format!r} " + f"in {get_config_path()}, using {fallback}[/yellow]" + ) + return fallback - fallback = normalize_format(DEFAULT_CONFIG["default_format"]) - console.print( - f"[yellow]Ignoring unknown default_format {config.default_format!r} " - f"in {get_config_path()}, using {fallback}[/yellow]" - ) - return fallback + return normalize_format(config.default_format) @main.command() @@ -220,7 +216,7 @@ def _resolve_output_format(output_format: str | None, config: NfDocsConfig) -> s "--format", "-f", "output_format", - type=click.Choice(["json", "yaml", "markdown", "md", "html", "table"], case_sensitive=False), + type=click.Choice(sorted({*RENDERERS, *FORMAT_ALIASES}), case_sensitive=False), default=None, help=( "Output format: json, yaml, markdown (or md), html, table " @@ -695,18 +691,9 @@ def config(init_config: bool, show_example: bool, path: bool) -> None: console.print() console.print("[bold]Current settings:[/bold]") - for key, value in load_config().to_dict().items(): - if isinstance(value, list): - if value: - console.print(f" {key}:") - for item in value: - console.print(f" - {item}") - else: - console.print(f" {key}: []") - elif isinstance(value, bool): - console.print(f" {key}: {str(value).lower()}") - else: - console.print(f" {key}: {value}") + # Dump as YAML so what's printed is exactly what you'd write in the file. + # click.echo rather than console.print, so rich doesn't read "[]" as markup. + click.echo(yaml.safe_dump(load_config().to_dict(), sort_keys=False)) if __name__ == "__main__": diff --git a/src/nf_docs/extractor.py b/src/nf_docs/extractor.py index a90a3eb..6f032b5 100644 --- a/src/nf_docs/extractor.py +++ b/src/nf_docs/extractor.py @@ -246,10 +246,6 @@ def extract(self) -> Pipeline: ) return pipeline - # Names of every schema parameter, including any dropped by config - # filtering below. Config params matching one of these are suppressed - # from the Configuration section, so a parameter the user asked to hide - # doesn't simply reappear under a different heading. schema_param_names: set[str] = set() # Extract from schema (has highest priority for inputs and metadata) @@ -282,7 +278,10 @@ def extract(self) -> Pipeline: config_metadata, config_params = parse_config(self.workspace_path, self.nextflow_path) # Merge metadata (schema takes priority) pipeline.metadata = self._merge_metadata(pipeline.metadata, config_metadata) - # Filter config params to exclude those already in inputs and ignored prefixes + # Drop config params matching an ignore prefix, and any that name a + # schema parameter — including one ``_filter_inputs`` dropped, so a + # parameter the user asked to hide doesn't reappear under a + # different heading. pipeline.config_params = [ p for p in config_params @@ -361,8 +360,9 @@ def _filter_inputs(self, inputs: list[PipelineInput]) -> list[PipelineInput]: if (self.config.include_hidden_params or not inp.hidden) and not self.config.should_ignore_input_param(inp.name) ] - if len(kept) < len(inputs): - logger.debug(f"Filtered out {len(inputs) - len(kept)} of {len(inputs)} schema inputs") + dropped = len(inputs) - len(kept) + if dropped: + logger.debug(f"Filtered out {dropped} of {len(inputs)} schema inputs") return kept def _merge_metadata( diff --git a/src/nf_docs/lsp_client.py b/src/nf_docs/lsp_client.py index 78a1bf6..29ce856 100644 --- a/src/nf_docs/lsp_client.py +++ b/src/nf_docs/lsp_client.py @@ -66,11 +66,8 @@ def build_exclude_list(exclude_patterns: list[str] | None = None) -> list[str]: Returns: The exclusions to send to the language server, de-duplicated and in order """ - excludes: list[str] = [] - for entry in [*DEFAULT_LSP_EXCLUDES, *(exclude_patterns or [])]: - if entry and entry not in excludes: - excludes.append(entry) - return excludes + merged = dict.fromkeys([*DEFAULT_LSP_EXCLUDES, *(exclude_patterns or [])]) + return [entry for entry in merged if entry] class LSPError(Exception): @@ -111,7 +108,7 @@ def __init__( self.workspace_path = Path(workspace_path).resolve() self.java_path = java_path self.auto_download = auto_download - self.exclude_patterns = build_exclude_list(exclude_patterns) + self.file_excludes = build_exclude_list(exclude_patterns) self._progress = progress_callback or null_progress self._process: subprocess.Popen | None = None self._request_id = 0 @@ -502,7 +499,7 @@ def _initialize(self) -> None: { "settings": { "nextflow": { - "files": {"exclude": self.exclude_patterns}, + "files": {"exclude": self.file_excludes}, "formatting": {"harshilAlignment": False}, "java": {"home": ""}, "suppressFutureWarnings": False, diff --git a/src/nf_docs/output.py b/src/nf_docs/output.py index e0e8cb4..e5946b6 100644 --- a/src/nf_docs/output.py +++ b/src/nf_docs/output.py @@ -35,9 +35,6 @@ # Formats that render as a directory of files rather than a single document. DIRECTORY_FORMATS: frozenset[str] = frozenset({"markdown", "html", "table"}) -# Every canonical format name, i.e. what ``normalize_format`` may return. -SUPPORTED_FORMATS: frozenset[str] = frozenset(SINGLE_FILE_OUTPUT_POLICY) - # Filename stem used for json/yaml output of a whole pipeline. DATA_FILE_STEM = "pipeline" diff --git a/tests/test_cli.py b/tests/test_cli.py index 6379d19..54c0426 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,6 +17,14 @@ def runner(): return CliRunner() +def write_user_config(body: str) -> Path: + """Write a config file at the (isolated) XDG config path and return its path.""" + config_file = get_config_path() + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(body, encoding="utf-8") + return config_file + + class TestMainCommand: def test_help(self, runner: CliRunner): result = runner.invoke(main, ["--help"]) @@ -276,15 +284,9 @@ class TestDefaultFormatConfig: directory, so these write a real config file and exercise ``load_config()``. """ - def _write_config(self, body: str) -> Path: - config_file = get_config_path() - config_file.parent.mkdir(parents=True, exist_ok=True) - config_file.write_text(body, encoding="utf-8") - return config_file - def test_default_format_from_config_is_used(self, runner: CliRunner, sample_pipeline: Path): """With no -f, the configured format wins over the built-in html.""" - self._write_config("default_format: json\n") + write_user_config("default_format: json\n") with patch.object(PipelineExtractor, "_extract_from_lsp"): result = runner.invoke(generate, [str(sample_pipeline)]) @@ -294,7 +296,7 @@ def test_default_format_from_config_is_used(self, runner: CliRunner, sample_pipe def test_explicit_format_flag_wins(self, runner: CliRunner, sample_pipeline: Path): """An explicit -f overrides the configured default.""" - self._write_config("default_format: json\n") + write_user_config("default_format: json\n") with patch.object(PipelineExtractor, "_extract_from_lsp"): result = runner.invoke(generate, [str(sample_pipeline), "--format", "yaml"]) @@ -318,7 +320,7 @@ def test_unknown_default_format_warns_and_falls_back( self, runner: CliRunner, sample_pipeline: Path, tmp_path: Path ): """A bad config value is a warning and html, not a traceback.""" - self._write_config("default_format: nonsense\n") + write_user_config("default_format: nonsense\n") output = tmp_path / "site" with patch.object(PipelineExtractor, "_extract_from_lsp"): @@ -354,9 +356,7 @@ def test_init_creates_file(self, runner: CliRunner): assert "ignore_config_prefixes:" in created.read_text() def test_init_does_not_overwrite(self, runner: CliRunner): - existing = get_config_path() - existing.parent.mkdir(parents=True) - existing.write_text("default_format: json\n") + existing = write_user_config("default_format: json\n") result = runner.invoke(config, ["--init"]) diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 5017fd2..e98e253 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -342,7 +342,8 @@ def test_strip_readme_badges_false_keeps_badges(self, tmp_path: Path): assert "badge" not in stripped.metadata.readme_content.lower() assert "badge" in kept.metadata.readme_content.lower() - # Either way the prose is preserved + # Either way the prose is preserved - stripping must not eat the body + assert "actual description" in stripped.metadata.readme_content.lower() assert "actual description" in kept.metadata.readme_content.lower() def test_max_readme_length_truncates_at_a_line_break(self, tmp_path: Path): @@ -358,7 +359,7 @@ def test_max_readme_length_truncates_at_a_line_break(self, tmp_path: Path): assert len(content) <= 100 assert full.startswith(content) # Cut on a line boundary, so no line is left half-written - assert content.splitlines()[-1] in full.splitlines() + assert full[len(content)] == "\n" def test_exclude_patterns_reach_the_language_server(self, tmp_path: Path): """Configured exclude patterns are passed through to the LSP client.""" From b6af8601f521cadd0870a72895ca8516d0135cea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:00:39 +0000 Subject: [PATCH 3/6] Validate config values at the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wiring up the six dead NfDocsConfig options meant a value of the wrong type in ~/.config/nf-docs/config.yaml now reaches real code. Reproduced against the previous commit: - `default_format: 3` — normalize_format() called .lower() on an int, an uncaught AttributeError and a traceback, from inside the guard that exists to keep a bad config from producing one. - `max_readme_length: "lots"` — TypeError comparing str to int, swallowed by the broad except around README parsing, so the README silently vanished from the output. - `exclude_patterns: "tests"` — a bare string where a list belongs, which the example file makes an easy slip. It iterated as characters and sent ['.git', '.nf-test', 'work', 't', 'e', 's'] to the language server. No error, just wrong indexing. NfDocsConfig.from_dict() now checks each value against the type its option expects and falls back to the default with a warning. Doing it there rather than at each use site covers the library path too — nf_docs.extract( config=load_config()) had no guard at all — and means the next option to be added is validated by construction. from_dict also copies its list defaults now; it previously handed out the DEFAULT_CONFIG lists themselves, so mutating a config mutated the defaults for every later caller. Separately, cache_key() no longer hashes default_format. That field only picks the CLI's output format, so including it evicted every cached pipeline — each one a full Language Server run — to change a presentation default. The exclusion is a named set with a test asserting every remaining option does affect the key, so it can't silently drift. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012iDGBm7Vt6rZ5FsZd1oZux --- CHANGELOG.md | 8 ++++ src/nf_docs/config.py | 95 ++++++++++++++++++++++++++++++++++--------- tests/test_config.py | 66 ++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ada617..c2dfc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ was introduced, then dropped by an unrelated revert in the same release - A parameter excluded by `include_hidden_params` or `ignore_input_prefixes` no longer reappears in the Configuration section +- A config option set to the wrong type falls back to its default with a warning, rather than + failing somewhere downstream. `default_format: 3` used to be an uncaught `AttributeError`, + `max_readme_length: "lots"` silently dropped the README, and `exclude_patterns: "tests"` sent the + language server one exclusion per character +- `NfDocsConfig.from_dict()` copies its list defaults, so mutating a returned config no longer + changes `DEFAULT_CONFIG` for every later caller +- `default_format` is excluded from the extraction cache key. It only picks the CLI's output format, + so changing it no longer evicts every cached pipeline - The extraction cache is now keyed on the configuration as well as the pipeline contents. Previously a CLI run (which loads `~/.config/nf-docs/config.yaml`) and a library call (which uses defaults) shared a cache entry for the same unchanged pipeline and returned each other's results diff --git a/src/nf_docs/config.py b/src/nf_docs/config.py index 580e895..171c98f 100644 --- a/src/nf_docs/config.py +++ b/src/nf_docs/config.py @@ -38,6 +38,45 @@ "exclude_patterns": [], } +# Expected shape of each option, for checking what comes out of the YAML file. +# ``list`` means "list of strings". Every option is consulted during extraction +# or by the CLI, so a value of the wrong type reaches real code and fails there +# rather than here - hence checking at the boundary instead of at each use. +_FIELD_TYPES: dict[str, type] = { + "ignore_config_prefixes": list, + "ignore_input_prefixes": list, + "include_hidden_params": bool, + "default_format": str, + "max_readme_length": int, + "strip_readme_badges": bool, + "exclude_patterns": list, +} + +# Options that don't affect extraction, and so stay out of the cache key. +_CLI_ONLY_FIELDS = frozenset({"default_format"}) + + +def _has_expected_shape(key: str, value: Any) -> bool: + """ + Check a config value against the type its option expects. + + Args: + key: Option name, which must be present in ``_FIELD_TYPES`` + value: Value as parsed from YAML + + Returns: + True if the value can be used as-is. + """ + expected = _FIELD_TYPES[key] + if expected is bool: + return isinstance(value, bool) + if expected is int: + # bool is a subclass of int, but `max_readme_length: true` is a mistake. + return isinstance(value, int) and not isinstance(value, bool) + if expected is str: + return isinstance(value, str) + return isinstance(value, list) and all(isinstance(item, str) for item in value) + def get_xdg_config_home() -> Path: """Get the XDG config directory.""" @@ -102,38 +141,54 @@ def should_ignore_input_param(self, param_name: str) -> bool: @classmethod def from_dict(cls, data: dict[str, Any]) -> "NfDocsConfig": - """Create a config from a dictionary, using defaults for missing values.""" - return cls( - ignore_config_prefixes=data.get( - "ignore_config_prefixes", DEFAULT_CONFIG["ignore_config_prefixes"] - ), - ignore_input_prefixes=data.get( - "ignore_input_prefixes", DEFAULT_CONFIG["ignore_input_prefixes"] - ), - include_hidden_params=data.get( - "include_hidden_params", DEFAULT_CONFIG["include_hidden_params"] - ), - default_format=data.get("default_format", DEFAULT_CONFIG["default_format"]), - max_readme_length=data.get("max_readme_length", DEFAULT_CONFIG["max_readme_length"]), - strip_readme_badges=data.get( - "strip_readme_badges", DEFAULT_CONFIG["strip_readme_badges"] - ), - exclude_patterns=data.get("exclude_patterns", DEFAULT_CONFIG["exclude_patterns"]), - ) + """ + Create a config from a dictionary, using defaults for missing values. + + A value of the wrong type falls back to the default with a warning, so a + typo in ``config.yaml`` can't surface as a traceback from the middle of + extraction. Unknown keys are ignored. + + Args: + data: Parsed contents of a config file + + Returns: + The configuration to use. + """ + values: dict[str, Any] = {} + for key, default in DEFAULT_CONFIG.items(): + if key in data and _has_expected_shape(key, data[key]): + values[key] = data[key] + continue + if key in data: + logger.warning( + f"Config option {key!r} should be " + f"{'a list of strings' if _FIELD_TYPES[key] is list else _FIELD_TYPES[key].__name__}" + f", got {type(data[key]).__name__}. Using the default: {default!r}" + ) + # Copy, so the caller can't mutate DEFAULT_CONFIG through the result. + values[key] = list(default) if isinstance(default, list) else default + return cls(**values) def cache_key(self) -> str: """ - Stable short hash of this configuration. + Stable short hash of the options that shape extraction. Extraction results depend on the config in use, so the cache key has to include it. Without this, a CLI run (which loads the user's config file) and a library call (which uses defaults) would share a cache entry on the same unchanged pipeline and return each other's results. + ``default_format`` is left out: it only picks the CLI's output format, + so including it would evict every cached extraction - each one a full + Language Server run - to change a presentation default. + Returns: A hex digest suitable for use in a cache filename. """ - payload = json.dumps(self.to_dict(), sort_keys=True) + extraction_options = { + key: value for key, value in self.to_dict().items() if key not in _CLI_ONLY_FIELDS + } + payload = json.dumps(extraction_options, sort_keys=True) return hashlib.sha256(payload.encode()).hexdigest()[:16] def to_dict(self) -> dict[str, Any]: diff --git a/tests/test_config.py b/tests/test_config.py index 38fde6a..bef5bab 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ """Tests for the configuration system.""" +import logging from pathlib import Path import pytest @@ -125,6 +126,71 @@ def test_from_dict_empty(self) -> None: assert config.include_hidden_params == DEFAULT_CONFIG["include_hidden_params"] assert config.default_format == DEFAULT_CONFIG["default_format"] + def test_from_dict_ignores_unknown_keys(self) -> None: + """A key nf-docs doesn't know about is skipped, not an error.""" + config = NfDocsConfig.from_dict({"made_up_option": 1, "default_format": "json"}) + + assert config == NfDocsConfig(default_format="json") + + def test_from_dict_copies_list_defaults(self) -> None: + """Defaults are copied, so a caller can't mutate DEFAULT_CONFIG through them.""" + config = NfDocsConfig.from_dict({}) + config.ignore_config_prefixes.append("mutated.") + + assert DEFAULT_CONFIG["ignore_config_prefixes"] == ["genomes."] + assert NfDocsConfig.from_dict({}).ignore_config_prefixes == ["genomes."] + + @pytest.mark.parametrize( + ("key", "bad_value"), + [ + # Every one of these used to reach real code and fail there: a bare + # string silently iterated as characters, a string where an int was + # expected raised TypeError mid-extraction. + ("exclude_patterns", "tests"), + ("ignore_config_prefixes", "genomes."), + ("ignore_input_prefixes", [1, 2]), + ("max_readme_length", "lots"), + ("max_readme_length", True), + ("default_format", 3), + ("include_hidden_params", "yes"), + ("strip_readme_badges", 1), + ], + ) + def test_from_dict_falls_back_on_wrong_type(self, key: str, bad_value: object) -> None: + """A value of the wrong type warns and uses the default.""" + config = NfDocsConfig.from_dict({key: bad_value}) + + assert getattr(config, key) == DEFAULT_CONFIG[key] + + def test_from_dict_warns_about_wrong_type(self, caplog) -> None: + """The fallback is announced rather than silent.""" + with caplog.at_level(logging.WARNING): + NfDocsConfig.from_dict({"max_readme_length": "lots"}) + + assert "max_readme_length" in caplog.text + + def test_cache_key_ignores_default_format(self) -> None: + """default_format is CLI-only, so changing it must not evict extractions.""" + assert ( + NfDocsConfig(default_format="html").cache_key() + == NfDocsConfig(default_format="json").cache_key() + ) + + def test_cache_key_covers_every_extraction_option(self) -> None: + """Any option that shapes extraction changes the key.""" + baseline = NfDocsConfig().cache_key() + variants = [ + NfDocsConfig(ignore_config_prefixes=[]), + NfDocsConfig(ignore_input_prefixes=["x."]), + NfDocsConfig(include_hidden_params=False), + NfDocsConfig(max_readme_length=500), + NfDocsConfig(strip_readme_badges=False), + NfDocsConfig(exclude_patterns=["tests"]), + ] + + for variant in variants: + assert variant.cache_key() != baseline, variant + def test_to_dict(self) -> None: """Test to_dict conversion.""" config = NfDocsConfig( From 8d052f2fa39a35def0bbd01a1efaf2d75023f321 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 09:58:15 +0000 Subject: [PATCH 4/6] Fix the rough edges around the config boundary Review findings, all reproduced before and after. The example config was a trap. Uncommenting the suggested `exclude_patterns` entries under `exclude_patterns: []` produced invalid YAML, and a broken file is not a partial failure - load_config() discards the whole thing and silently reverts every option to its default. Someone following the file's own suggestion would lose all their other settings with no error. It is now a fully commented block that uncomments cleanly, with a test that walks every comment block in the shipped example and fails if one uncomments to an orphan list. That test caught a second version of the same bug in my first fix. `default_format: "[/]"` crashed `nf-docs generate` with a MarkupError: the warning path interpolated the user's value into a rich markup string. It is escaped now. The whole point of that branch is to keep a bad config from producing a traceback. `nf-docs config --path` went through console.print, so rich hard-wrapped a long path across two lines when piped, breaking `$(nf-docs config --path)`. `nf-docs config` on an unparseable file printed "(file exists)" followed by the built-in defaults, with the parse warning escaping to stderr unformatted. It now sets up logging first, so the warning appears between the two, and the heading reads "Settings in effect" rather than "Current settings". A list containing one non-string reported "should be a list of strings, got list", which tells the user nothing. It now names the offending element type. max_readme_length is documented as capping README *source text*: the cut happens before local images are embedded, so the stored content can still be large. Previously the docs promised a character cap with no caveat. Also: docs/running.md still said --format always defaults to html and never mentioned the config file or the `nf-docs config` command; both fixed. Added an upgrade note to the changelog, since six options taking effect for the first time can change output for anyone who already has a config file. Finally, DEFAULT_CONFIG and _FIELD_TYPES are hand-maintained in parallel and from_dict indexes both, so a mismatch would be a KeyError at load time - there is now a test asserting they agree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012iDGBm7Vt6rZ5FsZd1oZux --- CHANGELOG.md | 14 +++++++++++ docs/python-api.md | 8 ++++++- docs/running.md | 32 ++++++++++++++++++++++--- src/nf_docs/cli.py | 22 ++++++++++++++---- src/nf_docs/config.py | 42 +++++++++++++++++++++++++-------- tests/test_config.py | 54 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2dfc71..a4fdcba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,20 @@ - `include_hidden_params` now defaults to `true`, matching the behaviour nf-docs has always had of showing hidden schema parameters. Set it to `false` to leave them out + +### Upgrading + +Six config options previously had no effect. If you already have a +`~/.config/nf-docs/config.yaml`, its settings now apply for the first time, and your output may +change: + +- `include_hidden_params: false` — parameters marked `hidden` in `nextflow_schema.json` disappear + from the documentation. This is the option doing what it says, but it is a visible change; delete + the line (or set `true`) to keep the previous output +- `ignore_input_prefixes`, `max_readme_length`, `exclude_patterns` — likewise now honoured +- `default_format` — `nf-docs generate` without `-f` uses it instead of always producing HTML + +Run `nf-docs config` to see the settings currently in effect. - Output path policy (format aliases, single-module detection, default filenames) moved from `cli.py` into a new `nf_docs.output` module so the CLI and the Python API share one implementation - Library callers now get `NfDocsConfig()` defaults rather than the user's diff --git a/docs/python-api.md b/docs/python-api.md index 119cdae..de75fbd 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -213,7 +213,7 @@ The fields: | `ignore_config_prefixes` | `["genomes."]` | Drops matching parameters from the Configuration section | | `ignore_input_prefixes` | `[]` | Drops matching parameters from the Parameters section | | `include_hidden_params` | `True` | Set `False` to leave out parameters marked `hidden` in `nextflow_schema.json` | -| `max_readme_length` | `0` | Trims README content to this many characters, on a line boundary. `0` = no limit | +| `max_readme_length` | `0` | Trims README source text to this many characters, on a line boundary. `0` = no limit | | `strip_readme_badges` | `True` | Set `False` to keep the badge lines below a README's title | | `exclude_patterns` | `[]` | Extra paths the Language Server skips when indexing the workspace | | `default_format` | `"html"` | CLI only — the format `nf-docs generate` uses without `-f/--format` | @@ -224,6 +224,12 @@ Configuration; it's left out of the documentation entirely. `exclude_patterns` is added to the exclusions nf-docs always sends (`.git`, `.nf-test`, `work`) rather than replacing them. +`max_readme_length` caps the README source text. Local images are converted to base64 data URIs +*after* the cut, so `readme_content` can still be large if the kept portion contains images. + +A value of the wrong type falls back to that option's default with a warning, so a typo in +`config.yaml` won't surface as an error part-way through extraction. + ## Caching nf-docs caches extraction results in `~/.cache/nf-docs/`, keyed by pipeline path, nf-docs version, a diff --git a/docs/running.md b/docs/running.md index 6146cef..be76a90 100644 --- a/docs/running.md +++ b/docs/running.md @@ -11,7 +11,7 @@ standalone module or subworkflow — see [Document a single module](#document-a- | Option | Description | | ------------------- | -------------------------------------------------------------------- | -| `--format`, `-f` | Output format: `html` (default), `markdown`, `table`, `json`, `yaml` | +| `--format`, `-f` | Output format: `markdown`, `table`, `json`, `yaml`, `html` (default) | | `--output`, `-o` | Output file or directory (default: `docs/`) | | `--title`, `-t` | Custom title | | `--no-cache` | Force fresh extraction | @@ -111,15 +111,41 @@ nf-docs clear-cache /path/to/pipeline # Clear the entire extraction cache nf-docs clear-cache --all + +# Show configuration, its file path, or create a starter config file +nf-docs config +nf-docs config --path +nf-docs config --init ``` +## Configuration file + +nf-docs reads an optional config file at `~/.config/nf-docs/config.yaml` (respects +`$XDG_CONFIG_HOME`). It works without one. `nf-docs config --init` writes a commented starter file, +and `nf-docs config` shows the settings currently in effect. + +| Option | Default | Effect | +| ------------------------ | -------------- | ----------------------------------------------------------------------------------- | +| `ignore_config_prefixes` | `["genomes."]` | Drop matching parameters from the Configuration section | +| `ignore_input_prefixes` | `[]` | Drop matching parameters from the Parameters section | +| `include_hidden_params` | `true` | Set `false` to leave out parameters marked `hidden` in `nextflow_schema.json` | +| `max_readme_length` | `0` | Trim README source text to this many characters, on a line boundary. `0` = no limit | +| `strip_readme_badges` | `true` | Set `false` to keep the badge lines below a README's title | +| `exclude_patterns` | `[]` | Extra paths the Language Server skips when indexing, added to the built-in ones | +| `default_format` | `html` | Format `nf-docs generate` uses when `-f/--format` isn't given | + +An option set to the wrong type falls back to its default with a warning. These settings apply to +the CLI; the [Python API](python-api.md#configuration) uses defaults unless you pass a config +explicitly. + ## Caching nf-docs caches extraction results to avoid re-running the Language Server on every invocation. Cached data is stored in `~/.cache/nf-docs/` (respects `$XDG_CACHE_HOME`). -The cache is keyed by pipeline path, a SHA-256 hash of all pipeline source files, and the nf-docs -version, so it is automatically invalidated whenever files change or nf-docs is upgraded. +The cache is keyed by pipeline path, a SHA-256 hash of all pipeline source files, the nf-docs +version, and the configuration options that affect extraction — so it is automatically invalidated +whenever files change, nf-docs is upgraded, or you edit a setting that changes the output. Use `--no-cache` to skip the cache for a single run, or `nf-docs clear-cache` to remove entries manually. diff --git a/src/nf_docs/cli.py b/src/nf_docs/cli.py index 1d6ef0c..dfd8aad 100644 --- a/src/nf_docs/cli.py +++ b/src/nf_docs/cli.py @@ -13,6 +13,7 @@ import yaml from rich.console import Console from rich.logging import RichHandler +from rich.markup import escape from rich.progress import ( BarColumn, MofNCompleteColumn, @@ -201,9 +202,11 @@ def _resolve_output_format(output_format: str | None, config: NfDocsConfig) -> s get_renderer(config.default_format) except ValueError: fallback = NfDocsConfig().default_format + # escape(): the value is straight from the user's config file, and rich + # would read something like "[/]" as markup and raise. console.print( - f"[yellow]Ignoring unknown default_format {config.default_format!r} " - f"in {get_config_path()}, using {fallback}[/yellow]" + f"[yellow]Ignoring unknown default_format {escape(repr(config.default_format))} " + f"in {escape(str(get_config_path()))}, using {fallback}[/yellow]" ) return fallback @@ -660,10 +663,15 @@ def config(init_config: bool, show_example: bool, path: bool) -> None: # Create config file with defaults nf-docs config --init """ + # Route load_config()'s warnings through the console, so a broken config + # file says so here rather than reaching stderr unformatted. + setup_logging(verbose=False) config_path = get_config_path() if path: - console.print(str(config_path)) + # click.echo, not console.print: rich hard-wraps at the terminal width, + # which would split a long path across lines in `$(nf-docs config --path)`. + click.echo(str(config_path)) return if show_example: @@ -688,12 +696,16 @@ def config(init_config: bool, show_example: bool, path: bool) -> None: else: console.print("[dim] (using defaults - file does not exist)[/dim]") + # Load before printing the header: a file that fails to parse warns here, + # so "settings in effect" isn't read as "contents of that file". + settings = load_config().to_dict() + console.print() - console.print("[bold]Current settings:[/bold]") + console.print("[bold]Settings in effect:[/bold]") # Dump as YAML so what's printed is exactly what you'd write in the file. # click.echo rather than console.print, so rich doesn't read "[]" as markup. - click.echo(yaml.safe_dump(load_config().to_dict(), sort_keys=False)) + click.echo(yaml.safe_dump(settings, sort_keys=False)) if __name__ == "__main__": diff --git a/src/nf_docs/config.py b/src/nf_docs/config.py index 171c98f..acb0bf5 100644 --- a/src/nf_docs/config.py +++ b/src/nf_docs/config.py @@ -78,6 +78,25 @@ def _has_expected_shape(key: str, value: Any) -> bool: return isinstance(value, list) and all(isinstance(item, str) for item in value) +def _describe_expected(key: str, value: Any) -> str: + """ + Describe what an option wanted and what it got, for a warning message. + + Args: + key: Option name, which must be present in ``_FIELD_TYPES`` + value: The offending value + + Returns: + A phrase completing "Config option 'x' should be ...". + """ + if _FIELD_TYPES[key] is not list: + return f"{_FIELD_TYPES[key].__name__}, got {type(value).__name__}" + if not isinstance(value, list): + return f"a list of strings, got {type(value).__name__}" + offenders = sorted({type(item).__name__ for item in value if not isinstance(item, str)}) + return f"a list of strings, but it contains {' and '.join(offenders)}" + + def get_xdg_config_home() -> Path: """Get the XDG config directory.""" xdg_config = os.environ.get("XDG_CONFIG_HOME") @@ -108,8 +127,9 @@ class NfDocsConfig: default_format: Default output format for the ``nf-docs generate`` command when ``-f/--format`` is not given. Only the CLI reads this; the Python API takes its format as an argument. Default: "html". - max_readme_length: Maximum README content length to include (0 = no limit). - Default: 0. + max_readme_length: Maximum length of README *source text* to include, in + characters (0 = no limit). Local images are embedded as base64 after + the cut, so the stored content can be larger than this. Default: 0. strip_readme_badges: Whether to strip badge lines (images/links at the top) from README files. Default: True. exclude_patterns: Extra paths for the Language Server to skip when indexing @@ -161,9 +181,8 @@ def from_dict(cls, data: dict[str, Any]) -> "NfDocsConfig": continue if key in data: logger.warning( - f"Config option {key!r} should be " - f"{'a list of strings' if _FIELD_TYPES[key] is list else _FIELD_TYPES[key].__name__}" - f", got {type(data[key]).__name__}. Using the default: {default!r}" + f"Config option {key!r} should be {_describe_expected(key, data[key])}. " + f"Using the default: {default!r}" ) # Copy, so the caller can't mutate DEFAULT_CONFIG through the result. values[key] = list(default) if isinstance(default, list) else default @@ -284,7 +303,9 @@ def get_example_config() -> str: # Default: html default_format: html -# Maximum length of README content to include (in characters). +# Maximum length of README source text to include, in characters. +# The cut happens before local images are embedded as base64, so this limits +# how much of the README is kept, not the size of the stored result. # Set to 0 for no limit. # Default: 0 max_readme_length: 0 @@ -300,8 +321,9 @@ def get_example_config() -> str: # nf-docs always excludes (.git, .nf-test, work) rather than replacing them. # Entries are passed through to the language server's `nextflow.files.exclude` # setting, so directory names are the safest thing to put here. -# Default: [] -exclude_patterns: [] -# - "tests" -# - "examples" +# Uncomment and edit to set some. Default: none. +# +# exclude_patterns: +# - "tests" +# - "examples" """ diff --git a/tests/test_config.py b/tests/test_config.py index bef5bab..ba71d63 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,10 +4,13 @@ from pathlib import Path import pytest +import yaml from nf_docs.config import ( + _FIELD_TYPES, DEFAULT_CONFIG, NfDocsConfig, + _has_expected_shape, get_config_path, get_example_config, get_xdg_config_home, @@ -126,6 +129,57 @@ def test_from_dict_empty(self) -> None: assert config.include_hidden_params == DEFAULT_CONFIG["include_hidden_params"] assert config.default_format == DEFAULT_CONFIG["default_format"] + def test_every_option_has_a_declared_type(self) -> None: + """ + DEFAULT_CONFIG and _FIELD_TYPES must describe the same set of options. + + from_dict() looks each option up in both, so an option added to one only + turns config loading into a KeyError. + """ + assert set(DEFAULT_CONFIG) == set(_FIELD_TYPES) + + def test_defaults_satisfy_their_own_declared_types(self) -> None: + """Each default is a valid value for its option.""" + for key, default in DEFAULT_CONFIG.items(): + assert _has_expected_shape(key, default), key + + def test_example_config_is_valid_and_matches_defaults(self) -> None: + """ + The shipped example must parse, and describe the defaults it claims. + + `nf-docs config --init` writes this file verbatim, so a stale value here + silently changes behaviour for anyone who runs it. + """ + parsed = yaml.safe_load(get_example_config()) + + assert NfDocsConfig.from_dict(parsed) == NfDocsConfig() + assert set(parsed) <= set(DEFAULT_CONFIG) + + def test_example_config_suggestions_uncomment_to_valid_yaml(self) -> None: + """ + Uncommenting a suggested value must leave a usable config file. + + A suggestion that uncomments to a bare list is an orphan under whatever + precedes it, which makes the whole file unparseable - and a broken file + isn't a partial failure. load_config() discards all of it and silently + reverts every option to its default. + """ + blocks: list[list[str]] = [] + for line in get_example_config().splitlines(): + if line.startswith("#"): + if not blocks or blocks[-1] is None: + blocks.append([]) + blocks[-1].append(line[2:] if line.startswith("# ") else line[1:]) + elif blocks and blocks[-1] is not None: + blocks.append(None) # type: ignore[arg-type] + + for block in filter(None, blocks): + try: + uncommented = yaml.safe_load("\n".join(block)) + except yaml.YAMLError: + continue # Prose, not a suggested setting + assert not isinstance(uncommented, list), "\n".join(block) + def test_from_dict_ignores_unknown_keys(self) -> None: """A key nf-docs doesn't know about is skipped, not an error.""" config = NfDocsConfig.from_dict({"made_up_option": 1, "default_format": "json"}) From fcfd707803987a20e7aa48b578dd493ec1a9ebab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 10:06:42 +0000 Subject: [PATCH 5/6] Simplify the example-config uncommenting test The comment-block grouping used a None sentinel inside a list[list[str]] and a type: ignore to get away with it. itertools.groupby over "does this line start with #" says the same thing without either. Confirmed it still fails against the original broken example config. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012iDGBm7Vt6rZ5FsZd1oZux --- tests/test_config.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index ba71d63..d111033 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ """Tests for the configuration system.""" import logging +from itertools import groupby from pathlib import Path import pytest @@ -164,21 +165,16 @@ def test_example_config_suggestions_uncomment_to_valid_yaml(self) -> None: isn't a partial failure. load_config() discards all of it and silently reverts every option to its default. """ - blocks: list[list[str]] = [] - for line in get_example_config().splitlines(): - if line.startswith("#"): - if not blocks or blocks[-1] is None: - blocks.append([]) - blocks[-1].append(line[2:] if line.startswith("# ") else line[1:]) - elif blocks and blocks[-1] is not None: - blocks.append(None) # type: ignore[arg-type] - - for block in filter(None, blocks): + lines = get_example_config().splitlines() + for is_comment, group in groupby(lines, lambda line: line.startswith("#")): + if not is_comment: + continue + block = "\n".join(line.removeprefix("#").removeprefix(" ") for line in group) try: - uncommented = yaml.safe_load("\n".join(block)) + uncommented = yaml.safe_load(block) except yaml.YAMLError: continue # Prose, not a suggested setting - assert not isinstance(uncommented, list), "\n".join(block) + assert not isinstance(uncommented, list), block def test_from_dict_ignores_unknown_keys(self) -> None: """A key nf-docs doesn't know about is skipped, not an error.""" From 69efeb6ea2f6cc379a0e3456dc9f7a0caeae8741 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 10:15:20 +0000 Subject: [PATCH 6/6] Validate default_format at the config boundary, like the other six Review of the whole PR. The substantive finding: `nf-docs config` printed `default_format: nonsense` under "Settings in effect" while `generate` warned and used html. The command this PR added to tell users what is in effect was reporting something that wasn't. The cause was validating that one option in the CLI while the other six are validated in from_dict. The stated reason - config.py can't import the renderers - didn't hold: output.py is a leaf module that imports nothing from the package and already owns the format tables. It now exposes is_supported_format()/supported_formats(), config.py checks default_format's domain alongside every other option, and _resolve_output_format collapses to one expression at the call site. One diagnostic channel now serves all seven options, and the rich escape() dance goes away with it. That leaves output.py and renderers/__init__.py as two tables that must agree, so tests/test_renderers.py now pins them together. Other findings, all three reviewers converging on the first: - _FIELD_TYPES hand-listed seven options whose types are exactly type(DEFAULT_CONFIG[key]). It's derived now, which deletes the table and the test that existed only to police it against drift. - _describe_expected built three sentence shapes across seventeen lines and never showed the offending value, which is the thing a user needs to spot the typo. Inlined; the warning now prints it. - _has_expected_shape had four branches where two were plain isinstance, with "list of strings" as an implicit fallthrough. Stated explicitly. - from_dict tested `key in data` twice to distinguish absent from invalid. - click.Choice re-derived the format set that renderers/__init__.py already computes for its error message; both call supported_formats() now, and AGENTS.md no longer tells contributors to update the CLI's choices by hand. - Tests: the warning assertion was a copy of one parametrize case, so it's folded in and now covers all eight; example-config tests moved next to their siblings in TestGetExampleConfig; dropped an assertion subsumed by the one below it. Added a test that `config` and `generate` agree. - docs/python-api.md carried a second copy of the option table; it links to the one in running.md and keeps only the two API-specific facts. Skipped, and worth their own change: caching the unfiltered Pipeline and applying config as a view (five of the six cached options are pure post-processing, so toggling one currently forces a full LSP re-run), moving README parsing into readme_parser.py, and generating get_example_config() from field metadata. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012iDGBm7Vt6rZ5FsZd1oZux --- AGENTS.md | 7 ++- docs/python-api.md | 31 +++------- src/nf_docs/cli.py | 49 +++------------- src/nf_docs/config.py | 88 ++++++++++++---------------- src/nf_docs/output.py | 28 +++++++++ src/nf_docs/renderers/__init__.py | 4 +- tests/test_cli.py | 22 ++++++- tests/test_config.py | 95 ++++++++++++------------------- tests/test_renderers.py | 22 +++++++ 9 files changed, 164 insertions(+), 182 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f1bcea..ad39fdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,9 +219,10 @@ src/nf_docs/ `render_to_directory()`. Factory function `get_renderer(format)` returns the appropriate renderer. HTML uses a single Jinja2 template with inline CSS (Tailwind) and JavaScript for search. Adding a new output format means updating `RENDERERS` in `renderers/__init__.py`, - `SINGLE_FILE_OUTPUT_POLICY` and (if it writes a directory) `DIRECTORY_FORMATS` in `output.py`, the - CLI's `--format` choices, and tests. Format aliases live only in `output.FORMAT_ALIASES`; - `get_renderer()` resolves through `normalize_format()` so there is one alias table. + `SINGLE_FILE_OUTPUT_POLICY` and (if it writes a directory) `DIRECTORY_FORMATS` in `output.py`, and + tests. The CLI's `--format` choices come from `output.supported_formats()`, so they follow + automatically. Format aliases live only in `output.FORMAT_ALIASES`; `get_renderer()` resolves + through `normalize_format()` so there is one alias table. - **Config parsing**: `config_parser.py` shells out to `nextflow config -flat`. `nextflow_env.py` sets an isolated `NXF_HOME` so this doesn't pollute the user's Nextflow state. - **Two entry points**: `cli.py` (rich-click) and `api.py` (library). All source/output path and diff --git a/docs/python-api.md b/docs/python-api.md index de75fbd..ebf025a 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -206,29 +206,14 @@ config = nf_docs.NfDocsConfig(ignore_config_prefixes=["genomes.", "test."]) nf_docs.extract("./my_pipeline", config=config) ``` -The fields: - -| Field | Default | Effect | -| ------------------------- | -------------- | ------------------------------------------------------------------------------- | -| `ignore_config_prefixes` | `["genomes."]` | Drops matching parameters from the Configuration section | -| `ignore_input_prefixes` | `[]` | Drops matching parameters from the Parameters section | -| `include_hidden_params` | `True` | Set `False` to leave out parameters marked `hidden` in `nextflow_schema.json` | -| `max_readme_length` | `0` | Trims README source text to this many characters, on a line boundary. `0` = no limit | -| `strip_readme_badges` | `True` | Set `False` to keep the badge lines below a README's title | -| `exclude_patterns` | `[]` | Extra paths the Language Server skips when indexing the workspace | -| `default_format` | `"html"` | CLI only — the format `nf-docs generate` uses without `-f/--format` | - -A parameter dropped by `include_hidden_params` or `ignore_input_prefixes` doesn't reappear under -Configuration; it's left out of the documentation entirely. - -`exclude_patterns` is added to the exclusions nf-docs always sends (`.git`, `.nf-test`, `work`) -rather than replacing them. - -`max_readme_length` caps the README source text. Local images are converted to base64 data URIs -*after* the cut, so `readme_content` can still be large if the kept portion contains images. - -A value of the wrong type falls back to that option's default with a warning, so a typo in -`config.yaml` won't surface as an error part-way through extraction. +The fields, their defaults and what each one does are listed under +[Configuration file](running.md#configuration-file). Two things specific to the API: + +- `default_format` is read only by `nf-docs generate`. Setting it on a config you pass to + `extract()` or `generate()` does nothing — the API takes its format as an argument. +- Passing a config that isn't an `NfDocsConfig` (say, a plain dict) isn't supported. Build one with + `NfDocsConfig(...)`, or `NfDocsConfig.from_dict(...)` if you have a mapping and want the same + wrong-type checking `load_config()` applies. ## Caching diff --git a/src/nf_docs/cli.py b/src/nf_docs/cli.py index dfd8aad..14f3947 100644 --- a/src/nf_docs/cli.py +++ b/src/nf_docs/cli.py @@ -13,7 +13,6 @@ import yaml from rich.console import Console from rich.logging import RichHandler -from rich.markup import escape from rich.progress import ( BarColumn, MofNCompleteColumn, @@ -25,21 +24,21 @@ from nf_docs import __version__ from nf_docs.api import extract as extract_pipeline -from nf_docs.config import NfDocsConfig, get_config_path, get_example_config, load_config +from nf_docs.config import get_config_path, get_example_config, load_config from nf_docs.extractor import ExtractionError, PipelineExtractor from nf_docs.lsp_client import LSPError from nf_docs.output import ( DIRECTORY_FORMATS, - FORMAT_ALIASES, default_output_dir, normalize_format, resolve_data_file_path, resolve_single_file_path, resolve_source, streams_by_default, + supported_formats, ) from nf_docs.progress import ProgressUpdate -from nf_docs.renderers import RENDERERS, get_renderer +from nf_docs.renderers import get_renderer console = Console() @@ -180,49 +179,16 @@ def _display_path(path: Path) -> str: return str(path) -def _resolve_output_format(output_format: str | None, config: NfDocsConfig) -> str: - """ - Work out which output format to use. - - An explicit ``-f/--format`` always wins. With no flag we fall back to the - config file's ``default_format``, warning and using the built-in default if - that value isn't a format we can render. - - Args: - output_format: The value of ``-f/--format``, or None if it wasn't given - config: The user's loaded configuration - - Returns: - A canonical format name - """ - if output_format is not None: - return normalize_format(output_format) - - try: - get_renderer(config.default_format) - except ValueError: - fallback = NfDocsConfig().default_format - # escape(): the value is straight from the user's config file, and rich - # would read something like "[/]" as markup and raise. - console.print( - f"[yellow]Ignoring unknown default_format {escape(repr(config.default_format))} " - f"in {escape(str(get_config_path()))}, using {fallback}[/yellow]" - ) - return fallback - - return normalize_format(config.default_format) - - @main.command() @click.argument("pipeline_path", type=click.Path(exists=True, path_type=Path)) @click.option( "--format", "-f", "output_format", - type=click.Choice(sorted({*RENDERERS, *FORMAT_ALIASES}), case_sensitive=False), + type=click.Choice(supported_formats(), case_sensitive=False), default=None, help=( - "Output format: json, yaml, markdown (or md), html, table " + f"Output format: {', '.join(supported_formats())} " "(default: the config file's default_format, otherwise html)" ), ) @@ -296,8 +262,9 @@ def generate( # deliberately does not (see nf_docs.api.extract). user_config = load_config() - # An explicit --format wins; otherwise fall back to the configured default. - output_format = _resolve_output_format(output_format, user_config) + # An explicit --format wins; otherwise the config file's default_format, + # which load_config() has already checked is a format we can render. + output_format = normalize_format(output_format or user_config.default_format) # Decide single-file vs directory mode up front. A directory holding only a # module-style main.nf is auto-detected as a single module. diff --git a/src/nf_docs/config.py b/src/nf_docs/config.py index acb0bf5..2b5ca91 100644 --- a/src/nf_docs/config.py +++ b/src/nf_docs/config.py @@ -12,12 +12,15 @@ import json import logging import os +from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml +from nf_docs.output import is_supported_format, supported_formats + logger = logging.getLogger(__name__) # Default configuration values @@ -38,63 +41,46 @@ "exclude_patterns": [], } -# Expected shape of each option, for checking what comes out of the YAML file. -# ``list`` means "list of strings". Every option is consulted during extraction -# or by the CLI, so a value of the wrong type reaches real code and fails there -# rather than here - hence checking at the boundary instead of at each use. -_FIELD_TYPES: dict[str, type] = { - "ignore_config_prefixes": list, - "ignore_input_prefixes": list, - "include_hidden_params": bool, - "default_format": str, - "max_readme_length": int, - "strip_readme_badges": bool, - "exclude_patterns": list, -} +# Expected shape of each option, derived from the default it ships with so the +# two can't drift. ``list`` means "list of strings". An option defaulting to +# None would need stating explicitly here; none currently does. +_FIELD_TYPES: dict[str, type] = {key: type(value) for key, value in DEFAULT_CONFIG.items()} # Options that don't affect extraction, and so stay out of the cache key. _CLI_ONLY_FIELDS = frozenset({"default_format"}) +# Options whose value has to be one of a known set, beyond just having the right +# type, paired with what to tell the user when it isn't. Checked in from_dict so +# every option is validated in one place. +_FIELD_VALUE_CHECKS: dict[str, tuple[Callable[[Any], bool], str]] = { + "default_format": (is_supported_format, f"one of {', '.join(supported_formats())}"), +} -def _has_expected_shape(key: str, value: Any) -> bool: - """ - Check a config value against the type its option expects. - - Args: - key: Option name, which must be present in ``_FIELD_TYPES`` - value: Value as parsed from YAML - Returns: - True if the value can be used as-is. - """ - expected = _FIELD_TYPES[key] - if expected is bool: - return isinstance(value, bool) - if expected is int: - # bool is a subclass of int, but `max_readme_length: true` is a mistake. - return isinstance(value, int) and not isinstance(value, bool) - if expected is str: - return isinstance(value, str) - return isinstance(value, list) and all(isinstance(item, str) for item in value) +def _requirement(key: str) -> str: + """Describe what an option accepts, completing "should be ..." in a warning.""" + if key in _FIELD_VALUE_CHECKS: + return _FIELD_VALUE_CHECKS[key][1] + return "a list of strings" if _FIELD_TYPES[key] is list else _FIELD_TYPES[key].__name__ -def _describe_expected(key: str, value: Any) -> str: +def _is_valid(key: str, value: Any) -> bool: """ - Describe what an option wanted and what it got, for a warning message. - - Args: - key: Option name, which must be present in ``_FIELD_TYPES`` - value: The offending value + Check a config value against the type, and domain, its option expects. - Returns: - A phrase completing "Config option 'x' should be ...". + Every option is consulted during extraction or by the CLI, so a bad value + would otherwise reach real code and fail there instead of at the boundary. """ - if _FIELD_TYPES[key] is not list: - return f"{_FIELD_TYPES[key].__name__}, got {type(value).__name__}" - if not isinstance(value, list): - return f"a list of strings, got {type(value).__name__}" - offenders = sorted({type(item).__name__ for item in value if not isinstance(item, str)}) - return f"a list of strings, but it contains {' and '.join(offenders)}" + expected = _FIELD_TYPES[key] + if expected is list: + if not (isinstance(value, list) and all(isinstance(item, str) for item in value)): + return False + elif expected is int and isinstance(value, bool): + return False # bool subclasses int, but `max_readme_length: true` is a mistake + elif not isinstance(value, expected): + return False + check = _FIELD_VALUE_CHECKS.get(key) + return check[0](value) if check else True def get_xdg_config_home() -> Path: @@ -176,13 +162,13 @@ def from_dict(cls, data: dict[str, Any]) -> "NfDocsConfig": """ values: dict[str, Any] = {} for key, default in DEFAULT_CONFIG.items(): - if key in data and _has_expected_shape(key, data[key]): - values[key] = data[key] - continue if key in data: + if _is_valid(key, data[key]): + values[key] = data[key] + continue logger.warning( - f"Config option {key!r} should be {_describe_expected(key, data[key])}. " - f"Using the default: {default!r}" + f"Config option {key!r} should be {_requirement(key)}, " + f"got {data[key]!r}. Using the default: {default!r}" ) # Copy, so the caller can't mutate DEFAULT_CONFIG through the result. values[key] = list(default) if isinstance(default, list) else default diff --git a/src/nf_docs/output.py b/src/nf_docs/output.py index e5946b6..8a0e823 100644 --- a/src/nf_docs/output.py +++ b/src/nf_docs/output.py @@ -56,6 +56,34 @@ def normalize_format(output_format: str) -> str: return FORMAT_ALIASES.get(lowered, lowered) +def is_supported_format(output_format: str) -> bool: + """ + Check whether a format name (or alias) is one nf-docs can render. + + This module is a leaf - it imports nothing from the rest of the package - + so ``config.py`` can validate a config file's ``default_format`` without + pulling in the renderers. ``tests/test_renderers.py`` pins these names to + the renderer registry. + + Args: + output_format: Format name as the user wrote it + + Returns: + True if the format can be rendered. + """ + return normalize_format(output_format) in SINGLE_FILE_OUTPUT_POLICY + + +def supported_formats() -> list[str]: + """ + Every format name accepted on input, canonical names and aliases, sorted. + + Returns: + Names suitable for a ``click.Choice`` or a help string. + """ + return sorted({*SINGLE_FILE_OUTPUT_POLICY, *FORMAT_ALIASES}) + + def format_extension(output_format: str) -> str: """ Get the file extension for a canonical format name. diff --git a/src/nf_docs/renderers/__init__.py b/src/nf_docs/renderers/__init__.py index 95a7216..5c77158 100644 --- a/src/nf_docs/renderers/__init__.py +++ b/src/nf_docs/renderers/__init__.py @@ -9,7 +9,7 @@ Table: Compact Markdown tables (terraform-docs style) """ -from nf_docs.output import FORMAT_ALIASES, normalize_format +from nf_docs.output import normalize_format, supported_formats from nf_docs.renderers.base import BaseRenderer from nf_docs.renderers.html import HTMLRenderer from nf_docs.renderers.json import JSONRenderer @@ -55,7 +55,7 @@ def get_renderer(format: str) -> type[BaseRenderer]: """ canonical = normalize_format(format) if canonical not in RENDERERS: - supported = ", ".join(sorted({*RENDERERS, *FORMAT_ALIASES})) + supported = ", ".join(supported_formats()) raise ValueError(f"Unsupported format: {format}. Supported formats: {supported}") return RENDERERS[canonical] diff --git a/tests/test_cli.py b/tests/test_cli.py index 54c0426..0e7352c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ """Tests for the CLI interface.""" +import logging from pathlib import Path from unittest.mock import patch @@ -317,19 +318,34 @@ def test_no_config_file_still_defaults_to_html( assert (output / "index.html").exists() def test_unknown_default_format_warns_and_falls_back( - self, runner: CliRunner, sample_pipeline: Path, tmp_path: Path + self, runner: CliRunner, sample_pipeline: Path, tmp_path: Path, caplog ): """A bad config value is a warning and html, not a traceback.""" write_user_config("default_format: nonsense\n") output = tmp_path / "site" - with patch.object(PipelineExtractor, "_extract_from_lsp"): + with caplog.at_level(logging.WARNING), patch.object(PipelineExtractor, "_extract_from_lsp"): result = runner.invoke(generate, [str(sample_pipeline), "-o", str(output)]) assert result.exit_code == 0 - assert "nonsense" in result.output + assert "nonsense" in caplog.text assert (output / "index.html").exists() + def test_config_command_reports_the_format_actually_used(self, runner: CliRunner): + """ + `nf-docs config` must not report a value `generate` would reject. + + Validating default_format at load time rather than in the CLI is what + keeps these two commands agreeing. + """ + write_user_config("default_format: nonsense\n") + + result = runner.invoke(config, []) + + assert result.exit_code == 0 + assert "default_format: html" in result.output + assert "nonsense" not in result.output + class TestConfigCommand: def test_shows_current_settings(self, runner: CliRunner): diff --git a/tests/test_config.py b/tests/test_config.py index d111033..4219350 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,10 +8,9 @@ import yaml from nf_docs.config import ( - _FIELD_TYPES, DEFAULT_CONFIG, NfDocsConfig, - _has_expected_shape, + _is_valid, get_config_path, get_example_config, get_xdg_config_home, @@ -130,51 +129,10 @@ def test_from_dict_empty(self) -> None: assert config.include_hidden_params == DEFAULT_CONFIG["include_hidden_params"] assert config.default_format == DEFAULT_CONFIG["default_format"] - def test_every_option_has_a_declared_type(self) -> None: - """ - DEFAULT_CONFIG and _FIELD_TYPES must describe the same set of options. - - from_dict() looks each option up in both, so an option added to one only - turns config loading into a KeyError. - """ - assert set(DEFAULT_CONFIG) == set(_FIELD_TYPES) - - def test_defaults_satisfy_their_own_declared_types(self) -> None: - """Each default is a valid value for its option.""" + def test_defaults_are_themselves_valid(self) -> None: + """Each default passes the check applied to a user's value.""" for key, default in DEFAULT_CONFIG.items(): - assert _has_expected_shape(key, default), key - - def test_example_config_is_valid_and_matches_defaults(self) -> None: - """ - The shipped example must parse, and describe the defaults it claims. - - `nf-docs config --init` writes this file verbatim, so a stale value here - silently changes behaviour for anyone who runs it. - """ - parsed = yaml.safe_load(get_example_config()) - - assert NfDocsConfig.from_dict(parsed) == NfDocsConfig() - assert set(parsed) <= set(DEFAULT_CONFIG) - - def test_example_config_suggestions_uncomment_to_valid_yaml(self) -> None: - """ - Uncommenting a suggested value must leave a usable config file. - - A suggestion that uncomments to a bare list is an orphan under whatever - precedes it, which makes the whole file unparseable - and a broken file - isn't a partial failure. load_config() discards all of it and silently - reverts every option to its default. - """ - lines = get_example_config().splitlines() - for is_comment, group in groupby(lines, lambda line: line.startswith("#")): - if not is_comment: - continue - block = "\n".join(line.removeprefix("#").removeprefix(" ") for line in group) - try: - uncommented = yaml.safe_load(block) - except yaml.YAMLError: - continue # Prose, not a suggested setting - assert not isinstance(uncommented, list), block + assert _is_valid(key, default), key def test_from_dict_ignores_unknown_keys(self) -> None: """A key nf-docs doesn't know about is skipped, not an error.""" @@ -187,7 +145,6 @@ def test_from_dict_copies_list_defaults(self) -> None: config = NfDocsConfig.from_dict({}) config.ignore_config_prefixes.append("mutated.") - assert DEFAULT_CONFIG["ignore_config_prefixes"] == ["genomes."] assert NfDocsConfig.from_dict({}).ignore_config_prefixes == ["genomes."] @pytest.mark.parametrize( @@ -206,18 +163,13 @@ def test_from_dict_copies_list_defaults(self) -> None: ("strip_readme_badges", 1), ], ) - def test_from_dict_falls_back_on_wrong_type(self, key: str, bad_value: object) -> None: - """A value of the wrong type warns and uses the default.""" - config = NfDocsConfig.from_dict({key: bad_value}) - - assert getattr(config, key) == DEFAULT_CONFIG[key] - - def test_from_dict_warns_about_wrong_type(self, caplog) -> None: - """The fallback is announced rather than silent.""" + def test_from_dict_falls_back_on_bad_value(self, key: str, bad_value: object, caplog) -> None: + """A value nf-docs can't use warns and falls back to the default.""" with caplog.at_level(logging.WARNING): - NfDocsConfig.from_dict({"max_readme_length": "lots"}) + config = NfDocsConfig.from_dict({key: bad_value}) - assert "max_readme_length" in caplog.text + assert getattr(config, key) == DEFAULT_CONFIG[key] + assert key in caplog.text def test_cache_key_ignores_default_format(self) -> None: """default_format is CLI-only, so changing it must not evict extractions.""" @@ -332,8 +284,6 @@ class TestGetExampleConfig: def test_returns_valid_yaml(self) -> None: """Test that example config is valid YAML.""" - import yaml - example = get_example_config() data = yaml.safe_load(example) @@ -342,6 +292,33 @@ def test_returns_valid_yaml(self) -> None: assert "include_hidden_params" in data assert "default_format" in data + def test_describes_the_actual_defaults(self) -> None: + """ + `nf-docs config --init` writes this file verbatim, so a value that has + drifted from the code silently changes behaviour for anyone who runs it. + """ + assert NfDocsConfig.from_dict(yaml.safe_load(get_example_config())) == NfDocsConfig() + + def test_example_config_suggestions_uncomment_to_valid_yaml(self) -> None: + """ + Uncommenting a suggested value must leave a usable config file. + + A suggestion that uncomments to a bare list is an orphan under whatever + precedes it, which makes the whole file unparseable - and a broken file + isn't a partial failure. load_config() discards all of it and silently + reverts every option to its default. + """ + lines = get_example_config().splitlines() + for is_comment, group in groupby(lines, lambda line: line.startswith("#")): + if not is_comment: + continue + block = "\n".join(line.removeprefix("#").removeprefix(" ") for line in group) + try: + uncommented = yaml.safe_load(block) + except yaml.YAMLError: + continue # Prose, not a suggested setting + assert not isinstance(uncommented, list), block + def test_contains_comments(self) -> None: """Test that example config contains helpful comments.""" example = get_example_config() diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 57eb4a1..8ae6dff 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -15,7 +15,13 @@ ProcessOutput, Workflow, ) +from nf_docs.output import ( + FORMAT_ALIASES, + SINGLE_FILE_OUTPUT_POLICY, + supported_formats, +) from nf_docs.renderers import ( + RENDERERS, HTMLRenderer, JSONRenderer, MarkdownRenderer, @@ -753,3 +759,19 @@ def test_render_to_directory_empty_markers_renders_all( assert "## Inputs" in content assert "## Processes" in content assert "Footer." in content + + +class TestFormatRegistryConsistency: + def test_renderers_and_output_policy_cover_the_same_formats(self): + """ + Every renderable format needs an output policy, and vice versa. + + `output.py` is a leaf module, so `config.py` validates a config file's + default_format against SINGLE_FILE_OUTPUT_POLICY rather than importing + the renderers. That is only sound while the two agree. + """ + assert set(RENDERERS) == set(SINGLE_FILE_OUTPUT_POLICY) + + def test_supported_formats_covers_every_renderer_and_alias(self): + """The name list offered to users matches what get_renderer() accepts.""" + assert set(supported_formats()) == {*RENDERERS, *FORMAT_ALIASES}