Skip to content

Commit d1a1519

Browse files
authored
Enable single doctest (opensearch-project#4130)
Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
1 parent aecca57 commit d1a1519

3 files changed

Lines changed: 344 additions & 10 deletions

File tree

DEVELOPER_GUIDE.rst

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ Most of the time you just need to run ./gradlew build which will make sure you p
294294
* - ./gradlew :integ-test:yamlRestTest
295295
- Run rest integration test.
296296
* - ./gradlew :doctest:doctest
297-
- Run doctests
297+
- Run doctests in docs folder. You can use ``-Pdocs=file1,file2`` to run specific file(s). See more info in `Documentation <#documentation>`_ section.
298298
* - ./gradlew build
299299
- Build plugin by run all tasks above (this takes time).
300300
* - ./gradlew pitest
@@ -466,6 +466,18 @@ Doctest
466466

467467
Python doctest library makes our document executable which keeps it up-to-date to source code. The doc generator aforementioned served as scaffolding and generated many docs in short time. Now the examples inside is changed to doctest gradually. For more details please read `testing-doctest <./docs/dev/testing-doctest.md>`_.
468468

469+
.. code-block:: bash
470+
# Test all docs
471+
./gradlew :doctest:doctest
472+
473+
# Test single file using main doctest task
474+
./gradlew :doctest:doctest -Pdocs=search
475+
476+
# Test multiple files at once
477+
./gradlew :doctest:doctest -Pdocs=search,fields,basics
478+
479+
# With verbose output
480+
./gradlew :doctest:doctest -Pdocs=stats -Pverbose=true
469481
470482
Backports
471483
>>>>>>>>>

doctest/build.gradle

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,44 @@ task startOpenSearch(type: SpawnProcessTask) {
7676
}
7777

7878
task doctest(type: Exec, dependsOn: ['bootstrap']) {
79-
80-
commandLine "$projectDir/bin/test-docs"
79+
description = 'Run doctest for all files, or specific files if provided'
80+
81+
def docs = project.findProperty('docs')
82+
def verbose = project.findProperty('verbose')
83+
def endpoint = project.findProperty('endpoint')
84+
85+
if (docs) {
86+
// Single file or multiple files mode
87+
def args = ['.venv/bin/python', 'test_docs.py']
88+
89+
// Handle multiple files (comma-separated)
90+
if (docs.contains(',')) {
91+
args.addAll(docs.split(',').collect { it.trim() })
92+
} else {
93+
args.add(docs)
94+
}
95+
96+
if (verbose == 'true') {
97+
args.add('--verbose')
98+
}
99+
if (endpoint) {
100+
args.addAll(['--endpoint', endpoint])
101+
}
102+
103+
commandLine args
104+
} else {
105+
// Full test suite mode (original behavior)
106+
commandLine "$projectDir/bin/test-docs"
107+
}
81108

82109
doLast {
83110
// remove the cloned sql-cli folder
84111
file("$projectDir/sql-cli").deleteDir()
85-
println("Doctest Done")
112+
if (docs) {
113+
println("Single file doctest done")
114+
} else {
115+
println("Full doctest suite done")
116+
}
86117
}
87118
}
88119

doctest/test_docs.py

Lines changed: 297 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
# Copyright OpenSearch Contributors
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import argparse
45
import doctest
6+
import json
57
import os
68
import os.path
7-
import zc.customdoctests
8-
import json
9-
import re
109
import random
10+
import re
1111
import subprocess
12+
import sys
1213
import unittest
13-
import click
14-
1514
from functools import partial
15+
16+
import click
17+
import zc.customdoctests
18+
from opensearch_sql_cli.formatter import Formatter
1619
from opensearch_sql_cli.opensearch_connection import OpenSearchConnection
1720
from opensearch_sql_cli.utils import OutputSettings
18-
from opensearch_sql_cli.formatter import Formatter
1921
from opensearchpy import OpenSearch, helpers
2022

2123
ENDPOINT = "http://localhost:9200"
@@ -244,3 +246,292 @@ def load_tests(loader, suite, ignore):
244246
random.shuffle(tests)
245247

246248
return DocTests(tests)
249+
250+
251+
# Single file doctest functionality
252+
def find_doc_file(filename_or_path):
253+
"""Find documentation file by name or return the path if it's already a full path"""
254+
# If it's already a full path that exists, return it
255+
if os.path.exists(filename_or_path):
256+
return filename_or_path
257+
258+
# If it's just a filename, search for it in the docs directory
259+
if not os.path.sep in filename_or_path:
260+
try:
261+
with open('../docs/category.json') as json_file:
262+
category = json.load(json_file)
263+
264+
# Search in all categories
265+
all_docs = category['bash'] + category['ppl_cli'] + category['sql_cli']
266+
267+
# Add .rst extension if not present
268+
search_filename = filename_or_path
269+
if not search_filename.endswith('.rst'):
270+
search_filename += '.rst'
271+
272+
# Find files that end with the given filename
273+
matches = [doc for doc in all_docs if doc.endswith(search_filename)]
274+
275+
if len(matches) == 1:
276+
found_path = f"../docs/{matches[0]}"
277+
print(f"Found: {found_path}")
278+
return found_path
279+
elif len(matches) > 1:
280+
print(f"Multiple files found matching '{search_filename}':")
281+
for match in matches:
282+
print(f" ../docs/{match}")
283+
print("Please specify the full path or a more specific filename.")
284+
return None
285+
else:
286+
print(f"No documentation file found matching '{search_filename}'")
287+
print("Use --list to see all available files")
288+
return None
289+
290+
except Exception as e:
291+
print(f"Error searching for file: {e}")
292+
return None
293+
294+
# If it's a relative path, try to find it
295+
if not filename_or_path.startswith('../docs/'):
296+
potential_path = f"../docs/{filename_or_path}"
297+
if os.path.exists(potential_path):
298+
return potential_path
299+
300+
return filename_or_path
301+
302+
303+
def determine_doc_type(file_path):
304+
"""Determine the type of documentation file based on category.json"""
305+
try:
306+
with open('../docs/category.json') as json_file:
307+
category = json.load(json_file)
308+
309+
# Convert absolute path to relative path from docs directory
310+
rel_path = os.path.relpath(file_path, '../docs')
311+
312+
if rel_path in category['bash']:
313+
return 'bash'
314+
elif rel_path in category['ppl_cli']:
315+
return 'ppl_cli'
316+
elif rel_path in category['sql_cli']:
317+
return 'sql_cli'
318+
else:
319+
# Try to guess based on file path
320+
if '/ppl/' in file_path:
321+
return 'ppl_cli'
322+
elif '/sql/' in file_path or '/dql/' in file_path:
323+
return 'sql_cli'
324+
else:
325+
return 'bash' # default fallback
326+
except Exception as e:
327+
print(f"Warning: Could not determine doc type from category.json: {e}")
328+
# Fallback to path-based detection
329+
if '/ppl/' in file_path:
330+
return 'ppl_cli'
331+
elif '/sql/' in file_path or '/dql/' in file_path:
332+
return 'sql_cli'
333+
else:
334+
return 'bash'
335+
336+
337+
def run_single_doctest(file_path, verbose=False, endpoint=None):
338+
"""Run doctest for a single documentation file"""
339+
340+
if not os.path.exists(file_path):
341+
print(f"Error: File {file_path} does not exist")
342+
return False
343+
344+
# Update endpoint if provided
345+
if endpoint:
346+
global ENDPOINT
347+
ENDPOINT = endpoint
348+
print(f"Using custom endpoint: {endpoint}")
349+
350+
doc_type = determine_doc_type(file_path)
351+
print(f"Detected doc type: {doc_type}")
352+
print(f"Running doctest for: {file_path}")
353+
354+
# Configure doctest options
355+
optionflags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS
356+
if verbose:
357+
optionflags |= doctest.REPORT_NDIFF
358+
359+
# Choose appropriate parser and setup based on doc type
360+
if doc_type == 'bash':
361+
parser = bash_parser
362+
setup_func = set_up_test_indices
363+
globs = {
364+
'sh': partial(
365+
subprocess.run,
366+
stdin=subprocess.PIPE,
367+
stdout=subprocess.PIPE,
368+
stderr=subprocess.STDOUT,
369+
timeout=60,
370+
shell=True
371+
),
372+
'pretty_print': pretty_print
373+
}
374+
elif doc_type == 'ppl_cli':
375+
parser = ppl_cli_parser
376+
setup_func = set_up_test_indices
377+
globs = {}
378+
else: # sql_cli
379+
parser = sql_cli_parser
380+
setup_func = set_up_test_indices
381+
globs = {}
382+
383+
try:
384+
print("Setting up test environment...")
385+
386+
# Create and run the doctest suite
387+
suite = doctest.DocFileSuite(
388+
file_path,
389+
parser=parser,
390+
setUp=setup_func,
391+
tearDown=tear_down,
392+
optionflags=optionflags,
393+
encoding='utf-8',
394+
globs=globs
395+
)
396+
397+
# Run the test
398+
runner = unittest.TextTestRunner(verbosity=2 if verbose else 1)
399+
result = runner.run(suite)
400+
401+
# Print summary
402+
if result.wasSuccessful():
403+
print(f"\nSUCCESS: All tests in {os.path.basename(file_path)} passed!")
404+
print(f"Tests run: {result.testsRun}, Failures: {len(result.failures)}, Errors: {len(result.errors)}")
405+
return True
406+
else:
407+
print(f"\nFAILED: {len(result.failures + result.errors)} test(s) failed in {os.path.basename(file_path)}")
408+
print(f"Tests run: {result.testsRun}, Failures: {len(result.failures)}, Errors: {len(result.errors)}")
409+
410+
if verbose:
411+
print("\nDetailed failure information:")
412+
for failure in result.failures:
413+
print(f"\n--- FAILURE in {failure[0]} ---")
414+
print(failure[1])
415+
for error in result.errors:
416+
print(f"\n--- ERROR in {error[0]} ---")
417+
print(error[1])
418+
else:
419+
print("Use --verbose for detailed failure information")
420+
421+
return False
422+
423+
except Exception as e:
424+
print(f"Error running doctest: {e}")
425+
if verbose:
426+
import traceback
427+
traceback.print_exc()
428+
return False
429+
430+
431+
def list_available_docs():
432+
"""List all available documentation files that can be tested"""
433+
try:
434+
with open('../docs/category.json') as json_file:
435+
category = json.load(json_file)
436+
437+
print("Available documentation files for testing:")
438+
print(f"\nBash-based docs ({len(category['bash'])} files):")
439+
for doc in sorted(category['bash']):
440+
print(f" ../docs/{doc}")
441+
442+
print(f"\nPPL CLI docs ({len(category['ppl_cli'])} files):")
443+
for doc in sorted(category['ppl_cli']):
444+
print(f" ../docs/{doc}")
445+
446+
print(f"\nSQL CLI docs ({len(category['sql_cli'])} files):")
447+
for doc in sorted(category['sql_cli']):
448+
print(f" ../docs/{doc}")
449+
450+
total_docs = len(category['bash']) + len(category['ppl_cli']) + len(category['sql_cli'])
451+
print(f"\nTotal: {total_docs} documentation files available for testing")
452+
453+
except Exception as e:
454+
print(f"Error reading category.json: {e}")
455+
456+
457+
def main():
458+
"""Main entry point for single file testing"""
459+
parser = argparse.ArgumentParser(
460+
description="Run doctest for one or more documentation files, or all files if no arguments provided",
461+
formatter_class=argparse.RawDescriptionHelpFormatter,
462+
epilog="""
463+
Examples:
464+
python test_docs.py # Run all tests (default behavior)
465+
python test_docs.py stats # Run single file (extension optional)
466+
python test_docs.py stats.rst --verbose
467+
python test_docs.py stats fields basics
468+
python test_docs.py ../docs/user/ppl/cmd/stats.rst --endpoint http://localhost:9201
469+
470+
Performance Tips:
471+
- Use --verbose for detailed debugging information
472+
- Ensure OpenSearch is running on the specified endpoint before testing
473+
- Extension .rst can be omitted for convenience
474+
"""
475+
)
476+
477+
parser.add_argument('file_paths', nargs='*', help='Path(s) to the documentation file(s) to test')
478+
parser.add_argument('--verbose', '-v', action='store_true',
479+
help='Enable verbose output with detailed diff information')
480+
parser.add_argument('--endpoint', '-e', default=None,
481+
help='Custom OpenSearch endpoint (default: http://localhost:9200)')
482+
parser.add_argument('--list', '-l', action='store_true',
483+
help='List all available documentation files')
484+
485+
args = parser.parse_args()
486+
487+
if args.list:
488+
list_available_docs()
489+
return
490+
491+
# If no file paths provided, run the default unittest behavior
492+
if not args.file_paths:
493+
print("No specific files provided. Running full doctest suite...")
494+
# Run the standard unittest discovery
495+
unittest.main(module=None, argv=['test_docs.py'], exit=False)
496+
return
497+
498+
# Single file testing mode
499+
all_success = True
500+
total_files = len(args.file_paths)
501+
502+
for i, file_path in enumerate(args.file_paths, 1):
503+
if total_files > 1:
504+
print(f"\n{'='*60}")
505+
print(f"Testing file {i}/{total_files}: {file_path}")
506+
print('='*60)
507+
508+
# Find the actual file path (handles both full paths and just filenames)
509+
actual_file_path = find_doc_file(file_path)
510+
if not actual_file_path:
511+
print(f"Skipping {file_path} - file not found")
512+
all_success = False
513+
continue
514+
515+
success = run_single_doctest(
516+
actual_file_path,
517+
verbose=args.verbose,
518+
endpoint=args.endpoint
519+
)
520+
521+
if not success:
522+
all_success = False
523+
524+
if total_files > 1:
525+
print(f"\n{'='*60}")
526+
print(f"SUMMARY: Tested {total_files} files")
527+
if all_success:
528+
print("All tests passed!")
529+
else:
530+
print("Some tests failed!")
531+
print('='*60)
532+
533+
sys.exit(0 if all_success else 1)
534+
535+
536+
if __name__ == '__main__':
537+
main()

0 commit comments

Comments
 (0)