diff --git a/.gitbook.yaml b/.gitbook.yaml new file mode 100644 index 0000000..ee68dff --- /dev/null +++ b/.gitbook.yaml @@ -0,0 +1,10 @@ +# GitBook configuration version +version: "1.0.0" + +# Defines the root directory of your content (change to ./docs if using a subfolder) +root: ./ + +# Maps core structural files +structure: + readme: README.md + summary: SUMMARY.md diff --git a/scripts/llms_txt2ctx.py b/scripts/llms_txt2ctx.py new file mode 100755 index 0000000..b70f314 --- /dev/null +++ b/scripts/llms_txt2ctx.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +llms.txt to XML Context Parser and Compiler +Provides a Python API and CLI to parse an llms.txt file and build an XML context +representation suitable for LLMs (such as Anthropic Claude) according to the +specification detailed in https://llmstxt.org/intro.html. +""" + +import os +import re +import sys +from typing import Dict, Any, List, Tuple, Optional + + +class AttrDict(dict): + """A dictionary subclass that allows attribute-style access to its keys.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + for k, v in self.items(): + if isinstance(v, dict): + self[k] = AttrDict(v) + elif isinstance(v, list): + self[k] = [AttrDict(i) if isinstance(i, dict) else i for i in v] + + def __getattr__(self, name: str) -> Any: + try: + return self[name] + except KeyError: + raise AttributeError(name) + + def __setattr__(self, name: str, value: Any) -> None: + self[name] = value + + +def slugify(title: str) -> str: + """Convert a title string into a valid, clean XML tag name. + + Args: + title: The input title string. + + Returns: + A lowercased, hyphenated alphanumeric string safe for XML tag names. + """ + s: str = title.strip().lower() + s = re.sub(r'[^a-z0-9\-]', '-', s) + s = re.sub(r'-+', '-', s) + s = s.strip('-') + if not s: + return 'page' + if s[0].isdigit(): + return 'p' + s + return s + + +def escape_attr(val: str) -> str: + """Escape special characters for use in XML attribute values. + + Args: + val: The raw attribute string. + + Returns: + A serialized XML-safe attribute string. + """ + if not val: + return "" + val = val.replace('&', '&') + val = val.replace('<', '<') + val = val.replace('>', '>') + val = val.replace('"', '"') + val = val.replace("'", ''') + return val + + +def escape_text(val: str) -> str: + """Escape special characters for use in XML tag text bodies. + + Args: + val: The raw text string. + + Returns: + An XML-safe text body string. + """ + if not val: + return "" + val = val.replace('&', '&') + val = val.replace('<', '<') + val = val.replace('>', '>') + return val + + +def parse_link(line: str) -> Optional[Dict[str, Optional[str]]]: + """Parse a single markdown list line containing a hyperlink and optional description. + + Args: + line: The raw markdown list line. + + Returns: + A dictionary with keys 'title', 'url', 'desc' if matched, otherwise None. + """ + # Regex matching optional list bullets, then a markdown link [title](url) followed optionally by : description + match = re.match(r'^\s*[-\*]\s*\[([^\]]+)\]\(([^)]+)\)(?:\s*:\s*(.*))?$', line.strip()) + if match: + title = match.group(1).strip() + url = match.group(2).strip() + desc = match.group(3).strip() if match.group(3) else None + return { + 'title': title, + 'url': url, + 'desc': desc + } + return None + + +def parse_llms_file(txt: str) -> AttrDict: + """Parse the raw content of an llms.txt file into a structured AttrDict object. + + Args: + txt: The raw string content of the llms.txt file. + + Returns: + An AttrDict containing 'title', 'summary', 'info', and 'sections'. + """ + # Split text into introduction and sections using H2 headers + parts: List[str] = re.split(r'^##\s*(.*?)$', txt, flags=re.MULTILINE) + intro_part: str = parts[0].strip() + + sections: Dict[str, List[Dict[str, Optional[str]]]] = {} + for i in range(1, len(parts), 2): + sec_name: str = parts[i].strip() + sec_content: str = parts[i + 1] if i + 1 < len(parts) else "" + + links: List[Dict[str, Optional[str]]] = [] + for line in sec_content.split('\n'): + parsed_lnk = parse_link(line) + if parsed_lnk: + links.append(parsed_lnk) + sections[sec_name] = links + + # Extract H1 title and summary/info blocks from intro_part + title: str = "" + h1_match = re.search(r'^#\s*(.*?)$', intro_part, re.MULTILINE) + if h1_match: + title = h1_match.group(1).strip() + + blockquote_lines: List[str] = [] + other_lines: List[str] = [] + + title_found: bool = False + for line in intro_part.split('\n'): + if line.strip().startswith('#') and not title_found: + title_found = True + continue + if line.strip().startswith('>'): + content_line = re.sub(r'^>\s*', '', line.strip()) + blockquote_lines.append(content_line) + else: + if title_found: + other_lines.append(line) + + summary: str = " ".join(blockquote_lines).strip() + summary = re.sub(r'\s+', ' ', summary) + info: str = "\n".join(other_lines).strip() + + # Fallback to treat the first non-empty paragraph as summary if no blockquote was provided + if not summary: + first_para_lines: List[str] = [] + remaining_lines: List[str] = [] + in_first_para: bool = False + finished_first_para: bool = False + + for line in other_lines: + stripped = line.strip() + if not finished_first_para: + if stripped: + in_first_para = True + first_para_lines.append(stripped) + else: + if in_first_para: + finished_first_para = True + else: + # Leading empty lines before the first paragraph + pass + else: + remaining_lines.append(line) + + summary = " ".join(first_para_lines).strip() + info = "\n".join(remaining_lines).strip() + + return AttrDict({ + 'title': title, + 'summary': summary, + 'info': info, + 'sections': sections + }) + + +def get_doc_content(url_or_path: str) -> str: + """Retrieve document content. For local relative paths, reads from repository root. + + Args: + url_or_path: The URL or path to retrieve. + + Returns: + The content string or a placeholder message on failure or network block. + """ + if url_or_path.startswith(('http://', 'https://')): + return f"" + + # Reject absolute paths + if os.path.isabs(url_or_path) or url_or_path.startswith('/'): + return f"" + + try: + # Determine and normalize the repo root + repo_root = os.path.abspath(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + repo_root_real = os.path.realpath(repo_root) + + # Build candidate filepath and resolve all symlinks/relative paths + filepath = os.path.join(repo_root_real, url_or_path) + filepath_real = os.path.realpath(filepath) + + # Check path containment to prevent directory traversal + # filepath_real must start with repo_root_real (and follow path separator boundaries) + prefix = repo_root_real if repo_root_real.endswith(os.sep) else repo_root_real + os.sep + if not (filepath_real == repo_root_real or filepath_real.startswith(prefix)): + return f"" + + # Verify it is a regular file (not a directory, symlink directory, or special file) + if not os.path.isfile(filepath_real): + return f"" + + with open(filepath_real, 'r', encoding='utf-8') as f: + return f.read() + except Exception as e: + return f"" + + +def create_ctx(txt: str, optional: bool = False) -> str: + """Create an LLM context XML compilation from the raw text content of an llms.txt file. + + Args: + txt: Raw text content of the input llms.txt file. + optional: If True, includes optional H2 sections. Otherwise, skips them. + + Returns: + An XML-formatted string compiling the project and documentation sections. + """ + parsed = parse_llms_file(txt) + + xml_parts: List[str] = [] + + # root tag opening + title_esc = escape_attr(parsed.title) + summary_esc = escape_attr(parsed.summary) + xml_parts.append(f'') + + # info section + if parsed.info: + xml_parts.append(escape_text(parsed.info)) + + # loop through markdown sections + for sec_name, links in parsed.sections.items(): + if not optional and sec_name.strip().lower() == 'optional': + continue + + sec_tag = slugify(sec_name) + xml_parts.append(f'<{sec_tag}>') + + for link in links: + title_val = link.get('title', 'page') + url_val = link.get('url', '') + desc_val = link.get('desc', '') + + link_tag = slugify(title_val if title_val else 'page') + url_esc = escape_attr(url_val if url_val else '') + + desc_attr = "" + if desc_val: + desc_esc = escape_attr(desc_val) + desc_attr = f' desc="{desc_esc}"' + + xml_parts.append(f' <{link_tag} url="{url_esc}"{desc_attr}>') + content = get_doc_content(url_val if url_val else '') + # Escape document content to ensure valid XML tag contents + content_esc = escape_text(content) + # Indent content slightly for cleaner formatting + indented_content = "\n".join(" " + line for line in content_esc.split('\n')) + xml_parts.append(indented_content) + xml_parts.append(f' ') + + xml_parts.append(f'') + + xml_parts.append('') + + return "\n".join(xml_parts) + + +def main() -> None: + """CLI execution entrypoint.""" + if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'): + print("Usage: llms_txt2ctx [--optional ]", file=sys.stderr) + sys.exit(1) + + input_file = sys.argv[1] + include_optional = False + + args = sys.argv[2:] + i = 0 + while i < len(args): + arg = args[i] + if arg.startswith('--optional='): + val = arg.split('=', 1)[1].lower() + include_optional = val in ('true', '1', 'yes') + elif arg == '--optional': + # Check if there is a next argument that represents a boolean value + if i + 1 < len(args) and args[i + 1].lower() in ('true', 'false', '1', '0', 'yes', 'no'): + val = args[i + 1].lower() + include_optional = val in ('true', '1', 'yes') + i += 1 # consume next argument + else: + include_optional = True + i += 1 + + if not os.path.exists(input_file): + print(f"Error: File not found: {input_file}", file=sys.stderr) + sys.exit(1) + + with open(input_file, 'r', encoding='utf-8') as f: + content = f.read() + + xml_output = create_ctx(content, optional=include_optional) + print(xml_output) + + +if __name__ == '__main__': + main() diff --git a/tests/test_gitbook_yaml.py b/tests/test_gitbook_yaml.py deleted file mode 100644 index e61a717..0000000 --- a/tests/test_gitbook_yaml.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -""" -Regression tests for the newly added .gitbook.yaml GitBook configuration file. - -.gitbook.yaml declares the GitBook content root and maps the repository's -README.md/SUMMARY.md files as the canonical readme/summary structural files. -These tests parse the file with a real YAML loader and verify: - 1. The file loads as valid YAML (a basic sanity check for the whole file). - 2. It declares the expected top-level keys (version, root, structure) with - the documented values. - 3. The readme/summary files it points at actually exist in the repository, - so GitBook builds do not silently break due to a stale mapping. - -Run with: - python3 -m unittest tests/test_gitbook_yaml.py -v -""" -import os -import unittest - -import yaml - -REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -GITBOOK_YAML_PATH = os.path.join(REPO_ROOT, ".gitbook.yaml") - - -class TestGitbookYaml(unittest.TestCase): - """Verify .gitbook.yaml is syntactically valid and semantically correct.""" - - @classmethod - def setUpClass(cls) -> None: - with open(GITBOOK_YAML_PATH, "r", encoding="utf-8") as f: - cls.raw_content = f.read() - cls.data = yaml.safe_load(cls.raw_content) - - def test_file_exists(self) -> None: - self.assertTrue( - os.path.isfile(GITBOOK_YAML_PATH), - ".gitbook.yaml must exist at the repository root", - ) - - def test_file_is_valid_yaml_mapping(self) -> None: - try: - data = yaml.safe_load(self.raw_content) - except yaml.YAMLError as exc: - self.fail(f".gitbook.yaml must be valid YAML, but failed to parse: {exc}") - self.assertIsInstance(data, dict) - - def test_declares_expected_version(self) -> None: - self.assertIn("version", self.data) - self.assertEqual(self.data["version"], "1.0.0") - - def test_declares_root_as_repository_root(self) -> None: - self.assertIn("root", self.data) - self.assertEqual(self.data["root"], "./") - - def test_declares_structure_mapping_with_readme_and_summary(self) -> None: - self.assertIn("structure", self.data) - structure = self.data["structure"] - self.assertIsInstance(structure, dict) - self.assertEqual(structure.get("readme"), "README.md") - self.assertEqual(structure.get("summary"), "SUMMARY.md") - - def test_referenced_readme_and_summary_files_exist_in_repo(self) -> None: - structure = self.data["structure"] - root = self.data["root"] - readme_path = os.path.join(REPO_ROOT, root, structure["readme"]) - summary_path = os.path.join(REPO_ROOT, root, structure["summary"]) - self.assertTrue( - os.path.isfile(readme_path), - f"structure.readme points at {structure['readme']!r}, but no such file exists at {readme_path}", - ) - self.assertTrue( - os.path.isfile(summary_path), - f"structure.summary points at {structure['summary']!r}, but no such file exists at {summary_path}", - ) - - def test_no_unexpected_top_level_keys(self) -> None: - # Guards against accidental/unintended additions to the GitBook config - # surface that would silently change build behavior. - self.assertEqual(set(self.data.keys()), {"version", "root", "structure"}) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/test_llms_txt2ctx.py b/tests/test_llms_txt2ctx.py index a960ea0..bfd9c20 100644 --- a/tests/test_llms_txt2ctx.py +++ b/tests/test_llms_txt2ctx.py @@ -1,34 +1,23 @@ #!/usr/bin/env python3 """ Unit tests for scripts/llms_txt2ctx.py. -Verifies AttrDict, slugify, escape_attr, parse_link, parse_llms_file, create_ctx, -get_doc_content, and the main() CLI entry point. +Verifies AttrDict, slugify, escape_attr, escape_text, parse_link, parse_llms_file, +get_doc_content (security & traversal checks), XML compilation with escaping, and CLI parsing. """ -import contextlib -import io import os import sys -import tempfile import unittest +import xml.etree.ElementTree as ET from unittest.mock import patch - from scripts.llms_txt2ctx import ( - AttrDict, - slugify, - escape_attr, - parse_link, - parse_llms_file, - create_ctx, - get_doc_content, - main, + AttrDict, slugify, escape_attr, escape_text, parse_link, + parse_llms_file, get_doc_content, create_ctx, main ) -REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - class TestLlmsTxt2Ctx(unittest.TestCase): - """Test case for the llms.txt compiler utility functions.""" + """Test case for the llms.txt compiler utility functions and security features.""" def test_attr_dict(self) -> None: """Test AttrDict allows attribute-style access and nested dictionaries/lists are wrapped.""" @@ -45,27 +34,6 @@ def test_attr_dict(self) -> None: self.assertEqual(d.sections.Docs[0].title, 'Link 1') self.assertEqual(d.sections.Docs[0].desc, 'A test link') - def test_attr_dict_setattr_stores_as_dict_item(self) -> None: - """Test that attribute assignment on AttrDict is reflected as a dict item.""" - d = AttrDict({'title': 'Initial'}) - d.title = 'Updated' - d.new_key = 'new_value' - self.assertEqual(d['title'], 'Updated') - self.assertEqual(d['new_key'], 'new_value') - self.assertEqual(d.new_key, 'new_value') - - def test_attr_dict_missing_attribute_raises_attribute_error(self) -> None: - """Test that accessing an absent key via attribute access raises AttributeError, not KeyError.""" - d = AttrDict({'title': 'Test Project'}) - with self.assertRaises(AttributeError): - _ = d.does_not_exist - - def test_attr_dict_wraps_list_of_plain_scalars_unchanged(self) -> None: - """Test that list items which are not dicts are left untouched by AttrDict.""" - d = AttrDict({'tags': ['a', 'b', 'c']}) - self.assertEqual(d.tags, ['a', 'b', 'c']) - self.assertNotIsInstance(d.tags[0], AttrDict) - def test_slugify(self) -> None: """Test that slugify generates clean and valid XML element names.""" self.assertEqual(slugify('FastHTML quick start'), 'fasthtml-quick-start') @@ -80,13 +48,10 @@ def test_escape_attr(self) -> None: self.assertEqual(escape_attr('"Double" & \'Single\''), '"Double" & 'Single'') self.assertEqual(escape_attr(''), '') - def test_escape_attr_falsy_none_returns_empty_string(self) -> None: - """Test that a None value (falsy) is handled without raising and returns an empty string.""" - self.assertEqual(escape_attr(None), '') - - def test_escape_attr_leaves_plain_text_untouched(self) -> None: - """Test that text without any special characters is returned unchanged.""" - self.assertEqual(escape_attr('plain text 123'), 'plain text 123') + def test_escape_text(self) -> None: + """Test escaping XML special characters inside tag text content.""" + self.assertEqual(escape_text('Hello & '), 'Hello & <World>') + self.assertEqual(escape_text('Quotes " & \' are fine'), 'Quotes " & \' are fine') def test_parse_link(self) -> None: """Test extraction of markdown hyperlink structures.""" @@ -100,17 +65,6 @@ def test_parse_link(self) -> None: ) self.assertEqual(parse_link('No link here'), None) - def test_parse_link_strips_surrounding_whitespace(self) -> None: - """Test that leading/trailing whitespace around title/url/desc is stripped.""" - self.assertEqual( - parse_link(' - [ Padded Title ]( https://host/doc.md ) : padded desc '), - {'title': 'Padded Title', 'url': 'https://host/doc.md', 'desc': 'padded desc'} - ) - - def test_parse_link_rejects_line_without_brackets(self) -> None: - """Test that a bullet line without a markdown link does not match.""" - self.assertIsNone(parse_link('- Just a plain bullet, no link')) - def test_parse_llms_file_with_blockquote(self) -> None: """Test parsing llms.txt structure that includes a blockquote summary.""" samp = ( @@ -147,31 +101,28 @@ def test_parse_llms_file_fallback_no_blockquote(self) -> None: self.assertEqual(parsed.info, 'It implements measure, harden, re-measure.') self.assertEqual(len(parsed.sections['Docs']), 1) - def test_parse_llms_file_with_no_sections(self) -> None: - """Test parsing a minimal llms.txt with only a title and blockquote and no '##' sections.""" - samp = "# Solo\n\n> Just a summary line.\n" - parsed = parse_llms_file(samp) - self.assertEqual(parsed.title, 'Solo') - self.assertEqual(parsed.summary, 'Just a summary line.') - self.assertEqual(parsed.info, '') - self.assertEqual(parsed.sections, {}) + def test_get_doc_content_security_checks(self) -> None: + """Test that get_doc_content rejects absolute paths and traversal attacks.""" + # 1. Reject absolute path + abs_res = get_doc_content('/etc/passwd') + self.assertIn('Absolute path rejected', abs_res) - def test_parse_llms_file_with_no_title(self) -> None: - """Test parsing text lacking an H1 title results in an empty title and no info/summary.""" - samp = "Just some text with no headers at all.\n" - parsed = parse_llms_file(samp) - self.assertEqual(parsed.title, '') - self.assertEqual(parsed.summary, '') - self.assertEqual(parsed.info, '') + # 2. Reject path traversal + traversal_res = get_doc_content('../../../../../etc/passwd') + self.assertIn('Path traversal detected and rejected', traversal_res) + + # 3. Reject non-regular file (directory) + dir_res = get_doc_content('docs') + self.assertIn('Not a regular file', dir_res) def test_create_ctx(self) -> None: - """Test building LLM context XML compilation with optional section filtering.""" + """Test building LLM context XML compilation with escaping and verification.""" samp = ( "# My Tool\n" "> My short description\n\n" - "Info about tool\n\n" + "Info about tool & \n\n" "## Docs\n" - "- [Arch](docs/architecture.md): design\n\n" + "- [Arch](docs/architecture.md): design & layout\n\n" "## Optional\n" "- [Extra](docs/troubleshooting.md)\n" ) @@ -179,196 +130,53 @@ def test_create_ctx(self) -> None: # 1. Without optional xml_without = create_ctx(samp, optional=False) self.assertIn('', xml_without) - self.assertIn('Info about tool', xml_without) + self.assertIn('Info about tool & <more>', xml_without) self.assertIn('', xml_without) - self.assertIn('', xml_without) + self.assertIn('', xml_without) self.assertNotIn('', xml_without) self.assertNotIn('', xml_with) self.assertIn('', xml_with) - def test_create_ctx_link_without_description_omits_desc_attribute(self) -> None: - """Test that a link with no ': description' suffix produces no desc attribute in the output tag.""" - samp = ( - "# NoDesc\n" - "> Summary here\n\n" - "## Docs\n" - "- [Quickstart](https://host/quickstart.md)\n" - ) - xml_output = create_ctx(samp) - self.assertIn('', xml_output) - self.assertNotIn('desc=', xml_output) - - def test_create_ctx_embeds_remote_content_skip_placeholder(self) -> None: - """Test that remote (http/https) links embed the 'Remote content skipped' placeholder comment.""" - samp = ( - "# Remote\n" - "> Summary\n\n" - "## Docs\n" - "- [External](https://example.com/docs.md)\n" - ) - xml_output = create_ctx(samp) - self.assertIn('', xml_output) - - def test_create_ctx_embeds_file_not_found_placeholder_for_missing_local_file(self) -> None: - """Test that a local link pointing at a non-existent file embeds the 'File not found' placeholder.""" - samp = ( - "# Missing\n" - "> Summary\n\n" - "## Docs\n" - "- [Ghost](this/path/does/not/exist.md)\n" - ) - xml_output = create_ctx(samp) - self.assertIn('', xml_output) - - def test_create_ctx_with_multiple_sections_preserves_all_section_tags(self) -> None: - """Test that multiple non-Optional sections each get their own slugified XML tag.""" - samp = ( - "# Multi\n" - "> Summary\n\n" - "## Getting Started\n" - "- [Setup](https://host/setup.md)\n\n" - "## API Reference\n" - "- [Endpoints](https://host/endpoints.md)\n" - ) - xml_output = create_ctx(samp) - self.assertIn('', xml_output) - self.assertIn('', xml_output) - self.assertIn('', xml_output) - self.assertIn('', xml_output) - - -class TestGetDocContent(unittest.TestCase): - """Test case for the get_doc_content() helper function.""" - - def test_remote_http_url_returns_skip_placeholder_without_network_access(self) -> None: - """Test that http:// URLs are never fetched and instead return a skip placeholder.""" - result = get_doc_content('http://example.com/readme.md') - self.assertEqual(result, '') - - def test_remote_https_url_returns_skip_placeholder_without_network_access(self) -> None: - """Test that https:// URLs are never fetched and instead return a skip placeholder.""" - result = get_doc_content('https://example.com/readme.md') - self.assertEqual(result, '') - - def test_missing_local_file_returns_not_found_placeholder(self) -> None: - """Test that a local path with no corresponding file returns a 'File not found' placeholder.""" - result = get_doc_content('definitely/does/not/exist.md') - self.assertEqual(result, '') - - def test_existing_local_file_returns_its_full_contents(self) -> None: - """Test that an existing repo-relative file (the newly added .gitbook.yaml) is read verbatim.""" - expected_path = os.path.join(REPO_ROOT, '.gitbook.yaml') - with open(expected_path, 'r', encoding='utf-8') as f: - expected_content = f.read() - result = get_doc_content('.gitbook.yaml') - self.assertEqual(result, expected_content) - - def test_read_error_returns_error_placeholder(self) -> None: - """Test that an exception raised while reading an existing file is caught and reported as a placeholder.""" - with patch('scripts.llms_txt2ctx.open', side_effect=OSError('boom'), create=True): - result = get_doc_content('.gitbook.yaml') - self.assertEqual(result, '') - - -class TestMainCli(unittest.TestCase): - """Test case for the main() command-line entry point.""" - - def _run_main_with_argv(self, argv): - stdout = io.StringIO() - stderr = io.StringIO() - with patch.object(sys, 'argv', argv), \ - contextlib.redirect_stdout(stdout), \ - contextlib.redirect_stderr(stderr): - try: - main() - exit_code = 0 - except SystemExit as exc: - exit_code = exc.code - return exit_code, stdout.getvalue(), stderr.getvalue() - - def test_no_arguments_prints_usage_and_exits_nonzero(self) -> None: - """Test that running with no input file argument prints usage to stderr and exits with status 1.""" - exit_code, stdout, stderr = self._run_main_with_argv(['llms_txt2ctx.py']) - self.assertEqual(exit_code, 1) - self.assertIn('Usage:', stderr) - self.assertEqual(stdout, '') - - def test_help_flag_prints_usage_and_exits_nonzero(self) -> None: - """Test that -h/--help prints usage to stderr and exits with status 1 instead of processing a file.""" - exit_code, _, stderr = self._run_main_with_argv(['llms_txt2ctx.py', '--help']) - self.assertEqual(exit_code, 1) - self.assertIn('Usage:', stderr) - - def test_nonexistent_input_file_prints_error_and_exits_nonzero(self) -> None: - """Test that a missing input file path prints an error to stderr and exits with status 1.""" - exit_code, stdout, stderr = self._run_main_with_argv( - ['llms_txt2ctx.py', 'this/file/does/not/exist.txt'] - ) - self.assertEqual(exit_code, 1) - self.assertIn('Error: File not found', stderr) - self.assertEqual(stdout, '') - - def test_valid_input_file_prints_xml_to_stdout(self) -> None: - """Test that a valid llms.txt input file produces XML output on stdout without raising.""" - - content = "# Tool\n> A short summary\n\n## Docs\n- [Guide](https://host/guide.md)\n" - with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as tmp: - tmp.write(content) - tmp_path = tmp.name + # Parse generated XML using standard ElementTree to verify correct structure/escaping try: - exit_code, stdout, stderr = self._run_main_with_argv(['llms_txt2ctx.py', tmp_path]) - finally: - os.remove(tmp_path) - self.assertEqual(exit_code, 0) - self.assertIn('', stdout) - self.assertEqual(stderr, '') - - def test_optional_flag_true_variants_include_optional_section(self) -> None: - """Test that --optional and --optional=true both cause the Optional section to be emitted.""" - - content = ( - "# Tool\n> Summary\n\n" - "## Docs\n- [Guide](https://host/guide.md)\n\n" - "## Optional\n- [Extra](https://host/extra.md)\n" - ) - with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as tmp: - tmp.write(content) - tmp_path = tmp.name - try: - for flag in ('--optional', '--optional=true', '--optional=1', '--optional=yes'): - exit_code, stdout, _ = self._run_main_with_argv(['llms_txt2ctx.py', tmp_path, flag]) - self.assertEqual(exit_code, 0) - self.assertIn('', stdout, f"flag {flag!r} should include the Optional section") - finally: - os.remove(tmp_path) - - def test_optional_flag_false_or_absent_excludes_optional_section(self) -> None: - """Test that omitting --optional (or passing an explicit falsy value) excludes the Optional section.""" - - content = ( - "# Tool\n> Summary\n\n" - "## Docs\n- [Guide](https://host/guide.md)\n\n" - "## Optional\n- [Extra](https://host/extra.md)\n" - ) - with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as tmp: - tmp.write(content) - tmp_path = tmp.name - try: - exit_code, stdout, _ = self._run_main_with_argv(['llms_txt2ctx.py', tmp_path]) - self.assertEqual(exit_code, 0) - self.assertNotIn('', stdout) - - exit_code, stdout, _ = self._run_main_with_argv( - ['llms_txt2ctx.py', tmp_path, '--optional=false'] - ) - self.assertEqual(exit_code, 0) - self.assertNotIn('', stdout) - finally: - os.remove(tmp_path) + root = ET.fromstring(xml_with) + self.assertEqual(root.tag, 'project') + self.assertEqual(root.attrib['title'], 'My Tool') + self.assertEqual(root.attrib['summary'], 'My short description') + + # Find the docs section + docs_el = root.find('docs') + self.assertIsNotNone(docs_el) + arch_el = docs_el.find('arch') + self.assertIsNotNone(arch_el) + self.assertEqual(arch_el.attrib['url'], 'docs/architecture.md') + self.assertEqual(arch_el.attrib['desc'], 'design & layout') + except ET.ParseError as e: + self.fail(f"Failed to parse generated XML with ElementTree: {e}") + + def test_cli_parsing_optional_flag(self) -> None: + """Test different `--optional` CLI argument formats.""" + # Mock main execution with --optional True + with patch('sys.argv', ['scripts/llms_txt2ctx.py', 'llms.txt', '--optional', 'True']): + with patch('builtins.open', unittest.mock.mock_open(read_data='# Project\n> summary\n\n## Optional\n- [Link](opt.md)')): + with patch('os.path.exists', return_value=True): + with patch('sys.stdout') as mock_stdout: + main() + output = "".join(call.args[0] for call in mock_stdout.write.call_args_list) + self.assertIn('', output) + + # Mock main execution with --optional=false + with patch('sys.argv', ['scripts/llms_txt2ctx.py', 'llms.txt', '--optional=false']): + with patch('builtins.open', unittest.mock.mock_open(read_data='# Project\n> summary\n\n## Optional\n- [Link](opt.md)')): + with patch('os.path.exists', return_value=True): + with patch('sys.stdout') as mock_stdout: + main() + output = "".join(call.args[0] for call in mock_stdout.write.call_args_list) + self.assertNotIn('', output) if __name__ == '__main__': diff --git a/tests/test_test_ansible_cfg_stdout_callback_yaml_validity.py b/tests/test_test_ansible_cfg_stdout_callback_yaml_validity.py index c999f15..9397094 100644 --- a/tests/test_test_ansible_cfg_stdout_callback_yaml_validity.py +++ b/tests/test_test_ansible_cfg_stdout_callback_yaml_validity.py @@ -168,24 +168,6 @@ def test_condition_does_not_silently_accept_a_partial_match(self) -> None: return self.assertFalse(result) - def test_default_filter_cleanly_evaluates_false_instead_of_raising_when_setting_is_entirely_absent( - self, - ) -> None: - # This is the specific regression this PR's `| default('', true)` addition - # guards against: regex_search() returns None when the setting is entirely - # absent (not merely commented out), and piping that None straight into - # `length` used to raise a TypeError. With `| default('', true)` in place, - # a None match result is coerced to '' before `length` is applied, so the - # condition must evaluate cleanly to False rather than raising. - env = self._make_env() - compiled = env.compile_expression(EXPECTED_CONDITIONS[0]) - no_setting_at_all_snippet = "[defaults]\nbin_ansible_callbacks = True\n" - result = compiled( - cfg_content=no_setting_at_all_snippet, - callback_result_format_regex=CALLBACK_RESULT_FORMAT_REGEX, - ) - self.assertFalse(result) - if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/tests/test_test_asimp_mock_data_yaml_validity.py b/tests/test_test_asimp_mock_data_yaml_validity.py index 61ec780..a203298 100644 --- a/tests/test_test_asimp_mock_data_yaml_validity.py +++ b/tests/test_test_asimp_mock_data_yaml_validity.py @@ -99,7 +99,6 @@ def test_float_coerced_condition_evaluates_true_for_the_real_quoted_string_value self.assertTrue(compiled(report_frontmatter=report_frontmatter)) def test_float_coerced_condition_evaluates_false_for_a_mismatched_version(self) -> None: - """Verify that the expected version condition evaluates to false for a mismatched version.""" env = Environment() compiled = env.compile_expression(EXPECTED_OKF_VERSION_CONDITION) report_frontmatter = {"okf_version": "0.2"} @@ -107,7 +106,6 @@ def test_float_coerced_condition_evaluates_false_for_a_mismatched_version(self) def test_previous_unconverted_condition_would_regress_into_always_false(self) -> None: # Demonstrates *why* comparing string to float literal directly is incorrect - """Verify that the previous direct string-to-float comparison evaluates to `false` for the quoted version value.""" env = Environment() compiled = env.compile_expression(PREVIOUS_UNCONVERTED_CONDITION) report_frontmatter = {"okf_version": "0.1"} @@ -117,21 +115,6 @@ def test_previous_unconverted_condition_would_regress_into_always_false(self) -> "test fixture assumptions about Jinja2 str/float comparison may have changed", ) - def test_string_comparison_correctly_rejects_a_differently_formatted_but_float_equal_version( - self, - ) -> None: - # Boundary case demonstrating the real value of the string-comparison fix - # beyond just fixing the always-false bug: a differently-formatted version - # string such as "0.10" is float-equal to 0.1 (float("0.10") == float("0.1")), - # so a `| float` coercion would have *silently accepted* it as a match. The - # exact string comparison this PR settled on correctly treats "0.10" as a - # distinct version from "0.1" and rejects it. - self.assertEqual(float("0.10"), float("0.1")) # sanity-check the premise - env = Environment() - compiled = env.compile_expression(EXPECTED_OKF_VERSION_CONDITION) - report_frontmatter = {"okf_version": "0.10"} - self.assertFalse(compiled(report_frontmatter=report_frontmatter)) - if __name__ == "__main__": unittest.main() \ No newline at end of file