diff --git a/.github/scripts/guide_ci_tasks.py b/.github/scripts/guide_ci_tasks.py index e3fa5b1b043..795487f1796 100644 --- a/.github/scripts/guide_ci_tasks.py +++ b/.github/scripts/guide_ci_tasks.py @@ -18,6 +18,7 @@ class GuideMetadata: slug: str publish: bool base: str | None + python_app: bool = False def kebab_case_to_gradle_name(name: str) -> str: @@ -41,15 +42,21 @@ def guide_slugs_from_paths(paths: Iterable[str]) -> set[str]: def load_guides(guides_dir: Path) -> dict[str, GuideMetadata]: - guides = {} + metadata_by_slug = {} for metadata_path in sorted(guides_dir.glob("*/metadata.json")): with metadata_path.open(encoding="utf-8") as metadata_file: - metadata = json.load(metadata_file) - slug = metadata_path.parent.name + metadata_by_slug[metadata_path.parent.name] = json.load(metadata_file) + has_apps = {slug: bool(metadata.get("apps")) for slug, metadata in metadata_by_slug.items()} + # Match GuideUtils' sorted, in-place merge of base applications. + for slug, metadata in metadata_by_slug.items(): + has_apps[slug] = has_apps[slug] or has_apps.get(metadata.get("base"), False) + guides = {} + for slug, metadata in metadata_by_slug.items(): guides[slug] = GuideMetadata( slug=slug, publish=metadata.get("publish", True) is not False, base=metadata.get("base"), + python_app="PYTHON" in (metadata.get("languages") or []) and has_apps[slug], ) return guides @@ -75,12 +82,18 @@ def resolve_impacted_guide_slugs( ) -def tasks_for_changed_files(changed_files: Iterable[str], guides_dir: Path) -> list[str]: +def tasks_for_changed_files( + changed_files: Iterable[str], guides_dir: Path, *, jvm_only: bool = False, +) -> list[str]: guides = load_guides(guides_dir) - return [ - f"{kebab_case_to_gradle_name(slug)}Build" - for slug in resolve_impacted_guide_slugs(changed_files, guides) - ] + tasks = [] + for slug in resolve_impacted_guide_slugs(changed_files, guides): + task_slug = kebab_case_to_gradle_name(slug) + task = f"{task_slug}Build" + if jvm_only and guides[slug].python_app: + task += f" -x {task_slug}RunPythonTestScript" + tasks.append(task) + return tasks def matrix_for_tasks(tasks: Iterable[str]) -> dict[str, list[str]]: @@ -124,6 +137,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--guides-dir", help="Guides directory. Defaults to /guides.") parser.add_argument("--base", help="Base commit for git diff.") parser.add_argument("--head", default="HEAD", help="Head commit for git diff.") + parser.add_argument( + "--jvm-only", action="store_true", + help="Exclude only Python runtime test tasks; retain JVM tests and guide generation.", + ) parser.add_argument( "--diff-mode", choices=("range", "merge-base"), @@ -153,7 +170,7 @@ def main() -> int: if args.changed_files is not None else git_changed_files(repo, args.base, args.head, args.diff_mode) ) - tasks = tasks_for_changed_files(changed_files, guides_dir) + tasks = tasks_for_changed_files(changed_files, guides_dir, jvm_only=args.jvm_only) matrix = matrix_for_tasks(tasks) print(f"Changed files: {json.dumps(changed_files)}", file=sys.stderr) diff --git a/.github/scripts/test_guide_ci_tasks.py b/.github/scripts/test_guide_ci_tasks.py index 5d0f07ade53..1ef1a95c173 100644 --- a/.github/scripts/test_guide_ci_tasks.py +++ b/.github/scripts/test_guide_ci_tasks.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -78,6 +80,180 @@ def test_non_guide_changes_return_empty_task_list(self) -> None: self.assertEqual([], tasks) + def test_default_python_build_remains_unchanged(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "python-guide", languages=["JAVA", "PYTHON"], apps=[{"name": "default"}]) + + self.assertEqual( + ["pythonGuideBuild"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/python-guide/metadata.json"], guides_dir, + ), + ) + + def test_jvm_only_guide_without_apps_has_no_absent_runner_exclusion(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "python-guide", languages=["JAVA", "PYTHON"]) + + self.assertEqual( + ["pythonGuideBuild"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/python-guide/metadata.json"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_excludes_python_runner_for_explicit_apps(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata( + guides_dir, "python-guide", languages=["JAVA", "PYTHON"], + apps=[{"name": "books"}, {"name": "inventory"}], + ) + + self.assertEqual( + ["pythonGuideBuild -x pythonGuideRunPythonTestScript"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/python-guide/python/tests/test_books.py"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_docs_only_guide_has_no_absent_runner_exclusion(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "docs-guide", languages=["JAVA", "PYTHON"], apps=[]) + + self.assertEqual( + ["docsGuideBuild"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/docs-guide/docs-guide.adoc"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_retains_jvm_guide_build(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "jvm-guide", languages=["JAVA", "GROOVY", "KOTLIN"]) + + self.assertEqual( + ["jvmGuideBuild"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/jvm-guide/metadata.json"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_excludes_python_runner_for_inherited_apps(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "app-base", publish=False, apps=[{"name": "default"}]) + write_metadata(guides_dir, "python-guide", base="app-base", languages=["PYTHON"], apps=[]) + + self.assertEqual( + ["pythonGuideBuild -x pythonGuideRunPythonTestScript"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/python-guide/metadata.json"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_resolves_sorted_transitive_app_merges(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "a-base", publish=False, apps=[{"name": "default"}]) + write_metadata(guides_dir, "b-base", publish=False, base="a-base", apps=[]) + write_metadata(guides_dir, "python-guide", base="b-base", languages=["PYTHON"], apps=[]) + + self.assertEqual( + ["pythonGuideBuild -x pythonGuideRunPythonTestScript"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/a-base/common.adoc"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_does_not_inherit_base_python_language(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata( + guides_dir, "python-base", publish=False, languages=["PYTHON"], + apps=[{"name": "default"}], + ) + for languages in (None, ["JAVA", "GROOVY", "KOTLIN"]): + with self.subTest(languages=languages): + child = "default-jvm" if languages is None else "explicit-jvm" + write_metadata(guides_dir, child, base="python-base", languages=languages, apps=[]) + self.assertEqual( + [f"{guide_ci_tasks.kebab_case_to_gradle_name(child)}Build"], + guide_ci_tasks.tasks_for_changed_files( + [f"guides/{child}/metadata.json"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_empty_base_and_child_have_no_python_runner(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "docs-base", publish=False, languages=["PYTHON"], apps=[]) + write_metadata(guides_dir, "docs-guide", base="docs-base", languages=["PYTHON"], apps=[]) + + self.assertEqual( + ["docsGuideBuild"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/docs-base/common.adoc"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_preserves_sorted_merge_order_for_later_bases(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "z-base", publish=False, apps=[{"name": "default"}]) + write_metadata(guides_dir, "b-base", publish=False, base="z-base", apps=[]) + write_metadata(guides_dir, "a-guide", base="b-base", languages=["PYTHON"], apps=[]) + + self.assertEqual( + ["aGuideBuild"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/z-base/common.adoc"], guides_dir, jvm_only=True, + ), + ) + + def test_jvm_only_preserves_transitive_base_impacts_and_deduplication(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + guides_dir = Path(temp_dir) + write_metadata(guides_dir, "root-base", publish=False) + write_metadata(guides_dir, "middle-base", publish=False, base="root-base") + write_metadata( + guides_dir, "python-guide", base="middle-base", languages=["JAVA", "PYTHON"], + apps=[{"name": "default"}], + ) + + self.assertEqual( + ["pythonGuideBuild -x pythonGuideRunPythonTestScript"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/root-base/common.adoc", "guides/python-guide/metadata.json"], + guides_dir, jvm_only=True, + ), + ) + + def test_cli_jvm_only_flag(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + guides_dir = repo / "guides" + guides_dir.mkdir() + write_metadata(guides_dir, "python-guide", languages=["JAVA", "PYTHON"], apps=[{"name": "default"}]) + + for flags, task in ( + ([], "pythonGuideBuild"), + (["--jvm-only"], "pythonGuideBuild -x pythonGuideRunPythonTestScript"), + ): + with self.subTest(flags=flags): + result = subprocess.run( + [sys.executable, str(Path(guide_ci_tasks.__file__).resolve()), + "--repo", str(repo), "--changed-file", "guides/python-guide/metadata.json", *flags], + capture_output=True, text=True, + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual({"group_test_tasks": [task]}, json.loads(result.stdout)) + def write_metadata( guides_dir: Path, @@ -85,6 +261,8 @@ def write_metadata( *, publish: bool = True, base: str | None = None, + languages: list[str] | None = None, + apps: list[dict[str, str]] | None = None, ) -> None: guide_dir = guides_dir / slug guide_dir.mkdir() @@ -93,6 +271,10 @@ def write_metadata( metadata["publish"] = publish if base is not None: metadata["base"] = base + if languages is not None: + metadata["languages"] = languages + if apps is not None: + metadata["apps"] = apps (guide_dir / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") diff --git a/.github/scripts/test_pyronaut_test_functions.py b/.github/scripts/test_pyronaut_test_functions.py new file mode 100644 index 00000000000..1420f5ddf29 --- /dev/null +++ b/.github/scripts/test_pyronaut_test_functions.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +HELPER = Path(__file__).resolve().parents[2] / "buildSrc/src/main/resources/pyronaut-test-functions.sh" +STEPS = ("setup", "install", "validate-config", "process", "test") +HARNESS = r''' +set -e +source "$1" +set_pyronaut_test_resources() { + printf 'setup\n' >> "$COMMAND_LOG" + if [[ "$FAILED_STEP" == "setup" ]]; then return 42; fi +} +pyronaut() { + printf '%s\n' "$1" >> "$COMMAND_LOG" + if [[ "$FAILED_STEP" == "$1" ]]; then return 42; fi +} +run_pyronaut_install() { pyronaut install; } +run_pyronaut_validate_config() { pyronaut validate-config; } +run_pyronaut_process() { pyronaut process; } +run_pyronaut_test() { pyronaut test; } +status=0 +run_pyronaut_tests || status=$? +exit "$status" +''' + + +class PyronautTestFunctionsTest(unittest.TestCase): + def run_helper(self, failed_step: str): + with tempfile.TemporaryDirectory() as directory: + command_log = Path(directory) / "commands.log" + completed = subprocess.run( + ["/bin/bash", "-c", HARNESS, "helper-test", str(HELPER)], + cwd=directory, + env={"PATH": os.defpath, "COMMAND_LOG": str(command_log), "FAILED_STEP": failed_step}, + text=True, capture_output=True, timeout=5, + ) + commands = command_log.read_text().splitlines() if command_log.exists() else [] + return completed, commands + + def check_failure(self, step: str): + completed, commands = self.run_helper(step) + self.assertEqual(42, completed.returncode, f"commands={commands}, stderr={completed.stderr}") + self.assertEqual(list(STEPS[:STEPS.index(step) + 1]), commands) + + def test_setup_failure_stops_install_and_tests(self): + self.check_failure("setup") + + def test_install_failure_stops_validation_and_tests(self): + self.check_failure("install") + + def test_validation_failure_stops_processing_and_tests(self): + self.check_failure("validate-config") + + def test_processing_failure_stops_tests(self): + self.check_failure("process") + + def test_test_failure_is_propagated(self): + self.check_failure("test") + + def test_success_executes_every_step_once(self): + completed, commands = self.run_helper("") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual(list(STEPS), commands) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index cb51dc25724..5fc3083a61a 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -17,6 +17,8 @@ jobs: - uses: actions/checkout@v6.0.2 with: fetch-depth: 0 + - name: Validate guide matrix selector + run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' - name: Generate Guide Test Matrix id: test_matrix_step env: @@ -25,6 +27,7 @@ jobs: DIFF_MODE: ${{ github.event_name == 'pull_request' && 'merge-base' || 'range' }} run: | python3 .github/scripts/guide_ci_tasks.py \ + --jvm-only \ --repo "$GITHUB_WORKSPACE" \ --base "$BASE_SHA" \ --head "$HEAD_SHA" \ diff --git a/.github/workflows/python-guides.yml b/.github/workflows/python-guides.yml new file mode 100644 index 00000000000..b2f2dc852fe --- /dev/null +++ b/.github/workflows/python-guides.yml @@ -0,0 +1,41 @@ +name: Validate Python Guides +on: + push: + branches: + - master + paths: + - '.github/workflows/python-guides.yml' + - 'buildSrc/**' + - 'guides/**' + - 'settings.gradle' + - 'src/docs/common/**' + pull_request: + branches: + - master + paths: + - '.github/workflows/python-guides.yml' + - 'buildSrc/**' + - 'guides/**' + - 'settings.gradle' + - 'src/docs/common/**' + +jobs: + validate: + name: Generate Python Guides + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-java@v5.2.0 + with: + distribution: adopt + java-version: '25' + - uses: actions/cache@v5.0.5 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Validate build infrastructure + run: ./gradlew buildSrc:test --stacktrace + - name: Generate Python guides + run: ./gradlew generateAllPythonGuideTestScripts --stacktrace diff --git a/README.md b/README.md index 910134b540d..e2a7a7eda06 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ To build a single guide, run the dynamic task created by `GuidesPlugin`; convert ./gradlew micronautHttpClientBuild ``` +Python guide test tasks use the installed `pyronaut` CLI on `PATH`; no local SDK +checkout is required. CI can provision the released tools with +[setup-pyronaut](https://github.com/micronaut-projects/setup-pyronaut). +`generateAllPythonGuideTestScripts` generates projects and scripts without running the SDK. + ## Create a new guide For a high-level overview of the guides infrastructure, take a look at this [blog post](https://micronaut.io/2021/04/12/improving-the-micronaut-guides-infrastructure/). diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 44e0ea18e78..6f683898dc8 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -36,7 +36,7 @@ repositories { } ext { - micronautStarterVersion = "5.2.0" + micronautStarterVersion = "5.2.1" } dependencies { diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/GuideAsciidocGenerator.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/GuideAsciidocGenerator.groovy index 2d1f5a3f01d..5ef5baca962 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/GuideAsciidocGenerator.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/GuideAsciidocGenerator.groovy @@ -653,7 +653,7 @@ class GuideAsciidocGenerator { } private static String pythonModuleName(String target) { - if (target.contains('_') || target == target.toLowerCase(Locale.ENGLISH)) { + if (target == target.toLowerCase(Locale.ENGLISH)) { return target } target.replaceAll(/([a-z0-9])([A-Z])/, '$1_$2') diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/GuideProjectGenerator.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/GuideProjectGenerator.groovy index 4284a174924..bb84fb9fc45 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/GuideProjectGenerator.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/GuideProjectGenerator.groovy @@ -20,13 +20,15 @@ import org.gradle.api.GradleException import org.slf4j.Logger import org.slf4j.LoggerFactory +import java.io.IOException import java.nio.file.Files import java.nio.file.FileVisitResult import java.nio.file.Path import java.nio.file.Paths import java.nio.file.SimpleFileVisitor -import java.time.LocalDate import java.nio.file.attribute.BasicFileAttributes +import java.time.LocalDate +import java.util.Locale import java.util.regex.Pattern import static groovy.io.FileType.FILES @@ -109,9 +111,7 @@ class GuideProjectGenerator implements AutoCloseable { } void generateOne(Guide metadata, File inputDir, File outputDir, Language languageFilter) { - if (!outputDir.exists()) { - assert outputDir.mkdir() - } + Files.createDirectories(outputDir.toPath()) JdkVersion javaVersion = Utils.parseJdkVersion() if (metadata.minimumJavaVersion() != null) { @@ -166,13 +166,17 @@ class GuideProjectGenerator implements AutoCloseable { copyGuideSourceFiles(inputDir, destinationPath, appName, guidesOption.language.toString()) + if (buildTool == BuildTool.PYRONAUT && lang == PYTHON) { + ensurePyronautBlockingExecutorConfig(destinationPath.resolve('config/application.toml')) + } + if (app.excludeSource()) { for (String mainSource : app.excludeSource()) { - File f = fileToDelete(destination, GuideAsciidocGenerator.mainPath(appName, mainSource), guidesOption) + File f = fileToDelete(destination, GuideAsciidocGenerator.mainPath(appName, mainSource, guidesOption), guidesOption) if (f.exists()) { f.delete() } - f = fileToDelete(destination, GuideAsciidocGenerator.mainPath(EMPTY_STRING, mainSource), guidesOption) + f = fileToDelete(destination, GuideAsciidocGenerator.mainPath(EMPTY_STRING, mainSource, guidesOption), guidesOption) if (f.exists()) { f.delete() } @@ -181,14 +185,20 @@ class GuideProjectGenerator implements AutoCloseable { if (app.excludeTest()) { for (String testSource : app.excludeTest()) { - File f = fileToDelete(destination, GuideAsciidocGenerator.testPath(appName, testSource, testFramework), guidesOption) + File f = fileToDelete(destination, GuideAsciidocGenerator.testPath(appName, testSource, guidesOption), guidesOption) if (f.exists()) { f.delete() } - f = fileToDelete(destination, GuideAsciidocGenerator.testPath(EMPTY_STRING, testSource, testFramework), guidesOption) + f = fileToDelete(destination, GuideAsciidocGenerator.testPath(EMPTY_STRING, testSource, guidesOption), guidesOption) if (f.exists()) { f.delete() } + if (buildTool == BuildTool.PYRONAUT && lang == PYTHON) { + f = new File(destination, "tests/${pythonTestModuleName(testSource)}.${lang.extension}") + if (f.exists()) { + f.delete() + } + } } } @@ -198,11 +208,41 @@ class GuideProjectGenerator implements AutoCloseable { copyFile(inputDir, destinationRoot, zipInclude) } } + if (buildTool == BuildTool.PYRONAUT && lang == PYTHON) { + removePythonPackageMarkerFiles(destination) + } addLicenses(new File(outputDir.absolutePath, folder)) } } } + private static void removePythonPackageMarkerFiles(File destination) { + Files.walkFileTree(destination.toPath(), new SimpleFileVisitor() { + @Override + FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (attrs.isRegularFile() && file.fileName.toString() == '__init__.py') { + Files.delete(file) + } + FileVisitResult.CONTINUE + } + }) + } + + private static String pythonModuleName(String target) { + if (target == target.toLowerCase(Locale.ENGLISH)) { + return target + } + target.replaceAll(/([a-z0-9])([A-Z])/, '$1_$2') + .replaceAll(/([A-Z]+)([A-Z][a-z])/, '$1_$2') + .toLowerCase(Locale.ENGLISH) + } + + private static String pythonTestModuleName(String target) { + String normalized = target.endsWith('Test') ? target.substring(0, target.length() - 'Test'.length()) : target + normalized = pythonModuleName(normalized) + normalized.startsWith('test_') ? normalized : "test_${normalized}" + } + private static void movePythonResources(Path destinationPath) { movePythonResourceFolder(destinationPath, 'views') movePythonResourceFolder(destinationPath, 'static') @@ -239,14 +279,18 @@ class GuideProjectGenerator implements AutoCloseable { void addLicenses(File folder) { String licenseHeader = licenseHeaderText() - folder.eachFileRecurse (FILES) { file -> - if ( - (file.path.endsWith(EXTENSION_JAVA) || file.path.endsWith(EXTENSION_GROOVY) || file.path.endsWith(EXTENSION_KT)) - && !file.text.contains("Licensed under") - ) { - file.text = licenseHeader + file.text + Files.walkFileTree(folder.toPath(), new SimpleFileVisitor() { + @Override + FileVisitResult visitFile(Path path, BasicFileAttributes attrs) { + File file = path.toFile() + if (attrs.isRegularFile() + && (file.path.endsWith(EXTENSION_JAVA) || file.path.endsWith(EXTENSION_GROOVY) || file.path.endsWith(EXTENSION_KT)) + && !file.text.contains("Licensed under")) { + file.text = licenseHeader + file.text + } + FileVisitResult.CONTINUE } - } + }) } @Memoized @@ -263,27 +307,20 @@ class GuideProjectGenerator implements AutoCloseable { // look for a common 'src' directory shared by multiple languages and copy those files first final String srcFolder = 'src' Path srcPath = Paths.get(inputDir.absolutePath, appName, srcFolder) + Path sourcePath = Paths.get(inputDir.absolutePath, appName, language) if (Files.exists(srcPath)) { - Path resourcesPath = srcPath.resolve('main').resolve('resources') - if (language == PYTHON.toString() && Files.exists(resourcesPath)) { - Path configPath = Paths.get(destinationPath.toString(), 'config') - Files.createDirectories(configPath) - Files.walkFileTree(resourcesPath, new CopyFileVisitor(configPath)) - Path destinationResourcesPath = Paths.get(destinationPath.toString(), srcFolder, 'main', 'resources') - Files.walkFileTree(resourcesPath, new SimpleFileVisitor() { - @Override - FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { - Files.deleteIfExists(destinationResourcesPath.resolve(resourcesPath.relativize(file))) - FileVisitResult.CONTINUE - } - }) + if (language == PYTHON.toString()) { + copySharedPythonResources(srcPath, sourcePath, destinationPath) + Path pythonResources = srcPath.resolve('main').resolve('resources') + Path pythonTestResources = srcPath.resolve('test').resolve('resources') + Path pythonTestResourcesLegacy = srcPath.resolve('test-resources') Files.walkFileTree(srcPath, new CopyFileVisitor(Paths.get(destinationPath.toString(), srcFolder)) { @Override FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { - if (dir.equals(resourcesPath)) { + if (dir.equals(pythonResources) || dir.equals(pythonTestResources) || dir.equals(pythonTestResourcesLegacy)) { return FileVisitResult.SKIP_SUBTREE } - super.preVisitDirectory(dir, attrs) + return super.preVisitDirectory(dir, attrs) } }) } else { @@ -291,7 +328,6 @@ class GuideProjectGenerator implements AutoCloseable { } } - Path sourcePath = Paths.get(inputDir.absolutePath, appName, language) if (!Files.exists(sourcePath)) { sourcePath.toFile().mkdir() } @@ -303,6 +339,80 @@ class GuideProjectGenerator implements AutoCloseable { } } + private static void ensurePyronautBlockingExecutorConfig(Path applicationConfig) { + String config = Files.exists(applicationConfig) ? Files.readString(applicationConfig) : '' + if (config.contains('[micronaut.executors.blocking]')) { + return + } + String suffix = config.isEmpty() || config.endsWith('\n') ? '' : '\n' + Files.createDirectories(applicationConfig.parent) + Files.writeString(applicationConfig, config + suffix + ''' +[micronaut.executors.blocking] +type = 'CACHED' +virtual = false +''') + } + + private static void copySharedPythonResources(Path srcPath, Path sourcePath, Path destinationPath) { + Path pythonConfigPath = sourcePath.resolve('config') + copySharedPythonResourceDirectory(srcPath.resolve('main/resources'), destinationPath.resolve('config'), pythonConfigPath) + Path pythonTestConfigPath = sourcePath.resolve('tests-config') + copySharedPythonResourceDirectory(srcPath.resolve('test/resources'), destinationPath.resolve('tests-config'), pythonTestConfigPath) + copySharedPythonResourceDirectory(srcPath.resolve('test-resources'), destinationPath.resolve('tests-config'), pythonTestConfigPath) + } + + private static void copySharedPythonResourceDirectory(Path resourcePath, Path destinationPath, Path pythonSpecificPath) { + if (!Files.exists(resourcePath)) { + return + } + def paths = Files.walk(resourcePath) + try { + paths.filter(Files::isRegularFile).forEach { Path source -> + Path relative = resourcePath.relativize(source) + if (!GuideUtils.isBootstrapConfiguration(relative) && !isOverriddenByPythonConfig(relative, pythonSpecificPath)) { + Path destination = destinationPath.resolve(relative) + Files.createDirectories(destination.parent) + Files.copy(source, destination, REPLACE_EXISTING) + } + } + } finally { + paths.close() + } + } + + private static boolean isOverriddenByPythonConfig(Path relative, Path pythonSpecificPath) { + if (Files.exists(pythonSpecificPath.resolve(relative))) { + return true + } + if (relative.nameCount != 1 || !Files.isDirectory(pythonSpecificPath)) { + return false + } + String filename = relative.fileName.toString() + int extensionIndex = filename.lastIndexOf('.') + if (extensionIndex < 1) { + return false + } + String basename = filename.substring(0, extensionIndex) + if (!isEnvironmentConfigBasename(basename)) { + return false + } + def paths = Files.list(pythonSpecificPath) + try { + paths.filter(Files::isRegularFile) + .map { Path path -> path.fileName.toString() } + .anyMatch { String name -> name.startsWith("${basename}.") } + } finally { + paths.close() + } + } + + private static boolean isEnvironmentConfigBasename(String basename) { + basename == 'application' || + basename.startsWith('application-') || + basename == 'bootstrap' || + basename.startsWith('bootstrap-') + } + private static File fileToDelete(File destination, String path, GuidesOption guidesOption) { Paths.get(destination.absolutePath, path .replace("@lang@", guidesOption.language.toString()) diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/GuidesPlugin.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/GuidesPlugin.groovy index 1bd550766a8..96a149f8fbf 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/GuidesPlugin.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/GuidesPlugin.groovy @@ -15,6 +15,7 @@ import io.micronaut.guides.tasks.TestScriptRunnerTask import io.micronaut.guides.tasks.TestScriptTask import io.micronaut.guides.tasks.NativeTestScriptRunnerTask import io.micronaut.guides.tasks.NativeTestScriptTask +import io.micronaut.guides.tasks.PythonTestScriptTask import io.micronaut.json.JsonMapper import io.micronaut.starter.options.Language import org.apache.tools.ant.filters.ReplaceTokens @@ -36,6 +37,7 @@ import java.util.stream.Collectors import static io.micronaut.guides.GuideProjectGenerator.DEFAULT_APP_NAME import static io.micronaut.starter.options.BuildTool.MAVEN +import static io.micronaut.starter.options.BuildTool.PYRONAUT @CompileStatic class GuidesPlugin implements Plugin { @@ -50,6 +52,7 @@ class GuidesPlugin implements Plugin { private static final String KEY_WORKFLOW = "workflow" private static final String KEY_WORKFLOW_SNAPSHOT = "workflow-snapshot" private static final String TEST_RUNNER = "test-runner" + private static final String PYTHON_TEST_SCRIPT = "python-test-script" private static final String PYTHON_TEST_RUNNER = "python-test-runner" private static final String KEY_DOC = "doc" private static final String KEY_PYTHON_BUILD = "python-build" @@ -78,32 +81,34 @@ class GuidesPlugin implements Plugin { .filter(guideMetadata -> Utils.process(guideMetadata, false)) .map(metadata -> { String taskSlug = kebabCaseToGradleName(metadata.slug()) + boolean hasApps = !metadata.apps().isEmpty() - TaskProvider githubActionWorkflowTask = registerGenerateGithubActionWorkflow(project, + TaskProvider githubActionWorkflowTask = hasApps ? registerGenerateGithubActionWorkflow(project, metadata, - taskSlug) + taskSlug) : null - TaskProvider githubActionSnapshotWorkflowTask = registerGenerateGithubActionSnapshotWorkflow(project, + TaskProvider githubActionSnapshotWorkflowTask = hasApps ? registerGenerateGithubActionSnapshotWorkflow(project, metadata, - taskSlug) + taskSlug) : null List options = GuideProjectGenerator.guidesOptions(metadata) TaskProvider generateTask = registerGenerateTask(project, metadata, projectGenerator, guidesDir, codeDir, taskSlug) TaskProvider docTask = registerDocTask(project, metadata, guidesDir, generateTask, taskSlug) - List> zippers = options.stream() + List> zippers = hasApps ? options.stream() .map(option -> { registerZipTask(project, metadata, option, generateTask) - }).collect(Collectors.toList()) - TaskProvider zip = registerZipTask(project, taskSlug, metadata, zippers) + }).collect(Collectors.toList()) : [] + TaskProvider zip = hasApps ? registerZipTask(project, taskSlug, metadata, zippers) : null TaskProvider indexTask = registerIndexTask(project, taskSlug, metadata) - TaskProvider testScriptTask = registerTestScriptTask(project, taskSlug, metadata, generateTask) - TaskProvider testScriptRunnerTask = registerTestScriptRunnerTask(project, taskSlug, metadata, testScriptTask) - TaskProvider nativeTestScriptTask = registerNativeTestScriptTask(project, taskSlug, metadata, generateTask) - TaskProvider nativeTestScriptRunnerTask = registerNativeTestScriptRunnerTask(project, taskSlug, metadata, nativeTestScriptTask) + TaskProvider testScriptTask = hasApps ? registerTestScriptTask(project, taskSlug, metadata, generateTask) : null + TaskProvider testScriptRunnerTask = hasApps ? registerTestScriptRunnerTask(project, taskSlug, metadata, testScriptTask) : null + TaskProvider nativeTestScriptTask = hasApps ? registerNativeTestScriptTask(project, taskSlug, metadata, generateTask) : null + TaskProvider nativeTestScriptRunnerTask = hasApps ? registerNativeTestScriptRunnerTask(project, taskSlug, metadata, nativeTestScriptTask) : null - registerGuideBuild(project, taskSlug, metadata, docTask, zip, indexTask, testScriptTask, testScriptRunnerTask, nativeTestScriptTask, nativeTestScriptRunnerTask) TaskProvider pythonBuildTask = null TaskProvider pythonTestRunnerTask = null + TaskProvider pythonTestScriptTask = null + List> guideBuildTasks = [docTask, zip, indexTask, testScriptTask, testScriptRunnerTask, nativeTestScriptTask, nativeTestScriptRunnerTask].findAll { it != null } List languages = options.stream() .map(option -> option.language) .distinct() @@ -111,21 +116,49 @@ class GuidesPlugin implements Plugin { for (Language language : languages) { GuidesOption languageOption = options.find { GuidesOption option -> option.language == language } TaskProvider languageGenerateTask = registerGenerateTask(project, metadata, projectGenerator, guidesDir, codeDir, taskSlug, language) + languageGenerateTask.configure { it.mustRunAfter(generateTask) } TaskProvider languageDocTask = registerDocTask(project, metadata, guidesDir, languageGenerateTask, taskSlug, language) + if (!hasApps) { + TaskProvider languageBuildTask = registerGuideBuildForLanguage(project, taskSlug, language.toString().capitalize(), metadata, languageDocTask) + if (language == Language.PYTHON) { + pythonBuildTask = languageBuildTask + } + continue + } TaskProvider languageZipTask = registerLanguageZipTask(project, taskSlug, metadata, language, languageOption, languageGenerateTask) - TaskProvider languageTestScriptTask = registerTestScriptTask(project, taskSlug, metadata, languageGenerateTask, language) - TaskProvider languageTestScriptRunnerTask = registerTestScriptRunnerTask(project, taskSlug, metadata, languageTestScriptTask, language) - TaskProvider languageBuildTask = registerGuideBuildForLanguage(project, taskSlug, language.toString().capitalize(), metadata, languageDocTask, languageZipTask, languageTestScriptTask, languageTestScriptRunnerTask) if (language == Language.PYTHON) { - pythonBuildTask = languageBuildTask - pythonTestRunnerTask = languageTestScriptRunnerTask as TaskProvider + for (int i = 0; i < options.size(); i++) { + if (options.get(i).language == Language.PYTHON) { + zippers.get(i).configure { it.dependsOn(languageGenerateTask) } + } + } + TaskProvider pythonScript = registerPythonTestScriptTask(project, taskSlug, metadata, languageGenerateTask) + TaskProvider pythonRunner = registerPythonTestScriptRunnerTask( + project, + taskSlug, + metadata, + pythonScript + ) + pythonBuildTask = registerGuideBuildForLanguage(project, taskSlug, language.toString().capitalize(), metadata, languageDocTask, languageZipTask, pythonScript, pythonRunner) + pythonTestRunnerTask = pythonRunner as TaskProvider + pythonTestScriptTask = pythonScript as TaskProvider + guideBuildTasks.add(pythonScript) + guideBuildTasks.add(pythonRunner) + } else { + TaskProvider languageTestScriptTask = registerTestScriptTask(project, taskSlug, metadata, languageGenerateTask, language) + TaskProvider languageTestScriptRunnerTask = registerTestScriptRunnerTask(project, taskSlug, metadata, languageTestScriptTask, language) + registerGuideBuildForLanguage(project, taskSlug, language.toString().capitalize(), metadata, languageDocTask, languageZipTask, languageTestScriptTask, languageTestScriptRunnerTask) } } + registerGuideBuild(project, taskSlug, metadata, guideBuildTasks) Map> taskMap = [(KEY_DOC) : docTask, (KEY_ZIP) : zip, (KEY_WORKFLOW) : githubActionWorkflowTask, (KEY_WORKFLOW_SNAPSHOT): githubActionSnapshotWorkflowTask, (TEST_RUNNER) : testScriptRunnerTask] as Map> + if (pythonTestScriptTask != null) { + taskMap.put(PYTHON_TEST_SCRIPT, pythonTestScriptTask) + } if (pythonBuildTask != null) { taskMap.put(KEY_PYTHON_BUILD, pythonBuildTask) } @@ -139,6 +172,9 @@ class GuidesPlugin implements Plugin { .map(m -> m.get(KEY_DOC)) .toList() as List> + project.tasks.named("asciidoctor").configure { Task it -> + it.mustRunAfter(project.tasks.withType(AsciidocGenerationTask)) + } List> pythonBuildTasks = sampleTasks.stream() .map(m -> m.get(KEY_PYTHON_BUILD)) .filter(task -> task != null) @@ -164,14 +200,14 @@ class GuidesPlugin implements Plugin { project.tasks.register("testsGroup${i + 1}") { Task it -> it.group = 'guides' it.description = "Run group of guide tests" - it.dependsOn(tasks.collect { it[TEST_RUNNER] }) + it.dependsOn(tasks.collect { it[TEST_RUNNER] }.findAll { it != null }) } } project.tasks.register("runAllGuideTests") { Task it -> it.group = 'guides' it.description = 'Runs all Guide test scripts' - it.dependsOn(sampleTasks.stream().map(m -> m.get(TEST_RUNNER)).collect(Collectors.toList())) + it.dependsOn(sampleTasks.stream().map(m -> m.get(TEST_RUNNER)).filter(task -> task != null).collect(Collectors.toList())) } List> pythonTestRunnerTasks = sampleTasks.stream() @@ -179,14 +215,37 @@ class GuidesPlugin implements Plugin { .filter(task -> task != null) .toList() as List> - project.tasks.register("runAllPythonGuidesTests") { Task it -> + if (!pythonTestRunnerTasks.isEmpty()) { + int pythonGroupSize = (pythonTestRunnerTasks.size() / Integer.parseInt(testProps.get("numberOfTestGroups") as String)) + .setScale(0, RoundingMode.UP).toInteger() + pythonTestRunnerTasks.collate(pythonGroupSize, true).eachWithIndex { List> tasks, int i -> + project.tasks.register("pythonTestsGroup${i + 1}") { Task it -> + it.group = 'guides' + it.description = "Run group of Python guide tests" + it.dependsOn(tasks) + } + } + } + + project.tasks.register("runAllPythonGuideTests") { Task it -> it.group = 'guides' it.description = 'Runs all Python Guide test scripts' it.dependsOn(pythonTestRunnerTasks) } + List> pythonTestScriptTasks = sampleTasks.stream() + .map(m -> m.get(PYTHON_TEST_SCRIPT)) + .filter(task -> task != null) + .toList() as List> + + project.tasks.register("generateAllPythonGuideTestScripts") { Task it -> + it.group = 'guides' + it.description = 'Generates every Python guide project and test script without running Pyronaut' + it.dependsOn(pythonTestScriptTasks) + } List> zipTasks = sampleTasks.stream() .map(m -> m.get(KEY_ZIP)) + .filter(task -> task != null) .toList() as List> project.tasks.register("generateCodeZip") { Task it -> @@ -197,9 +256,11 @@ class GuidesPlugin implements Plugin { List> workflowTasks = sampleTasks.stream() .map(m -> m.get(KEY_WORKFLOW)) + .filter(task -> task != null) .toList() as List> workflowTasks.addAll(sampleTasks.stream() .map(m -> m.get(KEY_WORKFLOW_SNAPSHOT)) + .filter(task -> task != null) .toList()) project.tasks.register("generateGithubActionWorkflows") { Task it -> @@ -209,6 +270,7 @@ class GuidesPlugin implements Plugin { } } + /** * https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore */ @@ -279,7 +341,8 @@ class GuidesPlugin implements Plugin { it.metadata = metadata it.guideSlug.set(metadata.slug()) it.metadataFile.set(project.layout.projectDirectory.dir("guides/${metadata.slug()}").file("metadata.json")) - it.scriptFile.set(project.layout.buildDirectory.dir("code/${metadata.slug()}").map(d -> d.file("test.sh"))) + String scriptFileName = language ? "test-${language.toString().toLowerCase()}.sh" : "test.sh" + it.scriptFile.set(project.layout.buildDirectory.dir("code/${metadata.slug()}").map(d -> d.file(scriptFileName))) if (language) { it.language.set(language.name()) } @@ -287,6 +350,22 @@ class GuidesPlugin implements Plugin { } } + private static TaskProvider registerPythonTestScriptTask(Project project, + String taskSlug, + Guide metadata, + TaskProvider generateTask) { + project.tasks.register("${taskSlug}PythonTestScript", PythonTestScriptTask) { PythonTestScriptTask it -> + it.group = "guides ${metadata.slug()}" + it.description = "Create a python-test.sh script for the Pyronaut project generated by ${metadata.slug()}" + it.metadata = metadata + it.guideSlug.set(metadata.slug()) + it.metadataFile.set(project.layout.projectDirectory.dir("guides/${metadata.slug()}").file("metadata.json")) + it.pyronautTestFunctionsFile.set(project.layout.projectDirectory.file("buildSrc/src/main/resources/pyronaut-test-functions.sh")) + it.scriptFile.set(project.layout.buildDirectory.dir("code/${metadata.slug()}").map(d -> d.file("python-test.sh"))) + it.dependsOn(generateTask) + } + } + private static TaskProvider registerNativeTestScriptTask(Project project, String taskSlug, Guide metadata, @@ -359,6 +438,25 @@ class GuidesPlugin implements Plugin { } } + private static TaskProvider registerPythonTestScriptRunnerTask(Project project, + String taskSlug, + Guide metadata, + TaskProvider pythonTestScriptTask) { + project.tasks.register("${taskSlug}RunPythonTestScript", TestScriptRunnerTask) { TestScriptRunnerTask it -> + it.onlyIf { !Utils.skipBecauseOfJavaVersion(metadata) && !Boolean.TRUE.equals(metadata.skipPyronautTests()) } + + Provider codeDirectory = project.layout.buildDirectory.dir("code/${metadata.slug()}") + + it.group = "guides ${metadata.slug()}" + it.description = "Run the Python tests for the Pyronaut project generated by ${metadata.slug()}" + + it.environment.set(metadata.env()) + it.testScript.set(pythonTestScriptTask.flatMap { t -> t.scriptFile }) + it.guideSourceDirectory.set(project.layout.projectDirectory.dir("guides/${metadata.slug()}")) + it.outputFile.set(codeDirectory.map(d -> d.file("python-output.log"))) + } + } + private static TaskProvider registerDocTask(Project project, Guide metadata, Directory guidesDir, @@ -443,21 +541,30 @@ class GuidesPlugin implements Plugin { .filter(option -> option.buildTool == MAVEN) .collect(Collectors.toList()) + List pyronautOptions = options + .stream() + .filter(option -> option.buildTool == PYRONAUT) + .collect(Collectors.toList()) + String gradleProjects = projects(metadata, gradleOptions) String mavenProjects = projects(metadata, mavenOptions) + String pyronautProjects = projects(metadata, pyronautOptions) boolean mavenEnabled = !(CollectionUtils.isEmpty(mavenOptions) || metadata.skipMavenTests()) boolean gradleEnabled = !(CollectionUtils.isEmpty(gradleOptions) || metadata.skipGradleTests()) + boolean pyronautEnabled = !(CollectionUtils.isEmpty(pyronautOptions) || metadata.skipPyronautTests()) [ gradleTask : taskSlug + TASK_SUFFIX_GENERATE_PROJECTS, javaMatrix : javaMatrix(metadata), slug : metadata.slug(), mavenProjects : mavenProjects, gradleProjects: gradleProjects, + pyronautProjects: pyronautProjects, paths : workflowPaths(metadata), mavenEnabled : String.valueOf(mavenEnabled), - gradleEnabled : String.valueOf(gradleEnabled) + gradleEnabled : String.valueOf(gradleEnabled), + pyronautEnabled: String.valueOf(pyronautEnabled) ] as Map } @@ -566,6 +673,10 @@ class GuidesPlugin implements Plugin { if (language) { it.language.set(language.name()) } + List projectOutputDirectories = SampleProjectGenerationTask.outputDirectoryNames(metadata, language) + it.outputDirectories.from(projectOutputDirectories.collect { String outputDirectoryName -> + codeDir.map(outputDirectory -> outputDirectory.dir("${metadata.slug()}/${outputDirectoryName}")) + }) it.guidesGenerator = projectGenerator it.metadata = metadata } @@ -574,7 +685,7 @@ class GuidesPlugin implements Plugin { private static TaskProvider registerGuideBuild(Project project, String taskSlug, Guide metadata, - TaskProvider... dependsOnTasks) { + Collection> dependsOnTasks) { project.tasks.register("${taskSlug}${TASK_SUFFIX_BUILD}") { Task it -> it.group = "guides ${metadata.slug()}" it.dependsOn(dependsOnTasks) diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/IndexGenerator.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/IndexGenerator.groovy index 05ef442310a..dbe8a54df07 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/IndexGenerator.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/IndexGenerator.groovy @@ -456,6 +456,7 @@ class IndexGenerator { String mavenImg = 'Maven' String gradleImg = 'Gradle' String pyronautImg = 'Pyronaut' + boolean hasPython = guidesOptionList.find { GuidesOption option -> option.language == Language.PYTHON } != null String tableHtml = """\ @@ -466,7 +467,9 @@ class IndexGenerator { tableHtml += "" tableHtml += "" tableHtml += "" - tableHtml += "" + if (hasPython) { + tableHtml += "" + } tableHtml += """\ @@ -481,7 +484,9 @@ class IndexGenerator { tableHtml += cell(baseURL, metadata, BuildTool.GRADLE, Language.JAVA, guidesOptionList) tableHtml += cell(baseURL, metadata, BuildTool.GRADLE, Language.KOTLIN, guidesOptionList) tableHtml += cell(baseURL, metadata, BuildTool.GRADLE, Language.GROOVY, guidesOptionList) - tableHtml += cell(baseURL, metadata, BuildTool.GRADLE, Language.PYTHON, guidesOptionList) + if (hasPython) { + tableHtml += "" + } tableHtml += """\ @@ -495,7 +500,9 @@ class IndexGenerator { tableHtml += cell(baseURL, metadata, BuildTool.MAVEN, Language.JAVA, guidesOptionList) tableHtml += cell(baseURL, metadata, BuildTool.MAVEN, Language.KOTLIN, guidesOptionList) tableHtml += cell(baseURL, metadata, BuildTool.MAVEN, Language.GROOVY, guidesOptionList) - tableHtml += cell(baseURL, metadata, BuildTool.MAVEN, Language.PYTHON, guidesOptionList) + if (hasPython) { + tableHtml += "" + } tableHtml += """\ @@ -506,9 +513,7 @@ class IndexGenerator { """ - tableHtml += cell(baseURL, metadata, BuildTool.PYRONAUT, Language.JAVA, guidesOptionList) - tableHtml += cell(baseURL, metadata, BuildTool.PYRONAUT, Language.KOTLIN, guidesOptionList) - tableHtml += cell(baseURL, metadata, BuildTool.PYRONAUT, Language.GROOVY, guidesOptionList) + tableHtml += "" tableHtml += cell(baseURL, metadata, BuildTool.PYRONAUT, Language.PYTHON, guidesOptionList) tableHtml += """\ diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/TestScriptGenerator.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/TestScriptGenerator.groovy index 4638afedbbe..2f0c6f46076 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/TestScriptGenerator.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/TestScriptGenerator.groovy @@ -102,7 +102,7 @@ exit 0 List metadatas, boolean stopIfFailure, Language languageFilter) { - String script = generateScript(metadatas, stopIfFailure, false, languageFilter) + String script = generateScript(metadatas, stopIfFailure, false, false, languageFilter) generateTestScript(output, script) } @@ -113,6 +113,13 @@ exit 0 generateTestScript(output, script, 'native-test.sh') } + static void generatePythonTestScript(File output, + List metadatas, + boolean stopIfFailure) { + String script = generateScript(metadatas, stopIfFailure, false, true) + generateTestScript(output, script, 'python-test.sh') + } + static void generateTestScript(File output, String script, String scriptFileName = "test.sh") { File testScript = new File(output, scriptFileName) testScript.createNewFile() @@ -139,6 +146,7 @@ exit 0 static String generateScript(List metadatas, boolean stopIfFailure, boolean nativeTest = false, + boolean pythonTest = false, Language languageFilter = null) { StringBuilder bashScript = new StringBuilder('''\ #!/usr/bin/env bash @@ -155,9 +163,15 @@ kill_kotlin_daemon () { done } ''') + if (pythonTest) { + bashScript << "\n\n" << pyronautFunctions() + } metadatas.sort { it.slug() } for (Guide metadata : metadatas) { + if (metadata.apps().isEmpty()) { + continue + } List guidesOptionList = GuideProjectGenerator.guidesOptions(metadata) if (languageFilter != null) { guidesOptionList = guidesOptionList.findAll { GuidesOption option -> option.language == languageFilter } @@ -165,11 +179,11 @@ kill_kotlin_daemon () { bashScript << """\ """ for (GuidesOption guidesOption : guidesOptionList) { - String folder = GuideProjectGenerator.folderName(metadata.slug(), guidesOption) - BuildTool buildTool = guidesOption.getBuildTool() - if (buildTool == PYRONAUT && System.getenv('CI') != null) { + if (pythonTest != isPyronautPython(guidesOption)) { continue } + String folder = GuideProjectGenerator.folderName(metadata.slug(), guidesOption) + BuildTool buildTool = guidesOption.getBuildTool() if (metadata.apps().any { it.name() == DEFAULT_APP_NAME } ) { if (GuideUtils.shouldSkip(metadata,buildTool, guidesOption.getLanguage())) { continue @@ -242,9 +256,7 @@ if (noDaemon) { } if (buildTool == PYRONAUT) { bashScript += """\ -pyronaut install || EXIT_STATUS=\$? -pyronaut validate-config || EXIT_STATUS=\$? -pyronaut test || EXIT_STATUS=\$? +run_pyronaut_tests || EXIT_STATUS=\$? """ } else if (nativeTest) { bashScript += """\ @@ -283,4 +295,18 @@ EXIT_STATUS=0 bashScript } + + static String pyronautFunctions() { + java.io.InputStream stream = TestScriptGenerator.class.getResourceAsStream('/pyronaut-test-functions.sh') + if (stream == null) { + throw new IllegalStateException('Missing pyronaut-test-functions.sh resource') + } + try (stream) { + new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).stripTrailing() + '\n' + } + } + + private static boolean isPyronautPython(GuidesOption guidesOption) { + guidesOption.buildTool == PYRONAUT && guidesOption.language == Language.PYTHON + } } diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/tasks/PythonTestScriptTask.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/tasks/PythonTestScriptTask.groovy new file mode 100644 index 00000000000..27e371c7cdc --- /dev/null +++ b/buildSrc/src/main/groovy/io/micronaut/guides/tasks/PythonTestScriptTask.groovy @@ -0,0 +1,49 @@ +package io.micronaut.guides.tasks + +import groovy.transform.CompileStatic +import io.micronaut.guides.TestScriptGenerator +import io.micronaut.guides.core.Guide +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.TaskAction + +import static org.gradle.api.tasks.PathSensitivity.RELATIVE + +@CompileStatic +@CacheableTask +abstract class PythonTestScriptTask extends DefaultTask { + + @Input + abstract Property getGuideSlug() + + @InputFile + @PathSensitive(RELATIVE) + abstract RegularFileProperty getMetadataFile() + + @InputFile + @PathSensitive(RELATIVE) + abstract RegularFileProperty getPyronautTestFunctionsFile() + + @Internal + Guide metadata + + @Input + String getMergedMetadata() { + metadata.toString() + } + + @OutputFile + abstract RegularFileProperty getScriptFile() + + @TaskAction + def perform() { + TestScriptGenerator.generatePythonTestScript(scriptFile.get().asFile.parentFile, [metadata], false) + } +} diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/tasks/SampleProjectGenerationTask.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/tasks/SampleProjectGenerationTask.groovy index 4d4da9e33c3..df4b3f8d1a6 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/tasks/SampleProjectGenerationTask.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/tasks/SampleProjectGenerationTask.groovy @@ -3,8 +3,10 @@ package io.micronaut.guides.tasks import groovy.transform.CompileStatic import io.micronaut.guides.GuideProjectGenerator import io.micronaut.guides.core.Guide +import io.micronaut.guides.core.GuidesOption import io.micronaut.starter.options.Language import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.CacheableTask @@ -12,7 +14,7 @@ import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputDirectories import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.TaskAction @@ -22,6 +24,15 @@ import static org.gradle.api.tasks.PathSensitivity.RELATIVE @CacheableTask abstract class SampleProjectGenerationTask extends DefaultTask { + static List outputDirectoryNames(Guide metadata, Language language) { + if (metadata.apps().isEmpty()) { + return [] + } + GuideProjectGenerator.guidesOptions(metadata) + .findAll { GuidesOption option -> language == null || option.language == language } + .collect { GuidesOption option -> GuideProjectGenerator.folderName(metadata.slug(), option) } + } + @Internal GuideProjectGenerator guidesGenerator @@ -44,12 +55,21 @@ abstract class SampleProjectGenerationTask extends DefaultTask { @PathSensitive(RELATIVE) abstract DirectoryProperty getBaseInputDirectory() - @OutputDirectory + @Internal abstract DirectoryProperty getOutputDir() + @OutputDirectories + abstract ConfigurableFileCollection getOutputDirectories() + @TaskAction def perform() { + File outputDirectory = outputDir.get().asFile Language languageFilter = language.isPresent() ? Language.valueOf(language.get()) : null - guidesGenerator.generateOne(metadata, inputDirectory.get().asFile, outputDir.get().asFile, languageFilter) + if (languageFilter == null) { + project.delete(outputDirectory) + } else { + project.delete(outputDirectories) + } + guidesGenerator.generateOne(metadata, inputDirectory.get().asFile, outputDirectory, languageFilter) } } diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/tasks/TestScriptRunnerWorkAction.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/tasks/TestScriptRunnerWorkAction.groovy index 95674882937..76f1adef558 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/tasks/TestScriptRunnerWorkAction.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/tasks/TestScriptRunnerWorkAction.groovy @@ -24,7 +24,7 @@ abstract class TestScriptRunnerWorkAction implements WorkAction pythonFeatures ) { + public App( + @NonNull String name, + @Nullable String packageName, + @Nullable ApplicationType applicationType, + @Nullable String framework, + @Nullable List features, + @Nullable List invisibleFeatures, + @Nullable List kotlinFeatures, + @Nullable List javaFeatures, + @Nullable List groovyFeatures, + @Nullable TestFramework testFramework, + @Nullable List excludeTest, + @Nullable List excludeSource, + @Nullable Boolean validateLicense) { + this(name, packageName, applicationType, framework, features, invisibleFeatures, kotlinFeatures, javaFeatures, + groovyFeatures, null, testFramework, excludeTest, excludeSource, validateLicense, null); + } + + public App( + @NonNull String name, + @Nullable String packageName, + @Nullable ApplicationType applicationType, + @Nullable String framework, + @Nullable List features, + @Nullable List invisibleFeatures, + @Nullable List kotlinFeatures, + @Nullable List javaFeatures, + @Nullable List groovyFeatures, + @Nullable List jvmFeatures, + @Nullable TestFramework testFramework, + @Nullable List excludeTest, + @Nullable List excludeSource, + @Nullable Boolean validateLicense) { + this(name, packageName, applicationType, framework, features, invisibleFeatures, kotlinFeatures, javaFeatures, + groovyFeatures, jvmFeatures, testFramework, excludeTest, excludeSource, validateLicense, null); + } } - diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/DefaultFilesTransferUtility.java b/buildSrc/src/main/java/io/micronaut/guides/core/DefaultFilesTransferUtility.java index 1c3fe9cf074..73bed2c2c0d 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/DefaultFilesTransferUtility.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/DefaultFilesTransferUtility.java @@ -10,6 +10,7 @@ import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; @@ -19,8 +20,11 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; import static io.micronaut.core.util.StringUtils.EMPTY_STRING; +import static io.micronaut.starter.options.BuildTool.PYRONAUT; +import static io.micronaut.starter.options.Language.PYTHON; @Singleton public class DefaultFilesTransferUtility implements FilesTransferUtility { @@ -48,30 +52,23 @@ private static boolean fileContainsText(File file, String text) { } private static void copyGuideSourceFiles(File inputDir, Path destinationPath, - String appName, String language, + String appName, Language language, boolean ignoreMissingDirectories) throws IOException { // look for a common 'src' directory shared by multiple languages and copy those files first final String srcFolder = "src"; Path srcPath = Paths.get(inputDir.getAbsolutePath(), appName, srcFolder); + Path sourcePath = Paths.get(inputDir.getAbsolutePath(), appName, language.toString()); if (Files.exists(srcPath)) { - Path resourcesPath = srcPath.resolve("main").resolve("resources"); - if (Language.PYTHON.toString().equals(language) && Files.exists(resourcesPath)) { - Path configPath = Paths.get(destinationPath.toString(), "config"); - Files.createDirectories(configPath); - Files.walkFileTree(resourcesPath, new CopyFileVisitor(configPath)); - Path destinationResourcesPath = Paths.get(destinationPath.toString(), srcFolder, "main", "resources"); - Files.walkFileTree(resourcesPath, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - Files.deleteIfExists(destinationResourcesPath.resolve(resourcesPath.relativize(file))); - return FileVisitResult.CONTINUE; - } - }); + if (language == PYTHON) { + copySharedPythonResources(srcPath, sourcePath, destinationPath); + Path pythonResources = srcPath.resolve("main").resolve("resources"); + Path pythonTestResources = srcPath.resolve("test").resolve("resources"); + Path pythonTestResourcesLegacy = srcPath.resolve("test-resources"); Files.walkFileTree(srcPath, new CopyFileVisitor(Paths.get(destinationPath.toString(), srcFolder)) { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - if (dir.equals(resourcesPath)) { + if (dir.equals(pythonResources) || dir.equals(pythonTestResources) || dir.equals(pythonTestResourcesLegacy)) { return FileVisitResult.SKIP_SUBTREE; } return super.preVisitDirectory(dir, attrs); @@ -82,7 +79,6 @@ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) th } } - Path sourcePath = Paths.get(inputDir.getAbsolutePath(), appName, language); if (!Files.exists(sourcePath)) { sourcePath.toFile().mkdir(); } @@ -94,6 +90,71 @@ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) th } } + private static void copySharedPythonResources(Path srcPath, Path sourcePath, Path destinationPath) throws IOException { + Path pythonConfigPath = sourcePath.resolve("config"); + copySharedPythonResourceDirectory(srcPath.resolve("main/resources"), destinationPath.resolve("config"), pythonConfigPath); + Path pythonTestConfigPath = sourcePath.resolve("tests-config"); + copySharedPythonResourceDirectory(srcPath.resolve("test/resources"), destinationPath.resolve("tests-config"), pythonTestConfigPath); + copySharedPythonResourceDirectory(srcPath.resolve("test-resources"), destinationPath.resolve("tests-config"), pythonTestConfigPath); + } + + private static void copySharedPythonResourceDirectory(Path resourcePath, Path destinationPath, Path pythonSpecificPath) throws IOException { + if (!Files.exists(resourcePath)) { + return; + } + try (Stream paths = Files.walk(resourcePath)) { + paths.filter(Files::isRegularFile).forEach(source -> { + try { + Path relative = resourcePath.relativize(source); + // Python samples use application configuration and native config imports. + if (GuideUtils.isBootstrapConfiguration(relative)) { + return; + } + if (isOverriddenByPythonConfig(relative, pythonSpecificPath)) { + return; + } + Path destination = destinationPath.resolve(relative); + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + + private static boolean isOverriddenByPythonConfig(Path relative, Path pythonSpecificPath) throws IOException { + if (Files.exists(pythonSpecificPath.resolve(relative))) { + return true; + } + if (relative.getNameCount() != 1 || !Files.isDirectory(pythonSpecificPath)) { + return false; + } + String filename = relative.getFileName().toString(); + int extensionIndex = filename.lastIndexOf('.'); + if (extensionIndex < 1) { + return false; + } + String basename = filename.substring(0, extensionIndex); + if (!isEnvironmentConfigBasename(basename)) { + return false; + } + try (Stream paths = Files.list(pythonSpecificPath)) { + return paths.filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .anyMatch(name -> name.startsWith(basename + ".")); + } + } + + private static boolean isEnvironmentConfigBasename(String basename) { + return basename.equals("application") + || basename.startsWith("application-") + || basename.equals("bootstrap") + || basename.startsWith("bootstrap-"); + } + private static File fileToDelete(File destination, String path) { return Paths.get(destination.getAbsolutePath(), path).toFile(); } @@ -126,10 +187,10 @@ public void transferFiles(@NotNull @NonNull File inputDirectory, @NotNull @NonNu if (guide.base() != null) { File baseDir = new File(inputDirectory.getParentFile(), guide.base()); - copyGuideSourceFiles(baseDir, destinationPath, appName, guidesOption.getLanguage().toString(), true); + copyGuideSourceFiles(baseDir, destinationPath, appName, guidesOption.getLanguage(), true); } - copyGuideSourceFiles(inputDirectory, destinationPath, appName, guidesOption.getLanguage().toString(), false); + copyGuideSourceFiles(inputDirectory, destinationPath, appName, guidesOption.getLanguage(), false); if (app.excludeSource() != null) { for (String mainSource : app.excludeSource()) { @@ -154,6 +215,12 @@ public void transferFiles(@NotNull @NonNull File inputDirectory, @NotNull @NonNu if (f.exists()) { f.delete(); } + if (guidesOption.getBuildTool() == PYRONAUT && guidesOption.getLanguage() == PYTHON) { + f = new File(destination, "tests/" + MacroUtils.pythonTestModuleName(testSource) + "." + guidesOption.getLanguage().getExtension()); + if (f.exists()) { + f.delete(); + } + } } } diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/DefaultGuideParser.java b/buildSrc/src/main/java/io/micronaut/guides/core/DefaultGuideParser.java index 85668b70b71..7175a2fc6e9 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/DefaultGuideParser.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/DefaultGuideParser.java @@ -19,6 +19,8 @@ import java.util.*; import static io.micronaut.guides.core.GuideUtils.mergeMetadataList; +import static io.micronaut.starter.options.BuildTool.PYRONAUT; +import static io.micronaut.starter.options.Language.PYTHON; @Singleton public class DefaultGuideParser implements GuideParser { @@ -106,9 +108,19 @@ public Optional parseGuideMetadata(@NonNull @NotNull File guidesDir, @Non app.testFramework(), app.excludeTest(), app.excludeSource(), - app.validateLicense() + app.validateLicense(), + app.pythonFeatures() != null ? app.pythonFeatures() : new ArrayList<>() )); } + List languages = raw.languages() != null + ? new ArrayList<>(raw.languages()) + : new ArrayList<>(List.of(Language.JAVA, Language.GROOVY, Language.KOTLIN)); + List buildTools = raw.buildTools() != null + ? new ArrayList<>(raw.buildTools()) + : new ArrayList<>(List.of(BuildTool.GRADLE, BuildTool.MAVEN)); + if (languages.contains(PYTHON) && !buildTools.contains(PYRONAUT)) { + buildTools.add(PYRONAUT); + } return Optional.of(new Guide( raw.title(), @@ -119,19 +131,20 @@ public Optional parseGuideMetadata(@NonNull @NotNull File guidesDir, @Non raw.minimumJavaVersion(), raw.maximumJavaVersion(), raw.cloud(), - raw.skipGradleTests(), - raw.skipMavenTests(), + Boolean.TRUE.equals(raw.skipGradleTests()), + Boolean.TRUE.equals(raw.skipMavenTests()), publish ? guidesDir.getName() + ".adoc" : null, - raw.languages() != null ? raw.languages() : List.of(Language.JAVA, Language.GROOVY, Language.KOTLIN), + languages, raw.tags() != null ? raw.tags() : Collections.emptyList(), - raw.buildTools() != null ? raw.buildTools() : List.of(BuildTool.GRADLE, BuildTool.MAVEN), + buildTools, raw.testFramework(), raw.zipIncludes() != null ? raw.zipIncludes() : new ArrayList<>(), guidesDir.getName(), publish, raw.base(), raw.env() != null ? raw.env() : new HashMap<>(), - apps + apps, + Boolean.TRUE.equals(raw.skipPyronautTests()) )); } } diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/DefaultGuideProjectGenerator.java b/buildSrc/src/main/java/io/micronaut/guides/core/DefaultGuideProjectGenerator.java index ce493ddaaf9..45547640165 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/DefaultGuideProjectGenerator.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/DefaultGuideProjectGenerator.java @@ -24,16 +24,21 @@ import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.stream.Stream; import static io.micronaut.core.util.StringUtils.EMPTY_STRING; import static io.micronaut.http.HttpStatus.BAD_REQUEST; import static io.micronaut.starter.options.BuildTool.GRADLE; +import static io.micronaut.starter.options.BuildTool.PYRONAUT; import static io.micronaut.starter.options.JdkVersion.JDK_25; +import static io.micronaut.starter.options.Language.PYTHON; @Singleton public class DefaultGuideProjectGenerator implements GuideProjectGenerator { @@ -114,12 +119,31 @@ public void generate(@NonNull File outputDirectory, generatorContext.getProject(), new FileSystemOutputHandler(destination, ConsoleOutput.NOOP), generatorContext); + if (guidesOption.getBuildTool() == PYRONAUT && guidesOption.getLanguage() == PYTHON) { + removePythonPackageMarkerFiles(destination); + } } catch (Exception e) { LOG.error("Error generating application: " + e.getMessage(), e); throw new IOException(e.getMessage(), e); } } + private static void removePythonPackageMarkerFiles(File destination) throws IOException { + try (Stream paths = Files.walk(destination.toPath())) { + paths.filter(Files::isRegularFile) + .filter(path -> "__init__.py".equals(path.getFileName().toString())) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + private GeneratorContext createProjectGeneratorContext( ApplicationType type, @Pattern(regexp = "[\\w\\d-_\\.]+") String packageAndName, diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/DefaultTestScriptGenerator.java b/buildSrc/src/main/java/io/micronaut/guides/core/DefaultTestScriptGenerator.java index 4745db2126b..8814a7d823a 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/DefaultTestScriptGenerator.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/DefaultTestScriptGenerator.java @@ -10,6 +10,9 @@ import org.slf4j.LoggerFactory; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -80,13 +83,7 @@ private static String scriptForFolder(String nestedFolder, } if (buildTool == PYRONAUT) { - bashScript.append( - """ - pyronaut install || EXIT_STATUS=$? - pyronaut validate-config || EXIT_STATUS=$? - pyronaut test || EXIT_STATUS=$? - """ - ); + bashScript.append("run_pyronaut_tests || EXIT_STATUS=$?\n"); } else if (nativeTest) { bashScript.append(String.format( "%s || EXIT_STATUS=$?\n", @@ -181,6 +178,11 @@ public String generateTestScript(@NonNull @NotNull List metadatas) { return generateScript(metadatas, false, false); } + @Override + public String generatePythonTestScript(@NonNull @NotNull List metadatas) { + return generateScript(metadatas, false, false, true); + } + public String generateScript(File guidesFolder, String metadataConfigName, boolean stopIfFailure, @@ -203,13 +205,20 @@ public String generateScript(File guidesFolder, public String generateScript(List metadatas, boolean stopIfFailure, boolean nativeTest) { + return generateScript(metadatas, stopIfFailure, nativeTest, false); + } + + public String generateScript(List metadatas, + boolean stopIfFailure, + boolean nativeTest, + boolean pythonTest) { StringBuilder bashScript = new StringBuilder(""" #!/usr/bin/env bash set -e - + FAILED_PROJECTS=() EXIT_STATUS=0 - + kill_kotlin_daemon () { echo "Killing KotlinCompile daemon to pick up fresh properties (due to kapt and java > 17)" for daemon in $(jps | grep KotlinCompile | cut -d' ' -f1); do @@ -217,12 +226,21 @@ public String generateScript(List metadatas, kill -9 $daemon done }"""); + if (pythonTest) { + bashScript.append("\n\n").append(pyronautFunctions()); + } metadatas.sort(Comparator.comparing(Guide::slug)); for (Guide metadata : metadatas) { + if (metadata.apps().isEmpty()) { + continue; + } List guidesOptionList = GuideGenerationUtils.guidesOptions(metadata, LOG); bashScript.append("\n"); for (GuidesOption guidesOption : guidesOptionList) { + if (pythonTest != isPyronautPython(guidesOption)) { + continue; + } String folder = MacroUtils.getSourceDir(metadata.slug(), guidesOption); BuildTool buildTool = guidesOption.getBuildTool(); if (metadata.apps().stream().anyMatch(app -> app.name().equals(guidesConfiguration.getDefaultAppName()))) { @@ -271,10 +289,25 @@ public String generateScript(List metadatas, else exit 0 fi - + """); } return bashScript.toString(); } + + private static String pyronautFunctions() { + try (InputStream stream = DefaultTestScriptGenerator.class.getResourceAsStream("/pyronaut-test-functions.sh")) { + if (stream == null) { + throw new IllegalStateException("Missing pyronaut-test-functions.sh resource"); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8).stripTrailing() + "\n"; + } catch (IOException e) { + throw new IllegalStateException("Unable to read pyronaut-test-functions.sh resource", e); + } + } + + private static boolean isPyronautPython(GuidesOption guidesOption) { + return guidesOption.getBuildTool() == PYRONAUT && guidesOption.getLanguage() == Language.PYTHON; + } } diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/DependencyLines.java b/buildSrc/src/main/java/io/micronaut/guides/core/DependencyLines.java index a70fae27d5d..10f6d9cad46 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/DependencyLines.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/DependencyLines.java @@ -8,6 +8,7 @@ import static io.micronaut.starter.options.BuildTool.GRADLE; import static io.micronaut.starter.options.BuildTool.MAVEN; +import static io.micronaut.starter.options.BuildTool.PYRONAUT; import static io.micronaut.starter.options.Language.GROOVY; import static io.micronaut.starter.options.Language.KOTLIN; @@ -58,15 +59,29 @@ private static String toGradleScope(Map attributes, Language lan } } + private static String toPyronautScope(Map attributes) { + String s = attributes.get("scope"); + if (s == null) { + return "runtime"; + } + return switch (s) { + case "annotationProcessor", "compileOnly" -> "build"; + case "testCompile", "test", "testRuntimeOnly", "testImplementation", "testCompileOnly", "testAnnotationProcessor" -> "test"; + default -> "runtime"; + }; + } + public static List asciidoc(String line, BuildTool buildTool, Language language) { return asciidoc(Collections.singletonList(line), buildTool, language); } public static List asciidoc(List lines, BuildTool buildTool, Language language) { + if (buildTool == PYRONAUT) { + return pyronautAsciidoc(lines); + } if (buildTool != GRADLE && buildTool != MAVEN) { return List.of(); } - List dependencyLines = new ArrayList<>(); // Open Asciidoctor code block @@ -154,6 +169,52 @@ public static List asciidoc(List lines, BuildTool buildTool, Lan return dependencyLines; } + private static List pyronautAsciidoc(List lines) { + Map> dependencies = new LinkedHashMap<>(); + dependencies.put("runtime", new ArrayList<>()); + dependencies.put("build", new ArrayList<>()); + dependencies.put("test", new ArrayList<>()); + + for (String line : lines) { + String artifactId = line.substring("dependency:".length(), line.indexOf("[")); + Map attributes = new HashMap<>(); + String attributesStr = line.substring(line.indexOf("[") + "[".length(), line.indexOf("]")); + String[] attrs = attributesStr.split(","); + for (String att : attrs) { + String[] keyValues = att.split("="); + if (keyValues.length == 2) { + attributes.put(keyValues[0], keyValues[1]); + } + } + + String groupId = attributes.getOrDefault("groupId", "io.micronaut"); + if ("true".equalsIgnoreCase(attributes.get("pom"))) { + throw new IllegalArgumentException("Python dependency macros do not support pom=true; configure dependency management explicitly in pyproject.toml."); + } + String version = attributes.get("version"); + String coordinate = groupId + ':' + artifactId + (version != null ? ':' + version : ""); + String callout = extractCallout(attributes); + String tomlCallout = callout.isEmpty() ? "" : " #" + callout.substring(" //".length()); + dependencies.get(toPyronautScope(attributes)).add("\"" + coordinate + "\"," + tomlCallout); + } + + List dependencyLines = new ArrayList<>(); + dependencyLines.add("[source, toml]"); + dependencyLines.add(".pyproject.toml"); + dependencyLines.add("----"); + dependencyLines.add("[tool.pyronaut.dependencies]"); + for (Map.Entry> entry : dependencies.entrySet()) { + if (entry.getValue().isEmpty()) { + continue; + } + dependencyLines.add(entry.getKey() + " = ["); + entry.getValue().forEach(value -> dependencyLines.add(" " + value)); + dependencyLines.add("]"); + } + dependencyLines.add("----"); + return dependencyLines; + } + private static String extractCallout(Map attributes) { String callout = attributes.getOrDefault("callout", null); return callout != null ? " // <" + callout + ">" : ""; diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/Guide.java b/buildSrc/src/main/java/io/micronaut/guides/core/Guide.java index b9053ec7ac1..b40b7e94f03 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/Guide.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/Guide.java @@ -38,7 +38,8 @@ * @param publish Whether the guide should be published, it defaults to true. You can set it to false for draft or base guides * @param base Defaults to null; if set, indicates directory name of the base guide to copy before copying the current one * @param env The guide's environment variables - * @param apps Applications created for the guide + * @param apps Applications created for the guide; an empty list denotes a documentation-only guide with no generated application + * @param skipPyronautTests Set it to true to skip running the Pyronaut application tests for the guide */ @JsonSchema @Serdeable @@ -111,8 +112,38 @@ public record Guide( @Nullable Map env, - @NotEmpty + @NotNull @NonNull - List apps + List apps, + + @JsonProperty(defaultValue = StringUtils.FALSE) + @Nullable + Boolean skipPyronautTests ) { + public Guide( + @NonNull String title, + @NonNull String intro, + @NonNull List authors, + @NonNull List categories, + @NonNull LocalDate publicationDate, + @Nullable Integer minimumJavaVersion, + @Nullable Integer maximumJavaVersion, + @Nullable Cloud cloud, + @Nullable Boolean skipGradleTests, + @Nullable Boolean skipMavenTests, + @Nullable String asciidoctor, + @Nullable List languages, + @Nullable List tags, + @Nullable List buildTools, + @Nullable TestFramework testFramework, + @Nullable List zipIncludes, + @Nullable String slug, + @Nullable Boolean publish, + @Nullable String base, + @Nullable Map env, + @NonNull List apps) { + this(title, intro, authors, categories, publicationDate, minimumJavaVersion, maximumJavaVersion, cloud, + skipGradleTests, skipMavenTests, asciidoctor, languages, tags, buildTools, testFramework, zipIncludes, + slug, publish, base, env, apps, false); + } } diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/GuideGenerationUtils.java b/buildSrc/src/main/java/io/micronaut/guides/core/GuideGenerationUtils.java index d4bb1bd24ba..84c1ae94d70 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/GuideGenerationUtils.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/GuideGenerationUtils.java @@ -60,6 +60,17 @@ static String pathByFolder(@NonNull String appName, @NonNull GuidesOption option, @NonNull GuidesConfiguration configuration) { String module = StringUtils.isNotEmpty(appName) ? appName + "/" : ""; + if (MacroUtils.isPyronautPython(option)) { + String sourceFolder = folder.equals("test") ? "tests" : "src"; + String target = folder.equals("test") + ? MacroUtils.pythonTestModuleName(fileName) + : MacroUtils.pythonModuleName(fileName); + Path path = Path.of(module, + sourceFolder, + configuration.getPackageName().replace(".", "/"), + target + "." + option.getLanguage().getExtension()); + return path.toString(); + } Path path = Path.of(module, "src", folder, diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/GuideUtils.java b/buildSrc/src/main/java/io/micronaut/guides/core/GuideUtils.java index 661298b0a5d..656f75dbc4c 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/GuideUtils.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/GuideUtils.java @@ -5,9 +5,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.file.Path; import java.util.*; import java.util.stream.Collectors; +import static io.micronaut.starter.options.BuildTool.PYRONAUT; +import static io.micronaut.starter.options.Language.PYTHON; + public final class GuideUtils { private static final Logger LOG = LoggerFactory.getLogger(GuideUtils.class); @@ -20,6 +24,16 @@ public final class GuideUtils { private GuideUtils() { } + /** + * Identifies root configuration files that Python samples must not inherit from JVM resources. + * @param relative The resource's path relative to its configuration directory + * @return Whether this is a bootstrap configuration file + */ + public static boolean isBootstrapConfiguration(Path relative) { + return relative.getNameCount() == 1 + && relative.getFileName().toString().matches("bootstrap(?:-[^.]+)?\\.(properties|yml|yaml|json|toml)"); + } + public static List getTags(Guide guide) { Set tagsList = new HashSet<>(); if (guide.tags() != null) { @@ -32,6 +46,7 @@ public static List getTags(Guide guide) { addAllSafe(allFeatures, app.javaFeatures()); addAllSafe(allFeatures, app.kotlinFeatures()); addAllSafe(allFeatures, app.groovyFeatures()); + addAllSafe(allFeatures, app.pythonFeatures()); for (String featureName : allFeatures) { String tagToAdd = featureName; for (String prefix : FEATURES_PREFIXES) { @@ -57,6 +72,9 @@ public static List getAppFeatures(App app, Language language) { if (language == Language.GROOVY) { return mergeLists(app.features(), getAppInvisibleFeatures(app), app.jvmFeatures(), app.groovyFeatures()); } + if (language == Language.PYTHON) { + return mergeLists(app.features(), getAppInvisibleFeatures(app), app.pythonFeatures()); + } return mergeLists(app.features(), getAppInvisibleFeatures(app)); } @@ -80,6 +98,9 @@ public static List getAppVisibleFeatures(App app, Language language) { if (language == Language.GROOVY) { return mergeLists(app.features(), app.jvmFeatures(), app.groovyFeatures()); } + if (language == Language.PYTHON) { + return mergeLists(app.features(), app.pythonFeatures()); + } return app.features(); } @@ -100,6 +121,9 @@ public static boolean shouldSkip(Guide guide, BuildTool buildTool, Language lang if (buildTool == BuildTool.MAVEN) { return guide.skipMavenTests(); } + if (buildTool == BuildTool.PYRONAUT) { + return guide.skipPyronautTests(); + } return false; } @@ -109,6 +133,21 @@ public static Set getFrameworks(Guide guide) { } public static Guide merge(Guide base, Guide guide) { + List languages = new ArrayList<>(); + if (guide.languages() != null) { + languages.addAll(guide.languages()); + } else if (base.languages() != null) { + languages.addAll(base.languages()); + } + List buildTools = new ArrayList<>(); + if (guide.buildTools() != null) { + buildTools.addAll(guide.buildTools()); + } else if (base.buildTools() != null) { + buildTools.addAll(base.buildTools()); + } + if (languages.contains(PYTHON) && !buildTools.contains(PYRONAUT)) { + buildTools.add(PYRONAUT); + } return new Guide( guide.title() == null ? base.title() : guide.title(), guide.intro() == null ? base.intro() : guide.intro(), @@ -121,16 +160,17 @@ public static Guide merge(Guide base, Guide guide) { base.skipGradleTests() || guide.skipGradleTests(), base.skipMavenTests() || guide.skipMavenTests(), guide.asciidoctor(), - guide.languages() == null ? base.languages() : guide.languages(), + languages, mergeLists(GuideUtils.getTags(base), GuideUtils.getTags(guide)), - guide.buildTools() == null ? base.buildTools() : guide.buildTools(), + buildTools, guide.testFramework() == null ? base.testFramework() : guide.testFramework(), guide.zipIncludes(), guide.slug(), guide.publish(), guide.base(), guide.env() == null ? base.env() : guide.env(), - mergeApps(base.apps(), guide.apps()) + mergeApps(base.apps(), guide.apps()), + Boolean.TRUE.equals(base.skipPyronautTests()) || Boolean.TRUE.equals(guide.skipPyronautTests()) ); } @@ -175,7 +215,8 @@ private static List mergeApps(List base, List guide) { guideApp.testFramework(), guideApp.excludeTest(), guideApp.excludeSource(), - baseApp.validateLicense() + baseApp.validateLicense(), + mergeLists(guideApp.pythonFeatures(), baseApp.pythonFeatures()) ); merged.add(mergedApp); } diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/MacroUtils.java b/buildSrc/src/main/java/io/micronaut/guides/core/MacroUtils.java index 3477e818522..a73e3ba5c7a 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/MacroUtils.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/MacroUtils.java @@ -1,10 +1,13 @@ package io.micronaut.guides.core; import io.micronaut.core.annotation.NonNull; +import io.micronaut.starter.options.BuildTool; +import io.micronaut.starter.options.Language; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Stack; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -18,6 +21,25 @@ static String getSourceDir(@NonNull String slug, @NonNull GuidesOption option) { return slug + "-" + option.getBuildTool() + "-" + option.getLanguage(); } + static boolean isPyronautPython(@NonNull GuidesOption option) { + return option.getBuildTool() == BuildTool.PYRONAUT && option.getLanguage() == Language.PYTHON; + } + + static String pythonModuleName(@NonNull String target) { + if (target.equals(target.toLowerCase(Locale.ROOT))) { + return target; + } + return target.replaceAll("([a-z0-9])([A-Z])", "$1_$2") + .replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2") + .toLowerCase(Locale.ROOT); + } + + static String pythonTestModuleName(@NonNull String target) { + String normalized = target.endsWith("Test") ? target.substring(0, target.length() - "Test".length()) : target; + normalized = pythonModuleName(normalized); + return normalized.startsWith("test_") ? normalized : "test_" + normalized; + } + static List findMacroLines(@NonNull String str, @NonNull String macro) { return str.lines() .filter(line -> line.startsWith(macro + ":")) diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/SourceBlockMacroSubstitution.java b/buildSrc/src/main/java/io/micronaut/guides/core/SourceBlockMacroSubstitution.java index 720b968ace6..94b5a179c83 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/SourceBlockMacroSubstitution.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/SourceBlockMacroSubstitution.java @@ -60,6 +60,7 @@ public String substitute(String str, Guide guide, GuidesOption option) { language = arr[arr.length - 1]; language = resolveAsciidoctorLanguage(language); } else { + condensedTarget = sourceTarget(condensedTarget, getClasspath(), option); condensedTarget = condensedTarget + "." + extension; } @@ -68,7 +69,7 @@ public String substitute(String str, Guide guide, GuidesOption option) { IncludeDirective.Builder includeDirectiveBuilder = IncludeDirective.builder().attributes(asciidocMacro.attributes()) .target(target); - if (getFileType() == FileType.CODE) { + if (getFileType() == FileType.CODE && !isPyronautPython(option)) { Range range = new Range(getLicenseLoader().getNumberOfLines(), -1); if (range.isValid() && asciidocMacro.attributes().stream().noneMatch(attribute -> attribute.key().equals(ATTRIBUTE_LINES))) { includeDirectiveBuilder.lines(range); @@ -94,17 +95,6 @@ protected String getExtension(GuidesOption option) { return option.getLanguage().getExtension(); } - protected String sourceTitle( - String appName, - String condensedTarget, - Classpath classpath, - String language, - String packageName) { - return (appName.equals(MacroSubstitution.APP_NAME_DEFAULT) ? "" : (appName + "/")) + sourceConventionFolder(classpath, language) + "/" - + (getFileType() == FileType.CODE ? (packageName.replace(".", "/") + "/") : "") - + condensedTarget; - } - protected String sourceInclude( String slug, String appName, @@ -114,10 +104,18 @@ protected String sourceInclude( String language, String packageName) { return "{sourceDir}/" + slug + "/" + getSourceDir(slug, option) + "/" + - sourceTitle(appName, condensedTarget, classpath, language, packageName); + sourceTitle(appName, condensedTarget, classpath, language, packageName, option); } - private String sourceConventionFolder(Classpath classpath, String language) { + private String sourceConventionFolder(Classpath classpath, String language, GuidesOption option) { + if (option != null && isPyronautPython(option)) { + if (getFileType() == FileType.CODE) { + return classpath == Classpath.TEST ? "tests" : "src"; + } + if (getFileType() == FileType.RESOURCE) { + return classpath == Classpath.TEST ? "tests-config" : "config"; + } + } if (getFileType() == FileType.CODE) { return "src/" + classpath + "/" + language; } else if (getFileType() == FileType.RESOURCE) { @@ -126,7 +124,26 @@ private String sourceConventionFolder(Classpath classpath, String language) { throw new UnsupportedOperationException("Unimplemented sourceConventionFolder for " + getFileType()); } + private String sourceTarget(String target, Classpath classpath, GuidesOption option) { + if (isPyronautPython(option) && getFileType() == FileType.CODE) { + return classpath == Classpath.TEST ? pythonTestModuleName(target) : pythonModuleName(target); + } + return target; + } + + protected String sourceTitle( + String appName, + String condensedTarget, + Classpath classpath, + String language, + String packageName, + GuidesOption option) { + return (appName.equals(MacroSubstitution.APP_NAME_DEFAULT) ? "" : (appName + "/")) + sourceConventionFolder(classpath, language, option) + "/" + + (getFileType() == FileType.CODE ? (packageName.replace(".", "/") + "/") : "") + + condensedTarget; + } + protected String condensedTarget(@NotNull AsciidocMacro asciidocMacro, GuidesOption option) { return asciidocMacro.target(); } -} \ No newline at end of file +} diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/TestScriptGenerator.java b/buildSrc/src/main/java/io/micronaut/guides/core/TestScriptGenerator.java index 0ff42ac94c9..6d888fd21dc 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/TestScriptGenerator.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/TestScriptGenerator.java @@ -22,4 +22,8 @@ public interface TestScriptGenerator { @NotNull String generateTestScript(@NonNull @NotNull List metadatas); + @NonNull + @NotNull + String generatePythonTestScript(@NonNull @NotNull List metadatas); + } diff --git a/buildSrc/src/main/java/io/micronaut/guides/core/ZipIncludeMacroSubstitution.java b/buildSrc/src/main/java/io/micronaut/guides/core/ZipIncludeMacroSubstitution.java index f73bd5505d7..1938e768732 100644 --- a/buildSrc/src/main/java/io/micronaut/guides/core/ZipIncludeMacroSubstitution.java +++ b/buildSrc/src/main/java/io/micronaut/guides/core/ZipIncludeMacroSubstitution.java @@ -32,7 +32,8 @@ protected String sourceTitle( String condensedTarget, Classpath classpath, String language, - String packageName) { + String packageName, + GuidesOption option) { return condensedTarget; } } diff --git a/buildSrc/src/main/resources/guide-metadata.schema.json b/buildSrc/src/main/resources/guide-metadata.schema.json index 19da9c78385..28b55b51489 100644 --- a/buildSrc/src/main/resources/guide-metadata.schema.json +++ b/buildSrc/src/main/resources/guide-metadata.schema.json @@ -46,6 +46,10 @@ "description": "Set it to true to skip running the tests for the Maven applications for the guide", "type": "boolean" }, + "skipPyronautTests": { + "description": "Set it to true to skip running the tests for the Pyronaut applications for the guide", + "type": "boolean" + }, "asciidoctor": { "description": "The guide asciidoc file. If not specified, the guide slug followed by the .adoc suffix is used", "type": "string" @@ -109,7 +113,7 @@ "type": "object" }, "apps": { - "description": "Applications created for the guide.", + "description": "Applications created for the guide; an empty list denotes a documentation-only guide with no generated application.", "type": "array", "items": { "type": "object", @@ -166,6 +170,13 @@ "type": "string" } }, + "pythonFeatures": { + "description": "The app's Python features", + "type": "array", + "items": { + "type": "string" + } + }, "jvmFeatures": { "description": "The app's features shared by the JVM languages (Java, Kotlin and Groovy)", "type": "array", diff --git a/buildSrc/src/main/resources/pyronaut-test-functions.sh b/buildSrc/src/main/resources/pyronaut-test-functions.sh new file mode 100644 index 00000000000..9e0c7c53e7e --- /dev/null +++ b/buildSrc/src/main/resources/pyronaut-test-functions.sh @@ -0,0 +1,41 @@ +pyronaut_repo_python () { + if [ -n "${PYTHON:-}" ]; then + echo "$PYTHON" + elif command -v python3 > /dev/null 2>&1; then + echo "python3" + else + echo "python" + fi +} + +set_pyronaut_test_resources () { + "$(pyronaut_repo_python)" -c 'exec(__import__("sys").stdin.read())' <<'PY' +from pathlib import Path +import re + +pyproject = Path("pyproject.toml") +text = pyproject.read_text(encoding="utf-8") +section = "[tool.pyronaut.test-resources]" +section_index = text.find(section) +if section_index < 0: + raise SystemExit(f"Missing {section} in pyproject.toml") + +next_section_index = text.find("\n[", section_index + len(section)) +if next_section_index < 0: + next_section_index = len(text) +section_text = text[section_index:next_section_index] +section_text, count = re.subn(r'(?m)^enabled\s*=\s*(?:true|false)', "enabled = true", section_text, count=1) +if count == 0: + section_text = section_text.replace(section + "\n", section + "\nenabled = true\n", 1) +text = text[:section_index] + section_text + text[next_section_index:] +pyproject.write_text(text, encoding="utf-8") +PY +} + +run_pyronaut_tests () { + set_pyronaut_test_resources && + pyronaut install && + pyronaut validate-config && + pyronaut process && + pyronaut test +} diff --git a/buildSrc/src/test/java/io/micronaut/guides/DocsOnlyGuideTest.java b/buildSrc/src/test/java/io/micronaut/guides/DocsOnlyGuideTest.java new file mode 100644 index 00000000000..408bc1ad283 --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/DocsOnlyGuideTest.java @@ -0,0 +1,147 @@ +package io.micronaut.guides; + +import io.micronaut.guides.core.App; +import io.micronaut.guides.core.Guide; +import io.micronaut.guides.core.GuideParser; +import io.micronaut.guides.tasks.SampleProjectGenerationTask; +import io.micronaut.starter.api.TestFramework; +import io.micronaut.starter.application.ApplicationType; +import io.micronaut.starter.options.BuildTool; +import io.micronaut.starter.options.Language; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.validation.validator.Validator; +import jakarta.inject.Inject; +import org.gradle.api.Project; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@MicronautTest(startApplication = false) +class DocsOnlyGuideTest { + + @Inject GuideParser guideParser; + @Inject Validator validator; + @Inject io.micronaut.guides.core.TestScriptGenerator coreScripts; + @TempDir Path tempDir; + + @Test + void explicitEmptyAppsAreValidMetadata() throws Exception { + writeMetadata("docs-only", "[]"); + Guide guide = guideParser.parseGuideMetadata(tempDir.resolve("guides/docs-only").toFile(), "metadata.json").orElseThrow(); + assertTrue(guide.apps().isEmpty()); + assertTrue(validator.validate(guide).isEmpty(), () -> validator.validate(guide).toString()); + assertFalse(guide.skipPyronautTests()); + } + + @Test + void emptyAppsProduceNoProjectOutputDirectories() throws Exception { + Guide guide = guide(List.of()); + assertAll( + () -> assertEquals(List.of(), SampleProjectGenerationTask.outputDirectoryNames(guide, null)), + () -> assertEquals(List.of(), SampleProjectGenerationTask.outputDirectoryNames(guide, Language.PYTHON)) + ); + Path outputDirectory = tempDir.resolve("build/code/docs-only"); + assertFalse(Files.exists(outputDirectory.getParent())); + try (GuideProjectGenerator generator = new GuideProjectGenerator()) { + generator.generateOne(guide, tempDir.toFile(), outputDirectory.toFile()); + generator.generateOne(guide, tempDir.toFile(), outputDirectory.toFile()); + } + try (var paths = Files.list(outputDirectory)) { + assertEquals(0, paths.count()); + } + } + + @Test + void existingFileCannotBeUsedAsProjectOutputDirectory() throws Exception { + Path outputFile = tempDir.resolve("output"); + Files.writeString(outputFile, "not a directory"); + try (GuideProjectGenerator generator = new GuideProjectGenerator()) { + assertThrows(IOException.class, () -> generator.generateOne(guide(List.of()), tempDir.toFile(), outputFile.toFile())); + } + assertEquals("not a directory", Files.readString(outputFile)); + } + + @Test + void aggregateScriptsDoNotEnterDocsOnlyProjectDirectories() { + Guide guide = guide(List.of()); + assertAll( + () -> assertFalse(coreScripts.generateTestScript(new ArrayList<>(List.of(guide))).contains("cd docs-only-")), + () -> assertFalse(coreScripts.generateNativeTestScript(new ArrayList<>(List.of(guide))).contains("cd docs-only-")), + () -> assertFalse(coreScripts.generatePythonTestScript(new ArrayList<>(List.of(guide))).contains("cd docs-only-")), + () -> assertFalse(TestScriptGenerator.generateScript(new ArrayList<>(List.of(guide)), false, false, false, null).contains("cd docs-only-")), + () -> assertFalse(TestScriptGenerator.generateScript(new ArrayList<>(List.of(guide)), false, true, false, null).contains("cd docs-only-")), + () -> assertFalse(TestScriptGenerator.generateScript(new ArrayList<>(List.of(guide)), false, false, true, null).contains("cd docs-only-")) + ); + } + + @Test + void ordinaryAppsStillHaveOutputDirectoriesAndTestCommands() { + Guide guide = guide(List.of(new App("default", "example.micronaut", ApplicationType.DEFAULT, "Micronaut", + List.of(), List.of(), List.of(), List.of(), List.of(), null, null, null, false))); + assertEquals(List.of("docs-only-pyronaut-python"), SampleProjectGenerationTask.outputDirectoryNames(guide, Language.PYTHON)); + assertTrue(coreScripts.generatePythonTestScript(new ArrayList<>(List.of(guide))).contains("cd docs-only-pyronaut-python")); + assertTrue(TestScriptGenerator.generateScript(new ArrayList<>(List.of(guide)), false, false, true, null).contains("cd docs-only-pyronaut-python")); + } + + @Test + void docsOnlyTaskRegistrationRetainsDocsIndexAndBuildWithoutApplicationTasks() throws Exception { + writeMetadata("docs-only", "[]"); + writeMetadata("ordinary", "[{\"name\":\"default\"}]"); + Files.writeString(tempDir.resolve("guides/tests.properties"), "numberOfTestGroups=1\n"); + Project project = ProjectBuilder.builder().withProjectDir(tempDir.toFile()).build(); + project.getExtensions().getExtraProperties().set("metadataConfigName", "metadata.json"); + for (String name : List.of("asciidoctor", "themeGuides", "createDist", "generateTestScript", "generateGuidesIndex", "generateGuidesJsonMetadata")) { + project.getTasks().register(name); + } + new GuidesPlugin().apply(project); + + assertAll( + () -> assertNotNull(project.getTasks().findByName("docsOnlyGenerateDocs")), + () -> assertNotNull(project.getTasks().findByName("docsOnlyGenerateDocsJava")), + () -> assertNotNull(project.getTasks().findByName("docsOnlyGenerateDocsPython")), + () -> assertNotNull(project.getTasks().findByName("docsOnlyIndex")), + () -> assertNotNull(project.getTasks().findByName("docsOnlyBuild")), + () -> assertNotNull(project.getTasks().findByName("docsOnlyBuildJava")), + () -> assertNotNull(project.getTasks().findByName("docsOnlyBuildPython")), + () -> assertFalse(project.getTasks().getNames().stream().anyMatch(name -> name.startsWith("docsOnly") + && (name.contains("Zip") || name.contains("TestScript") || name.contains("Workflow")))), + () -> assertNotNull(project.getTasks().findByName("ordinaryPythonZipCode")), + () -> assertNotNull(project.getTasks().findByName("ordinaryRunPythonTestScript")), + () -> assertNotNull(project.getTasks().findByName("ordinaryGenerateGithubActionWorkflow")) + ); + } + + private void writeMetadata(String slug, String apps) throws Exception { + Path directory = tempDir.resolve("guides/" + slug); + Files.createDirectories(directory); + Files.writeString(directory.resolve("metadata.json"), """ + { + "title": "Documentation guide", + "intro": "Connect the existing application.", + "authors": ["Micronaut"], + "categories": ["Data JDBC"], + "publicationDate": "2026-10-06", + "languages": ["JAVA", "PYTHON"], + "apps": %s + } + """.formatted(apps)); + } + + private static Guide guide(List apps) { + return new Guide("Documentation guide", "Connect the existing application.", List.of("Micronaut"), + List.of("Data JDBC"), LocalDate.of(2026, 10, 6), null, null, null, false, false, + "docs-only.adoc", List.of(Language.JAVA, Language.PYTHON), List.of(), + List.of(BuildTool.GRADLE, BuildTool.MAVEN, BuildTool.PYRONAUT), TestFramework.JUNIT, + List.of(), "docs-only", true, null, Map.of(), apps, false); + } +} diff --git a/buildSrc/src/test/java/io/micronaut/guides/GuideAsciidocGeneratorTest.java b/buildSrc/src/test/java/io/micronaut/guides/GuideAsciidocGeneratorTest.java index afe086ab1c7..6f9806ee42b 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/GuideAsciidocGeneratorTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/GuideAsciidocGeneratorTest.java @@ -2,6 +2,7 @@ import io.micronaut.guides.core.App; import io.micronaut.guides.core.Guide; +import io.micronaut.starter.api.TestFramework; import io.micronaut.starter.application.ApplicationType; import io.micronaut.starter.options.BuildTool; import io.micronaut.starter.options.Language; @@ -14,6 +15,7 @@ import java.util.List; import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -22,6 +24,40 @@ class GuideAsciidocGeneratorTest { @TempDir Path tempDir; + @Test + void onlyForLanguagesEndsAtClosingMarker() throws Exception { + Path input = Files.createDirectory(tempDir.resolve("guide")); + Path output = Files.createDirectory(tempDir.resolve("output")); + Path project = Files.createDirectory(tempDir.resolve("project")); + Files.createDirectories(project.resolve("buildSrc/src/main/resources")); + Files.writeString(project.resolve("buildSrc/src/main/resources/version.txt"), "5.2.0-SNAPSHOT"); + Files.createDirectories(project.resolve("src/docs/common/snippets")); + Files.writeString(project.resolve("src/docs/common/snippets/common-license.adoc"), "license"); + Files.writeString(input.resolve("guide.adoc"), """ + before + :only-for-languages:python + python-only + :only-for-languages: + after + """); + + Guide guide = new Guide( + "Guide", "Guide.", List.of("Micronaut"), List.of("Core Basics"), LocalDate.of(2026, 9, 22), + null, null, null, false, false, "guide.adoc", List.of(Language.JAVA, Language.GROOVY, Language.KOTLIN, Language.PYTHON), List.of(), + List.of(BuildTool.GRADLE, BuildTool.PYRONAUT), TestFramework.JUNIT, List.of(), "guide", true, null, + Map.of(), List.of(new App("default", null, null, null, List.of(), null, null, null, null, null, null, null, true)), false); + + GuideAsciidocGenerator.generate(guide, input.toFile(), output.toFile(), project.toFile()); + + for (Language language : guide.languages()) { + String buildTool = language == Language.PYTHON ? "pyronaut" : "gradle"; + String rendered = Files.readString(output.resolve("guide-" + buildTool + "-" + language.name().toLowerCase() + ".adoc")); + assertTrue(rendered.contains("before")); + assertEquals(language == Language.PYTHON, rendered.contains("python-only")); + assertTrue(rendered.contains("after")); + } + } + @Test void onlyForLanguagesFiltersContentAndUsesPythonProjectPaths() throws Exception { Path inputDir = Files.createDirectory(tempDir.resolve("input")); @@ -34,14 +70,14 @@ void onlyForLanguagesFiltersContentAndUsesPythonProjectPaths() throws Exception Authors: @authors@ Micronaut Version: @micronaut@ mn @cli-command@ example.micronaut.micronautguide - + :only-for-languages:python Python only source:HelloController[] test:HelloControllerTest[] pyronaut @cli-command@ example.micronaut.micronautguide --features=@features@ :only-for-languages: - + After """); diff --git a/buildSrc/src/test/java/io/micronaut/guides/GuideProjectGeneratorTest.java b/buildSrc/src/test/java/io/micronaut/guides/GuideProjectGeneratorTest.java new file mode 100644 index 00000000000..4ea8119d71e --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/GuideProjectGeneratorTest.java @@ -0,0 +1,161 @@ +package io.micronaut.guides; + +import io.micronaut.guides.core.App; +import io.micronaut.guides.core.Guide; +import io.micronaut.guides.core.GuidesOption; +import io.micronaut.starter.api.TestFramework; +import io.micronaut.starter.application.ApplicationType; +import io.micronaut.starter.options.BuildTool; +import io.micronaut.starter.options.Language; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GuideProjectGeneratorTest { + + @TempDir + Path directory; + + @Test + void mixedCaseUnderscoreExclusionsRemovePythonSourceAndPackagedAndFlatTests() throws Exception { + Path project = generatePythonExclusionProject("Hello_Controller", "Hello_ControllerTest"); + GuidesOption option = new GuidesOption(BuildTool.PYRONAUT, Language.PYTHON, TestFramework.PYTEST); + Method moduleName = GuideProjectGenerator.class.getDeclaredMethod("pythonModuleName", String.class); + moduleName.setAccessible(true); + Method testModuleName = GuideProjectGenerator.class.getDeclaredMethod("pythonTestModuleName", String.class); + testModuleName.setAccessible(true); + + assertAll( + () -> assertEquals("hello_controller", moduleName.invoke(null, "Hello_Controller")), + () -> assertEquals("test_hello_controller", testModuleName.invoke(null, "Hello_ControllerTest")), + () -> assertEquals("src/example/micronaut/hello_controller.py", + GuideAsciidocGenerator.mainPath("", "Hello_Controller", option)), + () -> assertEquals("tests/example/micronaut/test_hello_controller.py", + GuideAsciidocGenerator.testPath("", "Hello_ControllerTest", option)), + () -> assertFalse(Files.exists(project.resolve("src/example/micronaut/hello_controller.py"))), + () -> assertFalse(Files.exists(project.resolve("tests/example/micronaut/test_hello_controller.py"))), + () -> assertFalse(Files.exists(project.resolve("tests/test_hello_controller.py"))) + ); + } + + @Test + void lowercaseExclusionsPreserveAlreadyCorrectPythonModuleNames() throws Exception { + Path project = generatePythonExclusionProject("hello_controller", "test_hello_controller"); + GuidesOption option = new GuidesOption(BuildTool.PYRONAUT, Language.PYTHON, TestFramework.PYTEST); + Method moduleName = GuideProjectGenerator.class.getDeclaredMethod("pythonModuleName", String.class); + moduleName.setAccessible(true); + Method testModuleName = GuideProjectGenerator.class.getDeclaredMethod("pythonTestModuleName", String.class); + testModuleName.setAccessible(true); + + assertAll( + () -> assertEquals("hello_controller", moduleName.invoke(null, "hello_controller")), + () -> assertEquals("test_hello_controller", testModuleName.invoke(null, "test_hello_controller")), + () -> assertEquals("src/example/micronaut/hello_controller.py", + GuideAsciidocGenerator.mainPath("", "hello_controller", option)), + () -> assertEquals("tests/example/micronaut/test_hello_controller.py", + GuideAsciidocGenerator.testPath("", "test_hello_controller", option)), + () -> assertFalse(Files.exists(project.resolve("src/example/micronaut/hello_controller.py"))), + () -> assertFalse(Files.exists(project.resolve("tests/example/micronaut/test_hello_controller.py"))), + () -> assertFalse(Files.exists(project.resolve("tests/test_hello_controller.py"))) + ); + } + + private Path generatePythonExclusionProject(String sourceIdentifier, String testIdentifier) throws Exception { + Path input = Files.createDirectory(directory.resolve("guide")); + for (String path : List.of("src/example/micronaut/hello_controller.py", "src/example/micronaut/retained.py", + "tests/example/micronaut/test_hello_controller.py", "tests/example/micronaut/test_retained.py", + "tests/test_hello_controller.py", "tests/test_retained.py")) { + Path file = input.resolve("python/" + path); + Files.createDirectories(file.getParent()); + Files.writeString(file, "pass\n"); + } + App app = new App("default", "example.micronaut", ApplicationType.DEFAULT, "Micronaut", + List.of(), List.of(), List.of(), List.of(), List.of(), + TestFramework.PYTEST, List.of(testIdentifier), List.of(sourceIdentifier), false); + Guide guide = new Guide("Python exclusions", "Exclude Python modules.", List.of("Micronaut"), + List.of("Getting Started"), LocalDate.of(2026, 10, 6), null, null, null, false, false, + "python-exclusions.adoc", List.of(Language.PYTHON), List.of(), List.of(BuildTool.PYRONAUT), + TestFramework.PYTEST, List.of(), "python-exclusions", true, null, Map.of(), List.of(app), false); + Path output = directory.resolve("output"); + try (GuideProjectGenerator generator = new GuideProjectGenerator()) { + generator.generateOne(guide, input.toFile(), output.toFile(), Language.PYTHON); + } + Path project = output.resolve("python-exclusions-pyronaut-python"); + assertAll( + () -> assertTrue(Files.exists(project.resolve("pyproject.toml"))), + () -> assertTrue(Files.exists(project.resolve("src/example/micronaut/retained.py"))), + () -> assertTrue(Files.exists(project.resolve("tests/example/micronaut/test_retained.py"))), + () -> assertTrue(Files.exists(project.resolve("tests/test_retained.py"))) + ); + return project; + } + + @Test + void pythonResourceTransferDoesNotInheritJvmBootstrapConfiguration() throws Exception { + File input = new File("src/test/resources/file-transfer/python-application-config"); + Path python = Files.createDirectory(directory.resolve("python")); + Path java = Files.createDirectory(directory.resolve("java")); + Method transfer = GuideProjectGenerator.class.getDeclaredMethod("copyGuideSourceFiles", + File.class, Path.class, String.class, String.class, boolean.class); + transfer.setAccessible(true); + + transfer.invoke(null, input, python, "", "python", false); + transfer.invoke(null, input, java, "", "java", false); + + assertTrue(Files.exists(python.resolve("config/application.toml"))); + assertTrue(Files.exists(python.resolve("tests-config/application-test.toml"))); + assertFalse(Files.exists(python.resolve("config/bootstrap.properties"))); + assertFalse(Files.exists(python.resolve("tests-config/bootstrap-test.properties"))); + assertTrue(Files.exists(java.resolve("src/main/resources/bootstrap.properties"))); + assertTrue(Files.exists(java.resolve("src/test/resources/bootstrap-test.properties"))); + } + + @Test + void packageCleanupDoesNotTraverseVirtualEnvironmentSymlinks() throws Exception { + Path project = Files.createDirectory(directory.resolve("project")); + Path external = Files.createDirectory(directory.resolve("site-packages")); + Files.createSymbolicLink(project.resolve(".venv"), external); + Path externalMarker = Files.writeString(external.resolve("__init__.py"), "package initializer\n"); + Path sourceMarker = Files.writeString(project.resolve("__init__.py"), ""); + Path linkedPackage = Files.createDirectory(project.resolve("linked-package")); + Path linkedMarker = Files.createSymbolicLink(linkedPackage.resolve("__init__.py"), externalMarker); + + Method cleanup = GuideProjectGenerator.class.getDeclaredMethod("removePythonPackageMarkerFiles", File.class); + cleanup.setAccessible(true); + cleanup.invoke(null, project.toFile()); + + assertFalse(Files.exists(sourceMarker)); + assertTrue(Files.isSymbolicLink(linkedMarker)); + assertEquals("package initializer\n", Files.readString(externalMarker)); + } + + @Test + void licensingDoesNotModifyFilesThroughSymlinks() throws Exception { + Path project = Files.createDirectory(directory.resolve("project")); + Path source = Files.createDirectories(project.resolve("src/example")); + Path external = Files.createDirectory(directory.resolve("external")); + Files.createSymbolicLink(project.resolve(".venv"), external); + Path externalJava = Files.writeString(external.resolve("External.java"), "class External {}\n"); + Path sourceJava = Files.writeString(source.resolve("Application.java"), "class Application {}\n"); + Files.createSymbolicLink(project.resolve("Linked.java"), externalJava); + + try (GuideProjectGenerator generator = new GuideProjectGenerator()) { + generator.addLicenses(project.toFile()); + } + + assertTrue(Files.readString(sourceJava).contains("Licensed under")); + assertEquals("class External {}\n", Files.readString(externalJava)); + } +} diff --git a/buildSrc/src/test/java/io/micronaut/guides/GuidesPluginTest.java b/buildSrc/src/test/java/io/micronaut/guides/GuidesPluginTest.java new file mode 100644 index 00000000000..0ebb73f709c --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/GuidesPluginTest.java @@ -0,0 +1,108 @@ +package io.micronaut.guides; + +import io.micronaut.guides.tasks.AsciidocGenerationTask; +import io.micronaut.guides.tasks.TestScriptRunnerTask; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.function.Executable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class GuidesPluginTest { + + @TempDir + Path directory; + + @Test + void rendererIsOrderedAfterAllLanguageDocumentationTasks() throws IOException { + Project project = project(); + Task renderer = project.getTasks().getByName("asciidoctor"); + + assertAll(List.of("Java", "Groovy", "Kotlin", "Python").stream() + .map(language -> (Executable) () -> { + Task documentation = project.getTasks().getByName("helloGenerateDocs" + language); + assertTrue(renderer.getMustRunAfter().getDependencies(renderer).contains(documentation), + "Renderer must run after " + documentation.getName()); + })); + } + + @Test + void orderingDoesNotForceDocumentationGeneration() throws IOException { + Project project = project(); + Task renderer = project.getTasks().getByName("asciidoctor"); + Task documentation = project.getTasks().getByName("helloGenerateDocs"); + + assertTrue(renderer.getMustRunAfter().getDependencies(renderer).contains(documentation)); + assertTrue(renderer.getTaskDependencies().getDependencies(renderer).isEmpty(), + "Rendering existing documents must not schedule guide generation"); + } + + @Test + void orderingIncludesDocumentationTasksRegisteredLater() throws IOException { + Project project = project(); + Task renderer = project.getTasks().getByName("asciidoctor"); + Task documentation = project.getTasks().register("additionalDocs", AsciidocGenerationTask.class).get(); + + assertTrue(renderer.getMustRunAfter().getDependencies(renderer).contains(documentation)); + assertTrue(renderer.getTaskDependencies().getDependencies(renderer).isEmpty()); + } + + @Test + void releasedPythonRunnerDoesNotRequireLocalCheckouts() throws IOException { + assertPythonRunner(project(false), true); + } + + @Test + void skippedPythonRunnerDoesNotRequireRuntimeSetup() throws IOException { + assertPythonRunner(project(true), false); + } + + private void assertPythonRunner(Project project, boolean enabled) { + TestScriptRunnerTask runner = (TestScriptRunnerTask) project.getTasks().getByName("helloRunPythonTestScript"); + assertAll( + () -> assertFalse(runner.getTaskDependencies().getDependencies(runner).stream() + .anyMatch(task -> task.getName().equals("stageLocalPyronautArtifacts"))), + () -> assertEquals(Map.of("GUIDE_MODE", "fixture"), runner.getEnvironment().get()), + () -> assertEquals(enabled, runner.getOnlyIf().isSatisfiedBy(runner)) + ); + } + + private Project project() throws IOException { + return project(false); + } + + private Project project(boolean skipPythonTests) throws IOException { + Path guide = Files.createDirectories(directory.resolve("guides/hello")); + Files.writeString(directory.resolve("guides/tests.properties"), "numberOfTestGroups=1\n"); + Files.writeString(guide.resolve("metadata.json"), """ + { + "title": "Hello", + "intro": "A task-wiring fixture", + "authors": ["Micronaut"], + "categories": ["Core"], + "publicationDate": "2026-10-05", + "skipPyronautTests": %s, + "env": {"GUIDE_MODE": "fixture"}, + "languages": ["JAVA", "GROOVY", "KOTLIN", "PYTHON"], + "apps": [{"name": "default", "features": []}] + } + """.formatted(skipPythonTests)); + Project project = ProjectBuilder.builder().withProjectDir(directory.toFile()).build(); + project.getExtensions().getExtraProperties().set("metadataConfigName", "metadata.json"); + project.getTasks().register("asciidoctor"); + project.getPluginManager().apply(GuidesPlugin.class); + return project; + } +} diff --git a/buildSrc/src/test/java/io/micronaut/guides/IndexGeneratorTest.java b/buildSrc/src/test/java/io/micronaut/guides/IndexGeneratorTest.java index 1a11c9b04f3..f101596f2c1 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/IndexGeneratorTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/IndexGeneratorTest.java @@ -1,16 +1,129 @@ package io.micronaut.guides; +import io.micronaut.guides.core.App; +import io.micronaut.guides.core.Guide; +import io.micronaut.starter.api.TestFramework; +import io.micronaut.starter.options.BuildTool; +import io.micronaut.starter.options.Language; import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class IndexGeneratorTest { + @TempDir + Path tempDir; + + @Test + void pyronautReadLinkPointsToPublishedGuidePage() throws IOException { + File template = tempDir.resolve("template.html").toFile(); + Files.writeString(template.toPath(), String.join("\n", + "", + "@title@@twittercard@", + "", + "
@breadcrumb@
", + "
@content@
", + "
@toccontent@
", + "", + "")); + + File distDir = tempDir.resolve("dist").toFile(); + assertTrue(distDir.mkdirs()); + + Guide guide = new Guide( + "Python Guide", + "A guide with a Pyronaut variant.", + List.of("Micronaut"), + List.of("Core Basics"), + LocalDate.of(2026, 5, 18), + null, + null, + null, + false, + false, + null, + List.of(Language.JAVA, Language.PYTHON), + List.of(), + List.of(BuildTool.GRADLE, BuildTool.PYRONAUT), + TestFramework.JUNIT, + List.of(), + "python-guide", + true, + null, + Map.of(), + List.of(new App("default", null, null, null, List.of(), null, null, null, null, null, null, null, true)), + false); + + IndexGenerator.generateGuidesIndex(template, distDir, List.of(guide), null); + + String html = Files.readString(distDir.toPath().resolve("python-guide.html")); + assertTrue(html.contains("images/python.png")); + assertTrue(html.contains("python-guide-pyronaut-python.html'>Read")); + assertFalse(html.contains("python-guide.html'>Read")); + assertFalse(html.contains("python-guide-gradle-python.html")); + assertFalse(html.contains("python-guide-maven-python.html")); + assertTrue(html.matches("(?s).*
.*")); + } + + @Test + void jvmOnlyGuideDoesNotRenderPythonColumn() throws IOException { + File template = tempDir.resolve("jvm-template.html").toFile(); + Files.writeString(template.toPath(), String.join("\n", + "", + "@title@@twittercard@", + "", + "
@breadcrumb@
", + "
@content@
", + "
@toccontent@
", + "", + "")); + + File distDir = tempDir.resolve("jvm-dist").toFile(); + assertTrue(distDir.mkdirs()); + Guide guide = new Guide( + "JVM Guide", + "A JVM guide.", + List.of("Micronaut"), + List.of("Core Basics"), + LocalDate.of(2026, 5, 18), + null, + null, + null, + false, + false, + null, + List.of(Language.JAVA), + List.of(), + List.of(BuildTool.GRADLE), + TestFramework.JUNIT, + List.of(), + "jvm-guide", + true, + null, + Map.of(), + List.of(new App("default", null, null, null, List.of(), null, null, null, null, null, null, null, true)), + false); + + IndexGenerator.generateGuidesIndex(template, distDir, List.of(guide), null); + + String html = Files.readString(distDir.toPath().resolve("jvm-guide.html")); + assertFalse(html.contains("images/python.png")); + assertFalse(html.contains("Pyronaut")); + } + @Test void pythonJsonContainsFlatPythonGuideEntries() throws Exception { String json = IndexGenerator.generatePythonJsonIndex( diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/DependencyMacroSubstitutionTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/DependencyMacroSubstitutionTest.java index c167dbebec5..546ee4c745f 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/DependencyMacroSubstitutionTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/DependencyMacroSubstitutionTest.java @@ -69,7 +69,7 @@ void testSubstitute(){ } @Test - void pythonDependencyMacrosAreOmitted() { + void pythonDependencyMacrosUsePyprojectToml() { String source = """ Before @@ -87,9 +87,45 @@ void pythonDependencyMacrosAreOmitted() { assertEquals(""" Before - + [source, toml] + .pyproject.toml + ---- + [tool.pyronaut.dependencies] + runtime = [ + "io.micronaut.views:micronaut-views-thymeleaf", + ] + ---- After """, result); } + + @Test + void pythonPomMacrosFailInsteadOfRenderingOrdinaryDependencies() { + GuidesOption python = new GuidesOption(BuildTool.PYRONAUT, Language.PYTHON, TestFramework.PYTEST); + for (String scope : new String[]{"implementation", "annotationProcessor"}) { + for (String value : new String[]{"true", "TRUE"}) { + String macro = "dependency:micronaut-platform[groupId=io.micronaut.platform,scope=" + + scope + ",pom=" + value + "]"; + for (String source : new String[]{macro, ":dependencies:\n" + macro + "\n:dependencies:\n"}) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> dependencyMacroSubstitution.substitute(source, + GuideTestUtils.guideWithSlug("python-pom"), python)); + assertTrue(error.getMessage().contains("pom=true")); + } + } + } + } + + @Test + void ordinaryPythonDependenciesAndJvmPlatformsRemainSupported() { + String ordinary = "dependency:micronaut-http-client[pom=false]"; + assertTrue(DependencyLines.asciidoc(ordinary, BuildTool.PYRONAUT, Language.PYTHON) + .contains(" \"io.micronaut:micronaut-http-client\",")); + String platform = "dependency:micronaut-platform[groupId=io.micronaut.platform,pom=true]"; + assertTrue(DependencyLines.asciidoc(platform, BuildTool.GRADLE, Language.JAVA) + .contains("implementation platform(\"io.micronaut.platform:micronaut-platform\")")); + assertTrue(DependencyLines.asciidoc(platform, BuildTool.MAVEN, Language.JAVA) + .contains(" pom")); + } } diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/FilesTransferUtilityTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/FilesTransferUtilityTest.java index a0353eadbcb..cc3481979fd 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/FilesTransferUtilityTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/FilesTransferUtilityTest.java @@ -3,12 +3,15 @@ import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import jakarta.inject.Inject; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.File; import java.util.ArrayList; import java.util.List; import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -99,4 +102,24 @@ void pythonViewsAreCopiedToConfig() throws Exception { assertFalse(new File(destination, "src/main/resources/static/python.txt").exists()); } + @Test + void pythonUsesApplicationConfigurationWithoutInheritingBootstrapFiles(@TempDir Path output) throws Exception { + File inputDirectory = new File("src/test/resources/file-transfer/python-application-config"); + Guide guide = guideParser.parseGuideMetadata(inputDirectory, "metadata.json").orElseThrow(); + + filesTransferUtility.transferFiles(inputDirectory, output.toFile(), guide); + + Path python = output.resolve("python-application-config-pyronaut-python"); + assertEquals("[micronaut.application]\nname = \"python-application-config\"\n", + Files.readString(python.resolve("config/application.toml"))); + assertEquals("[micronaut.config]\nimport = []\n", + Files.readString(python.resolve("tests-config/application-test.toml"))); + assertFalse(Files.exists(python.resolve("config/bootstrap.properties"))); + assertFalse(Files.exists(python.resolve("tests-config/bootstrap-test.properties"))); + + Path java = output.resolve("python-application-config-gradle-java"); + assertTrue(Files.exists(java.resolve("src/main/resources/bootstrap.properties"))); + assertTrue(Files.exists(java.resolve("src/test/resources/bootstrap-test.properties"))); + } + } diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/GuideGenerationUtilsTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/GuideGenerationUtilsTest.java index ce394a7bd43..eea2d1e659e 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/GuideGenerationUtilsTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/GuideGenerationUtilsTest.java @@ -81,6 +81,38 @@ void testGroovyGuideProjectGeneratorGuidesOptions() { .anyMatch(option -> option.getBuildTool() == BuildTool.MAVEN && option.getLanguage() == Language.KOTLIN)); } + @Test + void pythonGuideOptionsUsePytestEvenWhenJvmGuideUsesJunit() { + Guide guideMetadata = new Guide(null,null, null, null, null, null, null, null,false,false,null, List.of(Language.JAVA, Language.PYTHON),null, List.of(BuildTool.GRADLE, BuildTool.PYRONAUT), TestFramework.JUNIT,null,null,true,null,null,null); + + List result = GuideGenerationUtils.guidesOptions(guideMetadata, LOG); + + assertTrue(result.stream() + .anyMatch(option -> option.getBuildTool() == BuildTool.GRADLE && + option.getLanguage() == Language.JAVA && + option.getTestFramework() == TestFramework.JUNIT)); + assertTrue(result.stream() + .anyMatch(option -> option.getBuildTool() == BuildTool.PYRONAUT && + option.getLanguage() == Language.PYTHON && + option.getTestFramework() == TestFramework.PYTEST)); + } + + @Test + void groovyGuideProjectGeneratorPythonOptionsUsePytestEvenWhenJvmGuideUsesJunit() { + Guide guideMetadata = new Guide(null,null, null, null, null, null, null, null,false,false,null, List.of(Language.JAVA, Language.PYTHON),null, List.of(BuildTool.GRADLE, BuildTool.PYRONAUT), TestFramework.JUNIT,null,null,true,null,null,null); + + List result = io.micronaut.guides.GuideProjectGenerator.guidesOptions(guideMetadata); + + assertTrue(result.stream() + .anyMatch(option -> option.getBuildTool() == BuildTool.GRADLE && + option.getLanguage() == Language.JAVA && + option.getTestFramework() == TestFramework.JUNIT)); + assertTrue(result.stream() + .anyMatch(option -> option.getBuildTool() == BuildTool.PYRONAUT && + option.getLanguage() == Language.PYTHON && + option.getTestFramework() == TestFramework.PYTEST)); + } + @Test void testGroovyPythonGuideOptionsUsePyronautAndPytest() { Guide guideMetadata = new Guide(null,null, null, null, null, null, null, null,false,false,null, List.of(Language.JAVA, Language.PYTHON),null, List.of(BuildTool.GRADLE, BuildTool.MAVEN), TestFramework.SPOCK,null,null,true,null,null,null); @@ -103,6 +135,7 @@ void testTestFrameworkOption() { assertEquals(TestFramework.PYTEST, GuideGenerationUtils.testFrameworkOption(Language.PYTHON, TestFramework.SPOCK)); assertEquals(TestFramework.JUNIT, GuideGenerationUtils.testFrameworkOption(Language.JAVA, null)); assertEquals(TestFramework.SPOCK, GuideGenerationUtils.testFrameworkOption(Language.JAVA, TestFramework.SPOCK)); + assertEquals(TestFramework.PYTEST, GuideGenerationUtils.testFrameworkOption(Language.PYTHON, TestFramework.JUNIT)); } @Test diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/GuideParserTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/GuideParserTest.java index e4481630233..8429496e68c 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/GuideParserTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/GuideParserTest.java @@ -1,13 +1,22 @@ package io.micronaut.guides.core; +import io.micronaut.core.io.ResourceLoader; import io.micronaut.starter.application.ApplicationType; import io.micronaut.starter.options.BuildTool; import io.micronaut.starter.options.Language; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import jakarta.inject.Inject; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDate; import java.util.Collections; import java.util.List; @@ -20,6 +29,54 @@ public class GuideParserTest { @Inject GuideParser guideParser; + @Test + void schemaAdvertisesSupportedPythonOptionsOnly(ResourceLoader resourceLoader) throws IOException, JSONException { + try (var input = resourceLoader.getResourceAsStream("classpath:guide-metadata.schema.json").orElseThrow()) { + JSONObject properties = new JSONObject(new String(input.readAllBytes(), StandardCharsets.UTF_8)) + .getJSONObject("properties"); + assertFalse(properties.has("python")); + assertTrue(properties.getJSONObject("languages").getJSONObject("items") + .getJSONArray("enum").toString().contains("\"PYTHON\"")); + assertTrue(properties.getJSONObject("apps").getJSONObject("items") + .getJSONObject("properties").has("pythonFeatures")); + } + } + + @Test + void explicitPythonLanguageAddsPyronautBuildTool() { + Guide guide = guideParser.parseGuideMetadata( + new File("src/test/resources/guides-python/creating-your-first-micronaut-app"), + "metadata.json").orElseThrow(); + assertEquals(List.of(Language.PYTHON), guide.languages()); + assertEquals(List.of(BuildTool.GRADLE, BuildTool.MAVEN, BuildTool.PYRONAUT), guide.buildTools()); + } + + @Test + void childLanguageDefaultsAreAppliedBeforeBaseAppsAreMerged(@TempDir Path guides) throws Exception { + Path base = Files.createDirectory(guides.resolve("base")); + Files.writeString(base.resolve("metadata.json"), """ + {"publish":false,"languages":["PYTHON"],"apps":[{"name":"default"}]} + """); + Path child = Files.createDirectory(guides.resolve("child")); + JSONObject metadata = new JSONObject(Files.readString( + Path.of("src/test/resources/guides-python/creating-your-first-micronaut-app/metadata.json"))); + metadata.remove("languages"); + metadata.put("base", "base"); + metadata.put("apps", new JSONArray()); + for (boolean python : List.of(false, true)) { + if (python) { + metadata.put("languages", new JSONArray(List.of("PYTHON"))); + } + Files.writeString(child.resolve("metadata.json"), metadata.toString()); + Guide parsed = guideParser.parseGuidesMetadata(guides.toFile(), "metadata.json").stream() + .filter(guide -> guide.slug().equals("child")).findFirst().orElseThrow(); + assertEquals(python ? List.of(Language.PYTHON) : List.of(Language.JAVA, Language.GROOVY, Language.KOTLIN), parsed.languages()); + assertEquals(python, parsed.buildTools().contains(BuildTool.PYRONAUT)); + assertEquals(1, parsed.apps().size()); + assertEquals("default", parsed.apps().get(0).name()); + } + } + @Test void testParseGuidesMetadata() { String path = "src/test/resources/guides"; @@ -39,8 +96,10 @@ void testParseGuidesMetadata() { assertEquals("child",guide.slug()); assertEquals(List.of(Language.JAVA, Language.GROOVY, Language.KOTLIN),guide.languages()); assertEquals(List.of(BuildTool.GRADLE, BuildTool.MAVEN),guide.buildTools()); + assertFalse(guide.languages().contains(Language.PYTHON)); assertTrue(guide.zipIncludes().isEmpty()); assertTrue(guide.env().isEmpty()); + assertFalse(guideParser.parseGuideMetadata(new File(path, "child"), "metadata.json").orElseThrow().languages().contains(Language.PYTHON)); List tags = guide.tags(); Collections.sort(tags); assertEquals(List.of("Azure", "cloud", "data-jdbc", "database", "flyway", "jdbc", "micronaut-data", "mysql"), tags); diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/GuideProjectZipperTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/GuideProjectZipperTest.java index 3a4a109a732..16289c966f5 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/GuideProjectZipperTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/GuideProjectZipperTest.java @@ -26,7 +26,7 @@ void testZip() throws IOException { guideProjectZipper.zipDirectory(projectFolder, zipFile); - List expected = List.of("metadata.json", "creating-your-first-micronaut-app.adoc"); + List expected = List.of("creating-your-first-micronaut-app.adoc", "metadata.json"); List result = new LinkedList<>(); try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) { @@ -38,6 +38,7 @@ void testZip() throws IOException { zis.closeEntry(); } + result.sort(String::compareTo); assertEquals(expected, result); } } diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/GuideTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/GuideTest.java index 246511e826d..0007d2b3cd1 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/GuideTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/GuideTest.java @@ -105,6 +105,15 @@ void typeCloudCanBeNull() { assertTrue(violations.isEmpty()); } + @Test + void appsMustNotBeNull() { + Guide guide = new Guide("Documentation guide", "Connect the existing application.", + List.of("Micronaut"), List.of("Data JDBC"), LocalDate.of(2026, 10, 6), + null, null, null, false, false, null, null, null, null, null, null, null, + true, null, null, null); + assertTrue(validator.validate(guide).stream().anyMatch(violation -> violation.getPropertyPath().toString().equals("apps"))); + } + @Test void testDeserialization() { Optional inputStreamOptional = resourceLoader.getResourceAsStream("classpath:metadata.json"); @@ -226,12 +235,11 @@ void buildGeneratesCorrectJsonSchema(ResourceLoader resourceLoader) throws IOExc "type": "object", "properties": { "apps": { - "description": "Applications created for the guide", + "description": "Applications created for the guide; an empty list denotes a documentation-only guide with no generated application", "type": "array", "items": { "$ref": "https://micronaut-projects.github.io/micronaut-guides/schemas/app.schema.json" - }, - "minItems": 1 + } }, "asciidoctor": { "description": "The guide asciidoc file. If not specified, the guide slug followed by the .adoc suffix is used", diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/GuideUtilsTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/GuideUtilsTest.java index 164068c8fcf..21acc05ce4d 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/GuideUtilsTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/GuideUtilsTest.java @@ -9,8 +9,10 @@ import org.junit.jupiter.api.Test; import java.io.InputStream; +import java.time.LocalDate; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -185,4 +187,41 @@ void testGetFrameworks() { Set expected = Set.of("Spring Boot","Micronaut"); assertEquals(expected,frameworks); } + + @Test + void testMergePreservesPythonOptionsFromBase() { + App baseApp = new App("default", null, null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of("base-jvm"), null, null, null, true, List.of("base")); + Guide base = new Guide("base", "base", List.of("author"), List.of("category"), LocalDate.of(2026, 1, 1), null, null, null, + false, false, null, List.of(Language.JAVA, Language.GROOVY, Language.KOTLIN, Language.PYTHON), List.of(), + List.of(BuildTool.GRADLE, BuildTool.MAVEN, BuildTool.PYRONAUT), null, List.of(), "base", true, null, Map.of(), List.of(baseApp), false); + App childApp = new App("default", null, null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of("child-jvm"), null, null, null, true, List.of("child")); + Guide child = new Guide("child", "child", List.of(), List.of("category"), LocalDate.of(2026, 1, 1), null, null, null, + false, false, null, null, List.of(), + null, null, List.of(), "child", true, "base", Map.of(), List.of(childApp), false); + + Guide merged = GuideUtils.merge(base, child); + + assertTrue(merged.languages().contains(Language.PYTHON)); + assertTrue(merged.buildTools().contains(BuildTool.PYRONAUT)); + assertEquals(List.of("child", "base"), merged.apps().get(0).pythonFeatures()); + assertEquals(List.of("child-jvm", "base-jvm"), merged.apps().get(0).jvmFeatures()); + assertEquals(List.of("child", "base"), GuideUtils.getAppVisibleFeatures(merged.apps().get(0), Language.PYTHON)); + assertEquals(List.of("child-jvm", "base-jvm"), GuideUtils.getAppVisibleFeatures(merged.apps().get(0), Language.JAVA)); + } + + @Test + void testMergeAllowsChildToDisablePython() { + App app = new App("default", null, null, null, List.of(), List.of(), List.of(), List.of(), List.of(), null, null, null, null, true, List.of()); + Guide base = new Guide("base", "base", List.of("author"), List.of("category"), LocalDate.of(2026, 1, 1), null, null, null, + false, false, null, List.of(Language.JAVA, Language.GROOVY, Language.KOTLIN, Language.PYTHON), List.of(), + List.of(BuildTool.GRADLE, BuildTool.MAVEN, BuildTool.PYRONAUT), null, List.of(), "base", true, null, Map.of(), List.of(app), false); + Guide child = new Guide("child", "child", List.of(), List.of("category"), LocalDate.of(2026, 1, 1), null, null, null, + false, false, null, List.of(Language.JAVA, Language.GROOVY, Language.KOTLIN), List.of(), + List.of(BuildTool.GRADLE, BuildTool.MAVEN), null, List.of(), "child", true, "base", Map.of(), List.of(app), false); + + Guide merged = GuideUtils.merge(base, child); + + assertFalse(merged.languages().contains(Language.PYTHON)); + assertFalse(merged.buildTools().contains(BuildTool.PYRONAUT)); + } } diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/MacroUtilsTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/MacroUtilsTest.java index 7a4de186665..3c24f89255e 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/MacroUtilsTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/MacroUtilsTest.java @@ -22,6 +22,11 @@ void testGetSourceDir() { assertEquals("slug-gradle-java", result); } + @Test + void testPythonModuleNameNormalizesMixedSnakeCase() { + assertEquals("hello_controller", MacroUtils.pythonModuleName("Hello_Controller")); + } + @Test void testFindMacroGroups() { String str = """ diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/SourceMacroSubstitutionTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/SourceMacroSubstitutionTest.java index 0fb1326173c..e4ef42a8b2c 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/SourceMacroSubstitutionTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/SourceMacroSubstitutionTest.java @@ -46,6 +46,20 @@ void testSubstitute(){ assertEquals(expectedGroovy, resGroovy); } + @Test + void testSubstitutePyronautPythonKeepsSourceCallouts() { + String str = "source:HelloController[]\n"; + String resPython = sourceMacroSubstitution.substitute(str, GuideTestUtils.guideWithSlug("creating-your-first-micronaut-app"), new GuidesOption(BuildTool.PYRONAUT, Language.PYTHON, TestFramework.PYTEST)); + String expectedPython = """ + [source,python] + .src/example/micronaut/hello_controller.py + ---- + include::{sourceDir}/creating-your-first-micronaut-app/creating-your-first-micronaut-app-pyronaut-python/src/example/micronaut/hello_controller.py[] + ---- + """; + assertEquals(expectedPython, resPython); + } + @Test void TestSubstituteWithApp(){ String str = "source:Application[app=springboot]\n"; diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/TestMacroSubstitutionTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/TestMacroSubstitutionTest.java index 4d4a2c8f263..95207ab110f 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/TestMacroSubstitutionTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/TestMacroSubstitutionTest.java @@ -44,6 +44,19 @@ void testSubstitute() { assertEquals(expectedGroovy, resGroovy); } + @Test + void testSubstitutePyronautPythonKeepsTestCallouts() { + String str = "test:HelloControllerTest[]"; + String resPython = testMacroSubstitution.substitute(str, GuideTestUtils.guideWithSlug("creating-your-first-micronaut-app"), new GuidesOption(BuildTool.PYRONAUT, Language.PYTHON, TestFramework.PYTEST)); + String expectedPython = """ + [source,python] + .tests/example/micronaut/test_hello_controller.py + ---- + include::{sourceDir}/creating-your-first-micronaut-app/creating-your-first-micronaut-app-pyronaut-python/tests/example/micronaut/test_hello_controller.py[] + ----"""; + assertEquals(expectedPython, resPython); + } + @Test void TestSubstituteWithApp() { String str = "test:ApplicationTest[app=springboot]"; diff --git a/buildSrc/src/test/java/io/micronaut/guides/core/TestScriptGeneratorTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/TestScriptGeneratorTest.java index d55ba17fa4d..a78ab49b7b4 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/TestScriptGeneratorTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/TestScriptGeneratorTest.java @@ -13,6 +13,7 @@ import static io.micronaut.starter.options.BuildTool.GRADLE; import static io.micronaut.starter.options.BuildTool.MAVEN; +import static io.micronaut.starter.options.BuildTool.PYRONAUT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -119,6 +120,51 @@ void testGenerate() { assertEquals(expected.strip(), result.strip()); } + @Test + void testGeneratePython() { + App app = new App("default", null, null, null, null, null, null, null, null, null, null, null, true); + Guide guide = new Guide( + "Python guide", + "Tests Python guide script generation.", + List.of("Micronaut"), + List.of("Getting Started"), + java.time.LocalDate.of(2026, 5, 13), + null, + null, + null, + false, + false, + null, + List.of(Language.PYTHON), + List.of(), + List.of(PYRONAUT), + TestFramework.PYTEST, + List.of(), + "python-guide", + true, + null, + java.util.Map.of(), + List.of(app), + false + ); + + String result = testScriptGenerator.generatePythonTestScript(new ArrayList<>(List.of(guide))); + + assertTrue(result.contains("set_pyronaut_test_resources &&")); + for (String command : List.of("install", "validate-config", "process", "test")) { + assertTrue(result.contains("pyronaut " + command)); + } + assertFalse(result.contains("PYRONAUT_LOCAL_REPOSITORY")); + assertFalse(result.contains("PYRONAUT_INSTALL_EXECUTABLE")); + assertFalse(result.contains("pyronaut_native_toolchain")); + assertFalse(result.contains("local tmp_dir")); + assertTrue(result.contains("cd python-guide-pyronaut-python")); + assertFalse(result.contains("}cd python-guide-pyronaut-python")); + assertTrue(result.contains("run_pyronaut_tests || EXIT_STATUS=$?")); + assertFalse(result.contains("pyenv")); + assertFalse(result.contains("./gradlew -q check")); + } + @Test void testGenerateNative() { String path = "src/test/resources/guides"; @@ -134,17 +180,17 @@ void testGenerateNative() { } @Test - void testGeneratePython() { + void testGenerateGenericScriptSkipsPython() { File guideFolder = new File("src/test/resources/file-transfer/python-resources"); Guide guide = guideParser.parseGuideMetadata(guideFolder, "metadata.json").orElseThrow(); String result = testScriptGenerator.generateTestScript(new ArrayList<>(List.of(guide))); - assertTrue(result.contains("cd python-resources-pyronaut-python")); + assertFalse(result.contains("python-resources-pyronaut-python")); assertFalse(result.contains("pyenv")); - assertTrue(result.contains("pyronaut install")); - assertTrue(result.contains("pyronaut validate-config")); - assertTrue(result.contains("pyronaut test")); + assertFalse(result.contains("pyronaut install")); + assertFalse(result.contains("pyronaut validate-config")); + assertFalse(result.contains("pyronaut test")); assertFalse(result.contains("./gradlew -q check")); assertFalse(result.contains("./mvnw -q test")); } diff --git a/buildSrc/src/test/java/io/micronaut/guides/tasks/PythonTestScriptTaskTest.java b/buildSrc/src/test/java/io/micronaut/guides/tasks/PythonTestScriptTaskTest.java new file mode 100644 index 00000000000..71346315a7b --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/tasks/PythonTestScriptTaskTest.java @@ -0,0 +1,83 @@ +package io.micronaut.guides.tasks; + +import io.micronaut.guides.core.DefaultGuideParser; +import io.micronaut.guides.core.DefaultJsonSchemaProvider; +import io.micronaut.guides.core.Guide; +import io.micronaut.json.JsonMapper; +import org.gradle.api.Project; +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.testfixtures.ProjectBuilder; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PythonTestScriptTaskTest { + + @Test + void inheritedAppsChangeTheDeclaredInputsAndGeneratedScript(@TempDir Path directory) throws Exception { + Path guides = Files.createDirectory(directory.resolve("guides")); + Path base = Files.createDirectory(guides.resolve("a-base")); + Path child = Files.createDirectory(guides.resolve("child")); + Path metadataFile = child.resolve("metadata.json"); + JSONObject childMetadata = new JSONObject(Files.readString( + Path.of("src/test/resources/guides-python/creating-your-first-micronaut-app/metadata.json"))); + childMetadata.put("base", "a-base"); + childMetadata.put("apps", new JSONArray()); + Files.writeString(metadataFile, childMetadata.toString()); + String unchangedChild = Files.readString(metadataFile); + Path functions = Files.writeString(directory.resolve("functions.sh"), "# unchanged helper\n"); + Project project = ProjectBuilder.builder().withProjectDir(directory.toFile()).build(); + PythonTestScriptTask task = project.getTasks().create("pythonTestScript", PythonTestScriptTask.class); + task.getGuideSlug().set("child"); + task.getMetadataFile().set(metadataFile.toFile()); + task.getPyronautTestFunctionsFile().set(functions.toFile()); + task.getScriptFile().set(directory.resolve("python-test.sh").toFile()); + DefaultGuideParser parser = new DefaultGuideParser(new DefaultJsonSchemaProvider(), JsonMapper.createDefault()); + Map originalInputs = null; + String originalScript = null; + for (String appName : new String[]{"first", "second"}) { + Files.writeString(base.resolve("metadata.json"), + "{\"publish\":false,\"apps\":[{\"name\":\"" + appName + "\"}]}"); + Guide merged = parser.parseGuidesMetadata(guides.toFile(), "metadata.json").stream() + .filter(guide -> guide.slug().equals("child")).findFirst().orElseThrow(); + task.setMetadata(merged); + Map inputs = Map.copyOf(task.getInputs().getProperties()); + task.perform(); + String script = Files.readString(task.getScriptFile().get().getAsFile().toPath()); + assertTrue(script.contains("cd " + appName + "\n")); + if (originalInputs != null) { + assertNotEquals(originalScript, script); + assertNotEquals(originalInputs, inputs, "Changing inherited applications must invalidate the script inputs"); + } + originalInputs = inputs; + originalScript = script; + } + assertEquals(unchangedChild, Files.readString(metadataFile)); + } + + @Test + void pyronautTestFunctionsResourceIsAnInput() { + Method property = Arrays.stream(PythonTestScriptTask.class.getMethods()) + .filter(method -> method.getName().equals("getPyronautTestFunctionsFile")) + .findFirst() + .orElse(null); + + assertNotNull(property, "The helper resource must be a task property"); + assertTrue(property.isAnnotationPresent(InputFile.class)); + assertEquals(PathSensitivity.RELATIVE, property.getAnnotation(PathSensitive.class).value()); + } +} diff --git a/buildSrc/src/test/java/io/micronaut/guides/tasks/SampleProjectGenerationTaskTest.java b/buildSrc/src/test/java/io/micronaut/guides/tasks/SampleProjectGenerationTaskTest.java new file mode 100644 index 00000000000..c74d98a0e6a --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/tasks/SampleProjectGenerationTaskTest.java @@ -0,0 +1,140 @@ +package io.micronaut.guides.tasks; + +import io.micronaut.guides.GuideProjectGenerator; +import io.micronaut.guides.core.App; +import io.micronaut.guides.core.Guide; +import io.micronaut.guides.core.App; +import io.micronaut.starter.api.TestFramework; +import io.micronaut.starter.application.ApplicationType; +import io.micronaut.starter.options.BuildTool; +import io.micronaut.starter.options.Language; +import org.gradle.api.Project; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SampleProjectGenerationTaskTest { + + @TempDir + Path directory; + + @Test + void outputDirectoriesAreLimitedToTheRequestedLanguage() { + Guide metadata = new Guide(null, null, null, null, null, null, null, null, + false, false, null, List.of(Language.JAVA, Language.PYTHON), null, + List.of(BuildTool.GRADLE, BuildTool.MAVEN), TestFramework.JUNIT, + null, "multi-language", true, null, null, + List.of(new App("default", null, null, null, null, null, null, null, null, null, null, null, false))); + + assertEquals(List.of("multi-language-pyronaut-python"), + SampleProjectGenerationTask.outputDirectoryNames(metadata, Language.PYTHON)); + assertEquals(List.of("multi-language-gradle-java", "multi-language-maven-java", + "multi-language-pyronaut-python"), + SampleProjectGenerationTask.outputDirectoryNames(metadata, null)); + } + + @Test + void pythonRegenerationRefreshesProjectAndPreservesOtherLanguages() throws IOException { + Path input = Files.createDirectories(directory.resolve("guide")); + Path output = directory.resolve("output"); + Path obsoleteSource = write(input.resolve("python/src/obsolete.py"), "obsolete = True\n"); + Guide metadata = metadata(List.of()); + + try (GuideProjectGenerator generator = new GuideProjectGenerator()) { + SampleProjectGenerationTask task = task(metadata, input, output, Language.PYTHON, generator); + task.perform(); + Path pythonProject = output.resolve("regeneration-pyronaut-python"); + Path pyproject = pythonProject.resolve("pyproject.toml"); + assertFalse(Files.readString(pyproject).contains("io.micronaut.validation:micronaut-validation")); + assertTrue(Files.exists(pythonProject.resolve("src/obsolete.py"))); + + Path javaSentinel = write(output.resolve("regeneration-gradle-java/sentinel.txt"), "Java survives\n"); + Path kotlinSentinel = write(output.resolve("regeneration-gradle-kotlin/sentinel.txt"), "Kotlin survives\n"); + Files.delete(obsoleteSource); + write(input.resolve("python/src/current.py"), "current = True\n"); + task.setMetadata(metadata(List.of("validation"))); + + task.perform(); + + String regeneratedPyproject = Files.readString(pyproject); + assertAll( + () -> assertTrue(regeneratedPyproject.contains("io.micronaut.validation:micronaut-validation"), + "The changed Starter feature must update pyproject.toml dependencies"), + () -> assertFalse(Files.exists(pythonProject.resolve("src/obsolete.py")), + "Removed guide samples must not survive regeneration"), + () -> assertEquals("current = True\n", Files.readString(pythonProject.resolve("src/current.py"))), + () -> assertEquals("Java survives\n", Files.readString(javaSentinel)), + () -> assertEquals("Kotlin survives\n", Files.readString(kotlinSentinel))); + } + } + + @Test + void unfilteredGenerationCleansTheEntireOutputDirectory() throws IOException { + Path input = Files.createDirectories(directory.resolve("guide")); + Path output = directory.resolve("output"); + Path stalePython = write(output.resolve("regeneration-pyronaut-python/stale.txt"), "stale\n"); + Path staleJava = write(output.resolve("regeneration-gradle-java/stale.txt"), "stale\n"); + Path staleKotlin = write(output.resolve("regeneration-gradle-kotlin/stale.txt"), "stale\n"); + Path undeclaredOutput = write(output.resolve("obsolete-project/stale.txt"), "stale\n"); + + try (GuideProjectGenerator generator = new GuideProjectGenerator()) { + task(metadata(List.of("validation")), input, output, null, generator).perform(); + + assertAll( + () -> assertFalse(Files.exists(stalePython)), + () -> assertFalse(Files.exists(staleJava)), + () -> assertFalse(Files.exists(staleKotlin)), + () -> assertFalse(Files.exists(undeclaredOutput)), + () -> assertTrue(Files.exists(output.resolve("regeneration-pyronaut-python/pyproject.toml"))), + () -> assertTrue(Files.readString(output.resolve("regeneration-pyronaut-python/pyproject.toml")) + .contains("io.micronaut.validation:micronaut-validation")), + () -> assertTrue(Files.exists(output.resolve("regeneration-gradle-java/build.gradle"))), + () -> assertTrue(Files.exists(output.resolve("regeneration-gradle-kotlin/build.gradle")))); + } + } + + private SampleProjectGenerationTask task(Guide metadata, Path input, Path output, Language language, + GuideProjectGenerator generator) throws IOException { + Project project = ProjectBuilder.builder() + .withProjectDir(Files.createDirectories(directory.resolve("gradle-project")).toFile()) + .build(); + SampleProjectGenerationTask task = project.getTasks().create("generateProjects", SampleProjectGenerationTask.class); + task.setMetadata(metadata); + task.setGuidesGenerator(generator); + task.getSlug().set(metadata.slug()); + task.getInputDirectory().set(input.toFile()); + task.getOutputDir().set(output.toFile()); + if (language != null) { + task.getLanguage().set(language.name()); + } + task.getOutputDirectories().from(SampleProjectGenerationTask.outputDirectoryNames(metadata, language) + .stream().map(name -> output.resolve(name).toFile()).toList()); + return task; + } + + private static Guide metadata(List features) { + App app = new App("default", "example.micronaut", ApplicationType.DEFAULT, "Micronaut", + features, List.of(), List.of(), List.of(), List.of(), null, null, null, false); + return new Guide("Regeneration", "Sample project regeneration", List.of("author"), List.of("Testing"), + LocalDate.of(2026, 10, 6), null, null, null, false, false, null, + List.of(Language.JAVA, Language.KOTLIN, Language.PYTHON), List.of(), List.of(BuildTool.GRADLE), + TestFramework.JUNIT, List.of(), "regeneration", true, null, Map.of(), List.of(app)); + } + + private static Path write(Path path, String content) throws IOException { + Files.createDirectories(path.getParent()); + return Files.writeString(path, content); + } +} diff --git a/buildSrc/src/test/resources/expected_test_script.sh b/buildSrc/src/test/resources/expected_test_script.sh index 79c79f14255..891c08c16d7 100644 --- a/buildSrc/src/test/resources/expected_test_script.sh +++ b/buildSrc/src/test/resources/expected_test_script.sh @@ -158,22 +158,6 @@ if [ $EXIT_STATUS -ne 0 ]; then fi EXIT_STATUS=0 -cd hello-base-gradle-java - -cd .. -cd hello-base-gradle-groovy - -cd .. -cd hello-base-gradle-kotlin - -cd .. -cd hello-base-maven-java - -cd .. -cd hello-base-maven-groovy - -cd .. - cd test-gradle-java cd springboot echo "-------------------------------------------------" diff --git a/buildSrc/src/test/resources/expected_test_script_native.sh b/buildSrc/src/test/resources/expected_test_script_native.sh index a8229d59e46..086a5802775 100644 --- a/buildSrc/src/test/resources/expected_test_script_native.sh +++ b/buildSrc/src/test/resources/expected_test_script_native.sh @@ -74,22 +74,6 @@ if [ $EXIT_STATUS -ne 0 ]; then fi EXIT_STATUS=0 -cd hello-base-gradle-java - -cd .. -cd hello-base-gradle-groovy - -cd .. -cd hello-base-gradle-kotlin - -cd .. -cd hello-base-maven-java - -cd .. -cd hello-base-maven-groovy - -cd .. - cd test-gradle-java cd micronautframeworkjacksondatabind echo "-------------------------------------------------" diff --git a/buildSrc/src/test/resources/file-transfer/python-application-config/metadata.json b/buildSrc/src/test/resources/file-transfer/python-application-config/metadata.json new file mode 100644 index 00000000000..5f75339abcc --- /dev/null +++ b/buildSrc/src/test/resources/file-transfer/python-application-config/metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Python application configuration", + "intro": "Test Python application configuration without inherited bootstrap files.", + "authors": ["Micronaut"], + "categories": ["Getting Started"], + "publicationDate": "2026-01-01", + "languages": ["JAVA", "PYTHON"], + "buildTools": ["GRADLE", "PYRONAUT"], + "apps": [{"name": "default"}] +} diff --git a/buildSrc/src/test/resources/file-transfer/python-application-config/python/config/application.toml b/buildSrc/src/test/resources/file-transfer/python-application-config/python/config/application.toml new file mode 100644 index 00000000000..7dff7b04978 --- /dev/null +++ b/buildSrc/src/test/resources/file-transfer/python-application-config/python/config/application.toml @@ -0,0 +1,2 @@ +[micronaut.application] +name = "python-application-config" diff --git a/buildSrc/src/test/resources/file-transfer/python-application-config/python/tests-config/application-test.toml b/buildSrc/src/test/resources/file-transfer/python-application-config/python/tests-config/application-test.toml new file mode 100644 index 00000000000..a31d948c365 --- /dev/null +++ b/buildSrc/src/test/resources/file-transfer/python-application-config/python/tests-config/application-test.toml @@ -0,0 +1,2 @@ +[micronaut.config] +import = [] diff --git a/buildSrc/src/test/resources/file-transfer/python-application-config/src/main/resources/bootstrap.properties b/buildSrc/src/test/resources/file-transfer/python-application-config/src/main/resources/bootstrap.properties new file mode 100644 index 00000000000..682df479809 --- /dev/null +++ b/buildSrc/src/test/resources/file-transfer/python-application-config/src/main/resources/bootstrap.properties @@ -0,0 +1 @@ +micronaut.config-client.enabled=true diff --git a/buildSrc/src/test/resources/file-transfer/python-application-config/src/test/resources/bootstrap-test.properties b/buildSrc/src/test/resources/file-transfer/python-application-config/src/test/resources/bootstrap-test.properties new file mode 100644 index 00000000000..8e850422cc3 --- /dev/null +++ b/buildSrc/src/test/resources/file-transfer/python-application-config/src/test/resources/bootstrap-test.properties @@ -0,0 +1 @@ +micronaut.config-client.enabled=false
${javaImg}${kotlinImg}${groovyImg}${pythonImg}${pythonImg}
${pyronautImg}Read