diff --git a/src/douki/_base/discovery.py b/src/douki/_base/discovery.py index ef8bfb6..f7042d6 100644 --- a/src/douki/_base/discovery.py +++ b/src/douki/_base/discovery.py @@ -278,6 +278,12 @@ class _GitIgnoreMatcher: """ def __init__(self, root: Path) -> None: + """ + title: Initialize the matcher with a root directory. + parameters: + root: + type: Path + """ self._root: Path = root.resolve() self._rules_by_dir: dict[Path, tuple[_GitIgnoreRule, ...]] = {} diff --git a/src/douki/_python/extractor.py b/src/douki/_python/extractor.py index 8a72ec4..f4db760 100644 --- a/src/douki/_python/extractor.py +++ b/src/douki/_python/extractor.py @@ -172,6 +172,9 @@ class _FuncExtractor(ast.NodeVisitor): """ def __init__(self) -> None: + """ + title: Initialize the extractor with empty state. + """ self.results: list[FuncInfo] = [] self.in_class: bool = False # Maps class name → full list of attrs (own + inherited) diff --git a/src/douki/_python/language.py b/src/douki/_python/language.py index f4d1f1e..17e2898 100644 --- a/src/douki/_python/language.py +++ b/src/douki/_python/language.py @@ -21,6 +21,9 @@ class PythonLanguage(BaseLanguage): """ def __init__(self) -> None: + """ + title: Initialize the Python language backend. + """ self._config: PythonConfig = PythonConfig() @property diff --git a/src/douki/_python/sync.py b/src/douki/_python/sync.py index a573a26..f4e7a6e 100644 --- a/src/douki/_python/sync.py +++ b/src/douki/_python/sync.py @@ -55,6 +55,16 @@ def sync_source( if not funcs: return source + # Check for missing docstrings — every function, class, and module + # must have a docstring. + missing = [f for f in funcs if f.docstring_node is None] + if missing: + errors = [] + for f in missing: + prefix = '' if f.name == '' else f"'{f.name}'" + errors.append(f'- {prefix}: missing docstring') + raise DocstringValidationError('\n'.join(errors)) + lines = source.splitlines(keepends=True) # Process in reverse line order so edits don't shift indices. funcs_with_ds = [f for f in funcs if f.docstring_node is not None] diff --git a/tests/test_cli.py b/tests/test_cli.py index a205e19..0177d94 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -103,12 +103,131 @@ def add(x: int, y: int) -> int: assert str(p) in result.output or 'title' in result.output +# ------------------------------------------------------------------- +# Missing docstrings on methods +# ------------------------------------------------------------------- + + +def test_check_should_fail_when_method_has_no_docstring( + tmp_path: Path, +) -> None: + """ + title: check should fail (exit != 0) when a class method has no docstring. + parameters: + tmp_path: + type: Path + """ + p = _write( + tmp_path, + 'no_docstring.py', + '''\ + class Calculator: + """ + title: A simple calculator. + """ + + def add(self, x: int, y: int) -> int: + return x + y + ''', + ) + result = runner.invoke(app, ['check', str(p)]) + assert result.exit_code != 0, ( + f'Expected check to fail for method without docstring, ' + f'but got exit_code={result.exit_code}' + ) + + +def test_sync_should_fail_when_method_has_no_docstring( + tmp_path: Path, +) -> None: + """ + title: sync should fail (exit != 0) when a class method has no docstring. + parameters: + tmp_path: + type: Path + """ + p = _write( + tmp_path, + 'no_docstring.py', + '''\ + class Calculator: + """ + title: A simple calculator. + """ + + def add(self, x: int, y: int) -> int: + return x + y + ''', + ) + result = runner.invoke(app, ['sync', str(p)]) + assert result.exit_code != 0, ( + f'Expected sync to fail for method without docstring, ' + f'but got exit_code={result.exit_code}' + ) + + +def test_check_should_fail_when_function_has_no_docstring( + tmp_path: Path, +) -> None: + """ + title: >- + check should fail (exit != 0) when a top-level function has no docstring. + parameters: + tmp_path: + type: Path + """ + p = _write( + tmp_path, + 'no_docstring_func.py', + """\ + def add(x: int, y: int) -> int: + return x + y + """, + ) + result = runner.invoke(app, ['check', str(p)]) + assert result.exit_code != 0, ( + f'Expected check to fail for function without docstring, ' + f'but got exit_code={result.exit_code}' + ) + + +def test_sync_should_fail_when_function_has_no_docstring( + tmp_path: Path, +) -> None: + """ + title: >- + sync should fail (exit != 0) when a top-level function has no docstring. + parameters: + tmp_path: + type: Path + """ + p = _write( + tmp_path, + 'no_docstring_func.py', + """\ + def add(x: int, y: int) -> int: + return x + y + """, + ) + result = runner.invoke(app, ['sync', str(p)]) + assert result.exit_code != 0, ( + f'Expected sync to fail for function without docstring, ' + f'but got exit_code={result.exit_code}' + ) + + # ------------------------------------------------------------------- # douki sync # ------------------------------------------------------------------- def test_sync_updates_file(tmp_path: Path) -> None: + """ + title: sync should update file and exit 1. + parameters: + tmp_path: + type: Path + """ p = _write( tmp_path, 'apply.py', @@ -127,6 +246,12 @@ def add(x: int, y: int) -> int: def test_sync_idempotent(tmp_path: Path) -> None: + """ + title: Running sync twice produces no further changes. + parameters: + tmp_path: + type: Path + """ p = _write( tmp_path, 'idem.py', @@ -151,6 +276,12 @@ def add(x: int, y: int) -> int: def test_multiple_files(tmp_path: Path) -> None: + """ + title: Multiple files are all processed by check. + parameters: + tmp_path: + type: Path + """ p1 = _write( tmp_path, 'a.py', @@ -183,6 +314,12 @@ def bar(y: str) -> str: def test_skips_non_py_files(tmp_path: Path) -> None: + """ + title: Non-Python files are silently skipped. + parameters: + tmp_path: + type: Path + """ p = tmp_path / 'data.txt' p.write_text('hello', encoding='utf-8') result = runner.invoke(app, ['check', str(p)]) @@ -190,6 +327,12 @@ def test_skips_non_py_files(tmp_path: Path) -> None: def test_directory_discovers_py_files(tmp_path: Path) -> None: + """ + title: Passing a directory discovers .py files inside it. + parameters: + tmp_path: + type: Path + """ _write( tmp_path, 'mod.py', @@ -215,6 +358,12 @@ def greet(name: str) -> str: def test_migrate_numpydoc(tmp_path: Path) -> None: + """ + title: migrate converts NumPy docstrings to Douki YAML. + parameters: + tmp_path: + type: Path + """ p = _write( tmp_path, 'numpy.py', @@ -333,18 +482,27 @@ def test_check_empty_directory(tmp_path: Path) -> None: def test_help_output() -> None: + """ + title: Top-level --help exits 0 and mentions douki. + """ result = runner.invoke(app, ['--help']) assert result.exit_code == 0 assert 'douki' in result.output.lower() def test_sync_help_output() -> None: + """ + title: sync --help exits 0 and mentions sync. + """ result = runner.invoke(app, ['sync', '--help']) assert result.exit_code == 0 assert 'sync' in result.output.lower() def test_check_help_output() -> None: + """ + title: check --help exits 0 and mentions check. + """ result = runner.invoke(app, ['check', '--help']) assert result.exit_code == 0 assert 'check' in result.output.lower() @@ -358,6 +516,14 @@ def test_check_help_output() -> None: def test_exclude_files_via_pyproject( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """ + title: Files matching exclude patterns in pyproject.toml are skipped. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ monkeypatch.chdir(tmp_path) _write( @@ -417,6 +583,14 @@ def dirty() -> None: def test_exclude_files_via_pyproject_windows_separator( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """ + title: Backslash separators in exclude patterns work on all platforms. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ monkeypatch.chdir(tmp_path) _write( @@ -450,6 +624,14 @@ def dirty(value: int) -> int: def test_gitignore_files_are_ignored_by_default( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """ + title: Files listed in .gitignore are skipped by default. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ monkeypatch.chdir(tmp_path) _write( @@ -484,6 +666,15 @@ def bad(value: int) -> int: def test_nested_gitignore_respected_unless_disabled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """ + title: >- + Nested .gitignore is respected unless --no-respect-gitignore is passed. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ monkeypatch.chdir(tmp_path) nested = tmp_path / 'pkg' @@ -521,6 +712,16 @@ def bad(value: int) -> int: def test_pyproject_can_disable_gitignore( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """ + title: >- + Setting respect-gitignore = false in pyproject.toml disables gitignore + filtering. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ monkeypatch.chdir(tmp_path) _write( @@ -557,6 +758,14 @@ def bad(value: int) -> int: def test_cli_flag_overrides_pyproject_gitignore_setting( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """ + title: CLI --respect-gitignore flag overrides pyproject.toml. + parameters: + tmp_path: + type: Path + monkeypatch: + type: pytest.MonkeyPatch + """ monkeypatch.chdir(tmp_path) _write( @@ -690,6 +899,18 @@ def foo() -> None: import douki._python.language def _boom(*a: object, **kw: object) -> str: + """ + title: Stub that always raises RuntimeError. + parameters: + a: + type: object + variadic: positional + kw: + type: object + variadic: keyword + returns: + type: str + """ raise RuntimeError('boom') monkeypatch.setattr( @@ -725,6 +946,18 @@ def foo() -> None: import douki._python.language def _boom(*a: object, **kw: object) -> str: + """ + title: Stub that always raises RuntimeError. + parameters: + a: + type: object + variadic: positional + kw: + type: object + variadic: keyword + returns: + type: str + """ raise RuntimeError('boom') monkeypatch.setattr( @@ -760,6 +993,18 @@ def foo() -> None: import douki._python.language def _boom(*a: object, **kw: object) -> str: + """ + title: Stub that always raises RuntimeError. + parameters: + a: + type: object + variadic: positional + kw: + type: object + variadic: keyword + returns: + type: str + """ raise RuntimeError('boom') monkeypatch.setattr( diff --git a/tests/test_migrate.py b/tests/test_migrate.py index f839ceb..79900ed 100644 --- a/tests/test_migrate.py +++ b/tests/test_migrate.py @@ -18,15 +18,24 @@ def test_is_numpy_basic() -> None: + """ + title: Standard NumPy docstring is detected. + """ ds = 'Summary.\n\nParameters\n----------\nx : int\n Desc.\n' assert _is_numpydoc_docstring(ds) def test_is_numpy_false_for_plain() -> None: + """ + title: Plain text is not detected as NumPy. + """ assert not _is_numpydoc_docstring('Just a plain docstring.') def test_is_numpy_false_for_yaml() -> None: + """ + title: Douki YAML is not detected as NumPy. + """ assert not _is_numpydoc_docstring('title: test\n') @@ -36,6 +45,9 @@ def test_is_numpy_false_for_yaml() -> None: def test_split_sections_basic() -> None: + """ + title: Sections are split by dashed underlines. + """ ds = ( 'Summary line.\n\n' 'Parameters\n----------\n' @@ -56,6 +68,9 @@ def test_split_sections_basic() -> None: def test_parse_map_section() -> None: + """ + title: Map section entries are parsed with type and description. + """ body = 'x : int\n The x value.\ny : str\n The y value.' result = _parse_map_section(body) assert result == { @@ -70,6 +85,9 @@ def test_parse_map_section() -> None: def test_numpy_to_douki_basic() -> None: + """ + title: Full NumPy docstring converts to Douki YAML. + """ ds = ( 'Add two numbers.\n\n' 'Parameters\n----------\n' @@ -87,11 +105,17 @@ def test_numpy_to_douki_basic() -> None: def test_numpy_preserves_non_numpy() -> None: + """ + title: Non-NumPy docstring is returned unchanged. + """ raw = 'Just a plain docstring.' assert numpydoc_to_douki_yaml(raw) == raw def test_numpy_parses_raises() -> None: + """ + title: Raises section is converted to Douki YAML. + """ ds = 'Do something.\n\nRaises\n------\nValueError\n If input is bad.\n' result = numpydoc_to_douki_yaml(ds) assert 'raises:' in result @@ -99,6 +123,9 @@ def test_numpy_parses_raises() -> None: def test_numpy_with_summary() -> None: + """ + title: Extended summary is preserved as summary field. + """ ds = ( 'Title line.\n\n' 'Extended summary that goes\n' @@ -141,12 +168,18 @@ def test_numpy_no_narrative() -> None: def test_numpy_yields_section() -> None: + """ + title: Yields section is converted to Douki YAML. + """ ds = 'Generate stuff.\n\nYields\n------\nint\n A number.\n' result = numpydoc_to_douki_yaml(ds) assert 'yields:' in result def test_numpy_warnings_section() -> None: + """ + title: Warnings section is converted to Douki YAML. + """ ds = ( 'Do risky things.\n\n' 'Warnings\n--------\n' @@ -158,6 +191,9 @@ def test_numpy_warnings_section() -> None: def test_numpy_notes_section() -> None: + """ + title: Notes section is converted to Douki YAML. + """ ds = 'Some function.\n\nNotes\n-----\nThis is a note.\nMulti-line note.\n' result = numpydoc_to_douki_yaml(ds) assert 'notes:' in result diff --git a/tests/test_sync.py b/tests/test_sync.py index ef8607f..34c6795 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -10,6 +10,7 @@ import pytest from douki._base.sync import ( + DocstringValidationError, ParamInfo, _extract_returns_desc, _load_docstring_yaml, @@ -23,15 +24,24 @@ def test_validate_docstring_valid() -> None: + """ + title: Valid Douki YAML returns True. + """ assert validate_docstring('title: hello', 'test') def test_validate_docstring_missing_title() -> None: + """ + title: YAML without title raises ValueError. + """ with pytest.raises(ValueError, match="Missing 'title' field"): validate_docstring('summary: no title here', 'test') def test_validate_docstring_plain_text() -> None: + """ + title: Plain text docstring raises ValueError. + """ with pytest.raises( ValueError, match='Docstring is not a valid Douki YAML dictionary' ): @@ -39,6 +49,9 @@ def test_validate_docstring_plain_text() -> None: def test_validate_docstring_empty() -> None: + """ + title: Empty string returns False. + """ assert not validate_docstring('', 'test') @@ -48,6 +61,9 @@ def test_validate_docstring_empty() -> None: def test_extract_basic_function() -> None: + """ + title: Basic function extraction returns name, params, and return type. + """ src = '''\ def greet(name: str) -> str: """title: Say hello""" @@ -64,6 +80,9 @@ def greet(name: str) -> str: def test_extract_ignores_self_cls() -> None: + """ + title: self and cls are excluded from extracted params. + """ src = '''\ class Foo: def bar(self, x: int) -> int: @@ -82,6 +101,9 @@ def baz(cls, y: int) -> int: def test_extract_async_function() -> None: + """ + title: Async functions are extracted correctly. + """ src = '''\ async def fetch(url: str) -> bytes: """title: fetch url""" @@ -94,6 +116,9 @@ async def fetch(url: str) -> bytes: def test_extract_star_args() -> None: + """ + title: '*args and **kwargs are extracted as parameters.' + """ src = '''\ def variadic(*args: int, **kwargs: str) -> None: """title: variadic""" @@ -107,6 +132,9 @@ def variadic(*args: int, **kwargs: str) -> None: def test_extract_no_docstring() -> None: + """ + title: Function without docstring has docstring_node None. + """ src = """\ def nodoc(x: int) -> int: return x @@ -126,10 +154,25 @@ def _p( ann: str = '', kind: str = 'regular', ) -> ParamInfo: + """ + title: Shorthand ParamInfo constructor for tests. + parameters: + name: + type: str + ann: + type: str + kind: + type: str + returns: + type: ParamInfo + """ return ParamInfo(name=name, annotation=ann, kind=kind) def test_sync_adds_missing_param() -> None: + """ + title: Missing parameters are added to the docstring. + """ raw = 'title: test\n' params = [_p('x', 'int'), _p('y', 'int')] result = sync_docstring(raw, params, 'int') @@ -139,6 +182,9 @@ def test_sync_adds_missing_param() -> None: def test_sync_removes_stale_param() -> None: + """ + title: Parameters no longer in signature are removed. + """ raw = ( 'title: test\nparameters:\n' ' x:\n type: int\n description: old\n' @@ -151,6 +197,9 @@ def test_sync_removes_stale_param() -> None: def test_sync_preserves_descriptions() -> None: + """ + title: Existing parameter descriptions are preserved. + """ raw = ( 'title: test\nparameters:\n' ' x:\n type: int\n' @@ -162,6 +211,9 @@ def test_sync_preserves_descriptions() -> None: def test_sync_updates_return_type() -> None: + """ + title: Return type annotation is synced into returns section. + """ raw = 'title: test\n' result = sync_docstring(raw, [], 'float') assert 'returns:' in result @@ -169,12 +221,18 @@ def test_sync_updates_return_type() -> None: def test_sync_raises_on_non_yaml() -> None: + """ + title: Non-YAML docstring raises ValueError. + """ raw = 'Just a plain docstring.' with pytest.raises(ValueError, match='not a valid Douki YAML'): sync_docstring(raw, [_p('x', 'int')], 'int') def test_sync_idempotent() -> None: + """ + title: Syncing a fully-synced docstring produces no changes. + """ raw = ( 'title: test\n' 'parameters:\n' @@ -192,6 +250,9 @@ def test_sync_idempotent() -> None: def test_sync_handles_star_args() -> None: + """ + title: '*args and **kwargs produce variadic markers.' + """ raw = 'title: test\n' params = [ _p('args', 'int', 'var_positional'), @@ -208,6 +269,9 @@ def test_sync_handles_star_args() -> None: def test_sync_variadic_round_trip() -> None: + """ + title: Variadic parameters survive a round-trip sync. + """ raw = ( 'title: test\n' 'parameters:\n' @@ -260,12 +324,18 @@ def test_sync_variadic_backward_compat() -> None: def test_sync_removes_returns_for_none() -> None: + """ + title: Returns section is removed when return type is None. + """ raw = 'title: test\nreturns:\n type: str\n description: old\n' result = sync_docstring(raw, [], 'None') assert 'returns' not in result def test_sync_preserves_other_sections() -> None: + """ + title: Non-parameter sections like summary and raises are preserved. + """ raw = ( 'title: test\n' 'summary: A summary\n' @@ -286,6 +356,9 @@ def test_sync_preserves_other_sections() -> None: def test_sync_source_basic() -> None: + """ + title: Basic sync_source adds parameters and types. + """ src = '''\ def add(x: int, y: int) -> int: """ @@ -301,6 +374,9 @@ def add(x: int, y: int) -> int: def test_sync_source_raises_on_non_yaml_docstring() -> None: + """ + title: Plain-text docstring in source raises ValueError. + """ src = '''\ def plain(x: int) -> int: """Just a plain docstring.""" @@ -310,16 +386,22 @@ def plain(x: int) -> int: sync_source(src) -def test_sync_source_skips_no_docstring() -> None: +def test_sync_source_raises_on_missing_docstring() -> None: + """ + title: Function without any docstring raises validation error. + """ src = """\ def nodoc(x: int) -> int: return x """ - result = sync_source(src) - assert result == src + with pytest.raises(DocstringValidationError, match='missing docstring'): + sync_source(src) def test_sync_source_idempotent() -> None: + """ + title: Syncing already-synced source produces identical output. + """ src = '''\ def greet(name: str) -> str: """ @@ -340,6 +422,9 @@ def greet(name: str) -> str: def test_sync_source_method_ignores_self() -> None: + """ + title: Method sync excludes self from parameters. + """ src = '''\ class Foo: def bar(self, x: int) -> int: @@ -354,6 +439,9 @@ def bar(self, x: int) -> int: def test_sync_source_complex_types() -> None: + """ + title: Complex type annotations are synced correctly. + """ src = '''\ from typing import Optional, List @@ -372,12 +460,18 @@ def process( def test_sync_source_preserves_syntax_error() -> None: + """ + title: Unparseable source is returned unchanged. + """ src = 'def broken( -> None:\n' result = sync_source(src) assert result == src def test_sync_source_no_functions() -> None: + """ + title: Source with no functions is returned unchanged. + """ src = 'X = 42\n' result = sync_source(src) assert result == src @@ -425,6 +519,9 @@ class MyClass: value: int name: str def __init__(self, value: int, name: str) -> None: + """ + title: Initialize MyClass. + """ self.value = value self.name = name ''' @@ -500,6 +597,9 @@ class MyClass: title: My class """ def __init__(self, count: int): + """ + title: Initialize MyClass. + """ self._count = count self._cache = {} ''' @@ -519,6 +619,9 @@ class MyClass: title: My class """ def __init__(self) -> None: + """ + title: Initialize MyClass. + """ self._value: int = 0 self._name: str = "default" ''' @@ -541,6 +644,9 @@ class MyClass: """ count: float # class-level wins def __init__(self, count: int): + """ + title: Initialize MyClass. + """ self.count = count ''' result = sync_source(src) @@ -653,6 +759,9 @@ class Derived(Base): def test_sync_source_nested_class_and_method() -> None: + """ + title: Nested class method parameters are synced. + """ src = '''\ class Outer: """ @@ -682,6 +791,9 @@ def method(self, val: float) -> bool: def test_sync_source_migrate_numpydoc() -> None: + """ + title: migrate=numpydoc converts NumPy docstrings to Douki YAML. + """ src = '''\ def add(x, y): """Add two numbers. @@ -707,6 +819,9 @@ def add(x, y): def test_sync_source_migrate_leaves_yaml_alone() -> None: + """ + title: migrate=numpydoc leaves existing Douki YAML unchanged. + """ src = '''\ def greet(name: str) -> str: """ @@ -727,6 +842,9 @@ def greet(name: str) -> str: def test_sync_source_migrate_then_sync() -> None: + """ + title: Migrated output is idempotent under plain sync. + """ src = '''\ def add(x: int, y: int) -> int: """Add two numbers. @@ -757,6 +875,9 @@ def add(x: int, y: int) -> int: def test_extract_forward_ref_annotation() -> None: + """ + title: Forward-reference string annotations are extracted. + """ src = '''\ def foo(x: 'MyClass') -> 'MyClass': """ @@ -769,6 +890,9 @@ def foo(x: 'MyClass') -> 'MyClass': def test_extract_union_annotation() -> None: + """ + title: Union type annotations (X | Y) are extracted. + """ src = '''\ def foo(x: int | str) -> int | None: """ @@ -781,6 +905,9 @@ def foo(x: int | str) -> int | None: def test_extract_attribute_annotation() -> None: + """ + title: Dotted attribute annotations like os.PathLike are extracted. + """ src = '''\ import os @@ -795,6 +922,9 @@ def foo(x: os.PathLike) -> None: def test_extract_tuple_annotation() -> None: + """ + title: Subscripted tuple annotations are extracted. + """ src = '''\ def foo(x: tuple[int, str]) -> None: """ @@ -807,6 +937,9 @@ def foo(x: tuple[int, str]) -> None: def test_extract_list_annotation_bare() -> None: + """ + title: Subscripted list annotations are extracted. + """ src = '''\ def foo(x: list[int]) -> None: """ @@ -824,6 +957,9 @@ def foo(x: list[int]) -> None: def test_extract_positional_only() -> None: + """ + title: Positional-only parameters get the correct kind. + """ src = '''\ def foo(x: int, /, y: int) -> None: """ @@ -839,6 +975,9 @@ def foo(x: int, /, y: int) -> None: def test_extract_keyword_only() -> None: + """ + title: Keyword-only parameters get the correct kind. + """ src = '''\ def foo(*, key: str) -> None: """ @@ -884,7 +1023,9 @@ def test_sync_returns_flat_string() -> None: def test_is_douki_yaml_invalid_schema() -> None: - # Has title but unknown field should fail schema validation + """ + title: Unknown field in YAML fails schema validation. + """ with pytest.raises( ValueError, match='Docstring YAML does not follow douki schema' ): @@ -900,6 +1041,9 @@ def test_is_douki_yaml_invalid_schema() -> None: def test_sync_with_multiline_summary() -> None: + """ + title: Multi-line summary is preserved through sync. + """ raw = 'title: test\nsummary: |\n Line one\n Line two\n' result = sync_docstring(raw, [], '') assert 'Line one' in result @@ -907,6 +1051,9 @@ def test_sync_with_multiline_summary() -> None: def test_sync_with_raises_list() -> None: + """ + title: raises section with list format is preserved. + """ raw = 'title: test\nraises:\n - type: ValueError\n description: bad\n' result = sync_docstring(raw, [], '') assert 'ValueError' in result @@ -914,6 +1061,9 @@ def test_sync_with_raises_list() -> None: def test_sync_with_examples_list() -> None: + """ + title: examples section with code blocks is preserved. + """ raw = 'title: test\nexamples:\n - code: |\n add(1, 2)\n' result = sync_docstring(raw, [], '') assert 'examples:' in result @@ -921,18 +1071,27 @@ def test_sync_with_examples_list() -> None: def test_sync_with_visibility_non_default() -> None: + """ + title: Non-default visibility is preserved. + """ raw = 'title: test\nvisibility: private\n' result = sync_docstring(raw, [], '', language_defaults=PYTHON_DEFAULTS) assert 'visibility: private' in result def test_sync_omits_default_visibility() -> None: + """ + title: Default visibility (public) is omitted from output. + """ raw = 'title: test\nvisibility: public\n' result = sync_docstring(raw, [], '', language_defaults=PYTHON_DEFAULTS) assert 'visibility' not in result def test_sync_with_extra_keys() -> None: + """ + title: Extra keys like notes are preserved through sync. + """ raw = 'title: test\nnotes: important note\n' result = sync_docstring(raw, [], '') assert 'notes: important note' in result @@ -944,6 +1103,9 @@ def test_sync_with_extra_keys() -> None: def test_sync_param_with_optional_true() -> None: + """ + title: 'optional: true is preserved on parameters.' + """ raw = 'title: test\nparameters:\n x:\n type: int\n optional: true\n' params = [_p('x', 'int')] result = sync_docstring(raw, params, '') @@ -951,6 +1113,9 @@ def test_sync_param_with_optional_true() -> None: def test_sync_param_with_default_value() -> None: + """ + title: Default value is preserved on parameters. + """ raw = 'title: test\nparameters:\n x:\n type: int\n default: 42\n' params = [_p('x', 'int')] result = sync_docstring(raw, params, '') @@ -958,6 +1123,9 @@ def test_sync_param_with_default_value() -> None: def test_sync_param_with_special_chars_desc() -> None: + """ + title: Descriptions with YAML special chars are preserved. + """ raw = ( 'title: test\n' 'parameters:\n' @@ -976,6 +1144,9 @@ def test_sync_param_with_special_chars_desc() -> None: def test_extract_classmethod() -> None: + """ + title: cls is excluded from classmethod parameters. + """ src = '''\ class Foo: @classmethod @@ -993,6 +1164,9 @@ def create(cls, x: int) -> 'Foo': def test_sync_source_multiple_functions() -> None: + """ + title: Multiple functions in one source are all synced. + """ src = '''\ def add(x: int, y: int) -> int: """ @@ -1013,6 +1187,9 @@ def sub(x: int, y: int) -> int: def test_sync_source_with_no_return_annotation() -> None: + """ + title: No return annotation means no returns section. + """ src = '''\ def foo(x: int): """ @@ -1418,10 +1595,16 @@ def test_sync_with_yields_dict() -> None: def test_yaml_scalar_null() -> None: + """ + title: None is serialized as null. + """ assert _yaml_scalar(None) == 'null' def test_yaml_scalar_bool() -> None: + """ + title: Booleans are serialized as true/false. + """ assert _yaml_scalar(True) == 'true' assert _yaml_scalar(False) == 'false'