java/ext/javadoctest.py (109 lines of code) (raw):

# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import os import pathlib import subprocess from typing import Any, Dict import tempfile import shutil from sphinx.ext.doctest import ( DocTestBuilder, TestcodeDirective, TestoutputDirective, doctest, sphinx, ) from sphinx.locale import __ class JavaTestcodeDirective(TestcodeDirective): def run(self): node_list = super().run() node_list[0]["language"] = "java" return node_list class JavaDocTestBuilder(DocTestBuilder): """ Runs java test snippets in the documentation. The code in each testcode block is insert into a template Maven project, run through exec:java, its output captured and post-processed, and finally compared to whatever's in the corresponding testoutput. """ name = "javadoctest" epilog = __( "Java testing of doctests in the sources finished, look at the " "results in %(outdir)s/output.txt." ) def compile( self, code: str, name: str, type: str, flags: Any, dont_inherit: bool ) -> Any: source_dir = pathlib.Path(__file__).parent.parent / "source" / "demo" with tempfile.TemporaryDirectory() as project_dir: shutil.copytree(source_dir, project_dir, dirs_exist_ok=True) template_file_path = ( pathlib.Path(project_dir) / "src" / "main" / "java" / "org" / "example" / "Example.java" ) with open(template_file_path, "r") as infile: template = infile.read() filled_template = self.fill_template(template, code) with open(template_file_path, "w") as outfile: outfile.write(filled_template) # Support JPMS (since Arrow 16) modified_env = os.environ.copy() modified_env["_JAVA_OPTIONS"] = "--add-opens=java.base/java.nio=ALL-UNNAMED" test_proc = subprocess.Popen( [ "mvn", "--batch-mode", "-f", project_dir, "compile", "exec:java", "-Dexec.mainClass=org.example.Example", ], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, env=modified_env, ) out_java_arrow, err_java_arrow = test_proc.communicate() # continue with python logic code to do java output validation output = f"print('''{self.clean_output(out_java_arrow)}''')" # continue with sphinx default logic return compile(output, name, self.type, flags, dont_inherit) def clean_output(self, output: str): lines = output.split("\n") # Remove log lines from output lines = [l for l in lines if not l.startswith("[INFO]")] lines = [l for l in lines if not l.startswith("[WARNING]")] lines = [l for l in lines if not l.startswith("Download")] lines = [l for l in lines if not l.startswith("Progress")] result = "\n".join(lines) # Sometimes the testoutput content is smushed onto the same line as # following log line, probably just when the testcode code doesn't print # its own final newline. This example is the only case I found so I # didn't pull out the re module (i.e., this works) result = result.replace( "[INFO] ------------------------------------------------------------------------", "", ) # Convert all tabs to 4 spaces, Sphinx seems to eat tabs even if we # explicitly put them in the testoutput block so we instead modify # the output result = (4 * " ").join(result.split("\t")) return result.strip() def fill_template(self, template, code): # Detect the special case where cookbook code is already wrapped in a # class and just use the code as-is without wrapping it up if code.find("public class Example") >= 0: return template + code # Split input code into imports and not-imports lines = code.split("\n") code_imports = [l for l in lines if l.startswith("import")] code_rest = [l for l in lines if not l.startswith("import")] pieces = [ template, "\n".join(code_imports), "\n\npublic class Example {\n public static void main(String[] args) {\n", "\n".join(code_rest), " }\n}", ] return "\n".join(pieces) def setup(app) -> Dict[str, Any]: app.add_directive("testcode", JavaTestcodeDirective) app.add_directive("testoutput", TestoutputDirective) app.add_builder(JavaDocTestBuilder) app.add_config_value("doctest_show_successes", True, False) # this config value adds to sys.path app.add_config_value("doctest_path", [], False) app.add_config_value("doctest_test_doctest_blocks", "default", False) app.add_config_value("doctest_global_setup", "", False) app.add_config_value("doctest_global_cleanup", "", False) app.add_config_value( "doctest_default_flags", doctest.DONT_ACCEPT_TRUE_FOR_1 | doctest.ELLIPSIS | doctest.IGNORE_EXCEPTION_DETAIL, False, ) return {"version": sphinx.__display_version__, "parallel_read_safe": True}