Skip to content

Commit 9c92c18

Browse files
raifdmuellerclaude
authored andcommitted
fix: Allow negative numbers as arguments in sections-at-level
Fixes #199 Changes: - Add ignore_unknown_options and allow_interspersed_args to command settings to allow Click to parse negative numbers like -1 as arguments instead of options - Add validation to reject negative levels with clear error message explaining that document hierarchies start at level 0 - Add comprehensive tests covering negative number parsing, validation, and regression tests for positive numbers - Update CLI specification to document valid level range (non-negative integers) - Bump version to 0.4.5 Technical details: Click's argument parser treats tokens starting with '-' as option flags before type conversion. The combination of ignore_unknown_options=True and allow_interspersed_args=False tells Click to treat unknown options as potential arguments, allowing negative numbers to be parsed correctly. Since document hierarchies don't have semantic meaning for negative levels, the implementation validates and rejects negative numbers with a helpful error message. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 88d20ac commit 9c92c18

File tree

6 files changed

+282
-5
lines changed

6 files changed

+282
-5
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "dacli"
3-
version = "0.4.4"
3+
version = "0.4.5"
44
description = "Documentation Access CLI - Navigate and query large documentation projects"
55
readme = "README.md"
66
license = { text = "MIT" }

src/dacli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
through hierarchical, content-aware access via the Model Context Protocol (MCP).
55
"""
66

7-
__version__ = "0.4.4"
7+
__version__ = "0.4.5"

src/dacli/cli.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ def section(ctx: CliContext, path: str):
467467
sys.exit(EXIT_ERROR)
468468

469469

470-
@cli.command("sections-at-level", epilog="""
470+
@cli.command("sections-at-level", context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False}, epilog="""
471471
Examples:
472472
dacli sections-at-level 1 # All top-level chapters
473473
dacli sections-at-level 2 # All second-level sections
@@ -476,7 +476,18 @@ def section(ctx: CliContext, path: str):
476476
@click.argument("level", type=int)
477477
@pass_context
478478
def sections_at_level(ctx: CliContext, level: int):
479-
"""Get all sections at a specific nesting level."""
479+
"""Get all sections at a specific nesting level.
480+
481+
LEVEL must be a non-negative integer (0, 1, 2, ...).
482+
"""
483+
# Validate level is non-negative (Issue #199)
484+
if level < 0:
485+
raise click.BadParameter(
486+
f"Level must be non-negative, got {level}. "
487+
"Document hierarchies start at level 0 (document root).",
488+
param_hint="level"
489+
)
490+
480491
sections = ctx.index.get_sections_at_level(level)
481492
result = {
482493
"level": level,

src/docs/spec/06_cli_specification.adoc

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,29 @@ Shows all sections at a specific level.
177177
dacli sections-at-level <LEVEL>
178178
----
179179

180+
**Arguments:**
181+
182+
* `LEVEL`: Non-negative integer (0, 1, 2, ...) representing the nesting level
183+
- Level 0: Document root
184+
- Level 1: Top-level chapters/sections
185+
- Level 2: Subsections
186+
- Level 3 and higher: Deeper nested sections
187+
188+
**Examples:**
189+
[source,bash]
190+
----
191+
# Get all top-level chapters (Level 1)
192+
$ dacli sections-at-level 1
193+
194+
# Get all second-level sections (Level 2)
195+
$ dacli sections-at-level 2
196+
197+
# Error: Negative levels are not valid (Issue #199)
198+
$ dacli sections-at-level -1
199+
Error: Invalid value for 'level': Level must be non-negative, got -1.
200+
Document hierarchies start at level 0 (document root).
201+
----
202+
180203
== Search & Elements Commands
181204

182205
=== search
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""Tests for Issue #199: sections-at-level cannot handle negative numbers.
2+
3+
These tests verify that:
4+
1. Negative numbers can be parsed (not treated as options)
5+
2. Negative numbers are rejected with clear error message
6+
3. Zero and positive numbers continue to work correctly
7+
"""
8+
9+
from pathlib import Path
10+
11+
import pytest
12+
from click.testing import CliRunner
13+
14+
from dacli.cli import cli
15+
16+
17+
@pytest.fixture
18+
def temp_doc_dir(tmp_path: Path) -> Path:
19+
"""Create a temporary directory with test documents."""
20+
doc_file = tmp_path / "test.adoc"
21+
doc_file.write_text(
22+
"""= Test Document
23+
24+
== Level 1 Section A
25+
26+
Content here.
27+
28+
=== Level 2 Section A.1
29+
30+
Nested content.
31+
32+
== Level 1 Section B
33+
34+
More content.
35+
36+
=== Level 2 Section B.1
37+
38+
More nested content.
39+
40+
=== Level 2 Section B.2
41+
42+
Even more nested.
43+
""",
44+
encoding="utf-8",
45+
)
46+
return tmp_path
47+
48+
49+
class TestNegativeNumberParsing:
50+
"""Test that negative numbers are parsed correctly."""
51+
52+
def test_negative_one_is_parsed_not_treated_as_option(self, temp_doc_dir: Path):
53+
"""Negative number -1 should be parsed as argument, not option (Issue #199)."""
54+
runner = CliRunner()
55+
result = runner.invoke(
56+
cli,
57+
[
58+
"--docs-root",
59+
str(temp_doc_dir),
60+
"sections-at-level",
61+
"-1",
62+
],
63+
)
64+
65+
# Should NOT fail with "No such option: -1"
66+
assert "No such option: -1" not in result.output
67+
68+
# Should fail with validation error instead
69+
assert result.exit_code != 0
70+
assert "Level must be non-negative" in result.output
71+
72+
def test_negative_ten_is_parsed(self, temp_doc_dir: Path):
73+
"""Larger negative number -10 should be parsed as argument."""
74+
runner = CliRunner()
75+
result = runner.invoke(
76+
cli,
77+
[
78+
"--docs-root",
79+
str(temp_doc_dir),
80+
"sections-at-level",
81+
"-10",
82+
],
83+
)
84+
85+
# Should NOT fail with "No such option: -10"
86+
assert "No such option: -10" not in result.output
87+
88+
# Should fail with validation error
89+
assert result.exit_code != 0
90+
assert "Level must be non-negative" in result.output
91+
92+
93+
class TestNegativeNumberValidation:
94+
"""Test validation of negative levels."""
95+
96+
def test_negative_level_rejected_with_clear_message(self, temp_doc_dir: Path):
97+
"""Negative level should be rejected with helpful error message."""
98+
runner = CliRunner()
99+
result = runner.invoke(
100+
cli,
101+
[
102+
"--docs-root",
103+
str(temp_doc_dir),
104+
"sections-at-level",
105+
"-1",
106+
],
107+
)
108+
109+
assert result.exit_code != 0
110+
assert "Level must be non-negative" in result.output
111+
assert "got -1" in result.output
112+
assert "Document hierarchies start at level 0" in result.output
113+
114+
def test_negative_level_error_includes_level_value(self, temp_doc_dir: Path):
115+
"""Error message should include the actual negative value provided."""
116+
runner = CliRunner()
117+
result = runner.invoke(
118+
cli,
119+
[
120+
"--docs-root",
121+
str(temp_doc_dir),
122+
"sections-at-level",
123+
"-5",
124+
],
125+
)
126+
127+
assert result.exit_code != 0
128+
assert "got -5" in result.output
129+
130+
131+
class TestPositiveNumbersStillWork:
132+
"""Test that zero and positive numbers continue to work correctly."""
133+
134+
def test_level_zero_works(self, temp_doc_dir: Path):
135+
"""Level 0 (document root) should work."""
136+
runner = CliRunner()
137+
result = runner.invoke(
138+
cli,
139+
[
140+
"--docs-root",
141+
str(temp_doc_dir),
142+
"sections-at-level",
143+
"0",
144+
],
145+
)
146+
147+
assert result.exit_code == 0
148+
# Should return the document root
149+
assert "test" in result.output or "count" in result.output
150+
151+
def test_level_one_works(self, temp_doc_dir: Path):
152+
"""Level 1 (top-level sections) should work."""
153+
runner = CliRunner()
154+
result = runner.invoke(
155+
cli,
156+
[
157+
"--docs-root",
158+
str(temp_doc_dir),
159+
"sections-at-level",
160+
"1",
161+
],
162+
)
163+
164+
assert result.exit_code == 0
165+
# Should return both Level 1 sections
166+
assert "Level 1 Section A" in result.output or "level-1-section-a" in result.output
167+
assert "Level 1 Section B" in result.output or "level-1-section-b" in result.output
168+
169+
def test_level_two_works(self, temp_doc_dir: Path):
170+
"""Level 2 (nested sections) should work."""
171+
runner = CliRunner()
172+
result = runner.invoke(
173+
cli,
174+
[
175+
"--docs-root",
176+
str(temp_doc_dir),
177+
"sections-at-level",
178+
"2",
179+
],
180+
)
181+
182+
assert result.exit_code == 0
183+
# Should return level 2 sections
184+
assert (
185+
"Level 2 Section A.1" in result.output
186+
or "level-2-section-a-1" in result.output
187+
)
188+
189+
def test_level_one_json_format(self, temp_doc_dir: Path):
190+
"""Level 1 with JSON format should work."""
191+
runner = CliRunner()
192+
result = runner.invoke(
193+
cli,
194+
[
195+
"--docs-root",
196+
str(temp_doc_dir),
197+
"--format",
198+
"json",
199+
"sections-at-level",
200+
"1",
201+
],
202+
)
203+
204+
assert result.exit_code == 0
205+
assert '"level": 1' in result.output
206+
assert '"count":' in result.output
207+
assert '"sections":' in result.output
208+
209+
210+
class TestAliasStillWorks:
211+
"""Test that the 'lv' alias still works correctly."""
212+
213+
def test_lv_alias_with_positive_number(self, temp_doc_dir: Path):
214+
"""The 'lv' alias should work with positive numbers."""
215+
runner = CliRunner()
216+
result = runner.invoke(
217+
cli,
218+
[
219+
"--docs-root",
220+
str(temp_doc_dir),
221+
"lv", # Using alias
222+
"1",
223+
],
224+
)
225+
226+
assert result.exit_code == 0
227+
assert "Level 1 Section" in result.output or "level-1-section" in result.output
228+
229+
def test_lv_alias_rejects_negative(self, temp_doc_dir: Path):
230+
"""The 'lv' alias should also reject negative numbers."""
231+
runner = CliRunner()
232+
result = runner.invoke(
233+
cli,
234+
[
235+
"--docs-root",
236+
str(temp_doc_dir),
237+
"lv", # Using alias
238+
"-1",
239+
],
240+
)
241+
242+
assert result.exit_code != 0
243+
assert "Level must be non-negative" in result.output

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)