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_distribution_timing.py b/.github/scripts/test_distribution_timing.py new file mode 100644 index 00000000000..832b090159e --- /dev/null +++ b/.github/scripts/test_distribution_timing.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +BASE = Path(__file__).resolve().parents[2] / "guides/distribution-base" +LANGUAGES = ("python", "java", "groovy", "kotlin") +JVM_BRANCHES = (("java", "java"), ("java", "crac"), ("groovy", "java"), ("kotlin", "java")) +HARNESS = r''' +if [[ "$INHERIT_ERREXIT" == "yes" ]]; then + set -o posix +fi +curl() { + if [[ "$TIMING_RESULT" == "timeout" ]]; then + SECONDS=$((SECONDS + 21)) + return 1 + fi + return 0 +} +sleep() { :; } +docker() { + printf 'docker %s\n' "$*" >> "$COMMAND_LOG" + if [[ "$1" == "run" ]]; then + printf 'fake-container\n' + fi +} +native_fake() { printf 'native start\n' >> "$COMMAND_LOG"; } +java() { printf 'java start %s\n' "$*" >> "$COMMAND_LOG"; } +kill() { printf 'native cleanup %s\n' "$*" >> "$COMMAND_LOG"; } +source "$1" "${@:2}" +''' + + +class DistributionTimingTest(unittest.TestCase): + def run_timing(self, language: str, branch: str, result: str, inherit_errexit: bool = False): + with tempfile.TemporaryDirectory() as directory: + command_log = Path(directory) / "commands.log" + environment = os.environ | { + "COMMAND_LOG": str(command_log), + "TIMING_RESULT": result, + "INHERIT_ERREXIT": "yes" if inherit_errexit else "no", + } + arguments = { + "docker": ["-d", "fake-image"], "native": ["-n", "native_fake"], + "java": ["-j", "fake.jar"], "crac": ["-c", "fake-checkpoint"], + }[branch] + completed = subprocess.run( + ["bash", "-c", HARNESS, "timing-test", str(BASE / language / "ttfr.sh"), *arguments], + env=environment, text=True, capture_output=True, timeout=5, + ) + commands = command_log.read_text() if command_log.exists() else "" + cleanup = "docker container kill fake-container" if branch == "docker" else "native cleanup" + return completed, commands, cleanup + + def check_timeout(self, language: str, branch: str, inherit_errexit: bool = False): + completed, commands, cleanup = self.run_timing(language, branch, "timeout", inherit_errexit) + self.assertIn("No response from the app in 20 seconds", completed.stderr) + self.assertNotEqual(0, completed.returncode, completed.stdout) + self.assertIn(cleanup, commands) + self.assertNotIn(" seconds", completed.stdout) + + def test_docker_timeout(self): + for language in LANGUAGES: + with self.subTest(language=language): + self.check_timeout(language, "docker") + + def test_native_timeout(self): + for language in LANGUAGES: + with self.subTest(language=language): + self.check_timeout(language, "native") + + def test_docker_timeout_with_inherited_errexit(self): + for language in LANGUAGES: + with self.subTest(language=language): + self.check_timeout(language, "docker", True) + + def test_native_timeout_with_inherited_errexit(self): + for language in LANGUAGES: + with self.subTest(language=language): + self.check_timeout(language, "native", True) + + def test_jvm_timeout(self): + for language, branch in JVM_BRANCHES: + with self.subTest(language=language, branch=branch): + self.check_timeout(language, branch) + + def test_jvm_timeout_with_inherited_errexit(self): + for language, branch in JVM_BRANCHES: + with self.subTest(language=language, branch=branch): + self.check_timeout(language, branch, True) + + def check_success(self, language: str, branch: str): + completed, commands, cleanup = self.run_timing(language, branch, "success") + self.assertEqual(0, completed.returncode, completed.stderr) + self.assertEqual(1, commands.count(cleanup)) + self.assertIn(" seconds", completed.stdout) + + def test_docker_success(self): + for language in LANGUAGES: + with self.subTest(language=language): + self.check_success(language, "docker") + + def test_native_success(self): + for language in LANGUAGES: + with self.subTest(language=language): + self.check_success(language, "native") + + def test_jvm_success(self): + for language, branch in JVM_BRANCHES: + with self.subTest(language=language, branch=branch): + self.check_success(language, branch) + + +if __name__ == "__main__": + unittest.main() 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_python_guide_bases.py b/.github/scripts/test_python_guide_bases.py new file mode 100644 index 00000000000..0b104461710 --- /dev/null +++ b/.github/scripts/test_python_guide_bases.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + + +GUIDES = Path(__file__).resolve().parents[2] / "guides" + + +class PythonGuideBasesTest(unittest.TestCase): + def test_weather_tool_description_matches_java(self) -> None: + source = (GUIDES / "micronaut-mcp-weather/python/src/example/micronaut/tools.py").read_text(encoding="utf-8") + self.assertIn('description="Get weather forecast for specific latitude/longitude coordinates"', source) + + def test_openapi_toml_example_is_python_only(self) -> None: + source = (GUIDES / "micronaut-openapi-base/openapi-intro.adoc").read_text(encoding="utf-8") + self.assertIn( + ":only-for-languages:python\nresource:application.toml[tag=static-resources-swagger]\n:only-for-languages:", + source, + ) + + def test_storage_download_contract_allows_missing_object(self) -> None: + source = GUIDES / "micronaut-object-storage-base/python/src/example/micronaut/profile_pictures_api.py" + module = ast.parse(source.read_text(encoding="utf-8")) + api = next(node for node in module.body if isinstance(node, ast.ClassDef) and node.name == "ProfilePicturesApi") + download = next(node for node in api.body if isinstance(node, ast.FunctionDef) and node.name == "download") + self.assertEqual("HttpResponse[StreamedFile] | None", ast.unparse(download.returns)) + +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..025d5667e60 --- /dev/null +++ b/.github/workflows/python-guides.yml @@ -0,0 +1,43 @@ +name: Validate Python Guides +on: + push: + branches: + - master + paths: + - '.github/workflows/python-guides.yml' + - 'buildSrc/**' + - 'gradle/local-composite-builds.gradle' + - 'guides/**' + - 'settings.gradle' + - 'src/docs/common/**' + pull_request: + branches: + - master + paths: + - '.github/workflows/python-guides.yml' + - 'buildSrc/**' + - 'gradle/local-composite-builds.gradle' + - '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..47611fd67f2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,22 @@ To build a single guide, run the dynamic task created by `GuidesPlugin`; convert ./gradlew micronautHttpClientBuild ``` +### Use local composite builds + +Local Micronaut Core, Pyronaut, and Starter checkouts can be included with +either the matching Gradle property or environment variable: + +| Checkout | Gradle property | Environment variable | +| --- | --- | --- | +| Micronaut Core | `local.git.micronaut-core` | `LOCAL_GIT_MICRONAUT_CORE` | +| Pyronaut | `local.git.pyronaut` | `LOCAL_GIT_PYRONAUT` | +| Micronaut Starter | `local.git.micronaut-starter` | `LOCAL_GIT_MICRONAUT_STARTER` | + +For example: `./gradlew -Plocal.git.pyronaut=/path/to/pyronaut help`. +Gradle properties take precedence when both forms are set. Pyronaut's GraalPy +bundle repository is discovered from a local Core or Pyronaut checkout when +available; set `micronaut.graalpy.bundle.repo` to override it. + ## 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..9512488e84c 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -36,7 +36,7 @@ repositories { } ext { - micronautStarterVersion = "5.2.0" + micronautStarterVersion = "5.2.1" } dependencies { @@ -83,5 +83,5 @@ tasks.named('test', Test) { } tasks.withType(JavaCompile).configureEach { - options.compilerArgs.add("-Amicronaut.jsonschema.baseUri=https://micronaut-projects.github.io/micronaut-guides/schemas") // (1) + options.compilerArgs.add("-Amicronaut.jsonschema.baseUri=https://guides.micronaut.io/schemas") // (1) } diff --git a/buildSrc/settings.gradle b/buildSrc/settings.gradle new file mode 100644 index 00000000000..bf1a7b02461 --- /dev/null +++ b/buildSrc/settings.gradle @@ -0,0 +1,10 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "micronaut-guides-buildsrc" + +apply from: file('../gradle/local-composite-builds.gradle') 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..657d8609227 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/GuidesPlugin.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/GuidesPlugin.groovy @@ -15,14 +15,17 @@ 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 +import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.Transformer import org.gradle.api.file.Directory +import org.gradle.api.initialization.IncludedBuild import org.gradle.api.provider.Provider import org.gradle.api.tasks.Copy import org.gradle.api.tasks.TaskProvider @@ -36,6 +39,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,17 +54,55 @@ 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" private static final String COMMA = "," private static final String TASK_SUFFIX_BUILD = "Build" + private static final String LOCAL_GIT_PYRONAUT_PROPERTY = "local.git.pyronaut" + private static final String LOCAL_GIT_PYRONAUT_ENV = "LOCAL_GIT_PYRONAUT" + private static final String LOCAL_PYRONAUT_CORE_VERSION_PROPERTY = "local.pyronaut.core.version" + private static final String LOCAL_PYRONAUT_CORE_VERSION_ENV = "LOCAL_PYRONAUT_CORE_VERSION" + private static final String LOCAL_PYRONAUT_PLATFORM_VERSION_PROPERTY = "local.pyronaut.platform.version" + private static final String LOCAL_PYRONAUT_PLATFORM_VERSION_ENV = "LOCAL_PYRONAUT_PLATFORM_VERSION" + private static final String DEFAULT_LOCAL_PYRONAUT_CORE_VERSION = "5.2.3" + private static final String DEFAULT_LOCAL_PYRONAUT_PLATFORM_VERSION = "5.1.0" + private static final String PYRONAUT_INCLUDED_BUILD_NAME = "pyronaut" + private static final String PYRONAUT_FIXTURE_REPOSITORY = "functional-test/build/fixture-repo" + private static final String PYRONAUT_INSTALL_EXECUTABLE = "pyronaut-install/build/install/micronaut-pyronaut-install/bin/pyronaut-install" + private static final String PYRONAUT_VALIDATE_CONFIG_EXECUTABLE = "pyronaut-validate-config/build/install/micronaut-pyronaut-validate-config/bin/pyronaut-validate-config" + private static final String PYRONAUT_PROCESS_EXECUTABLE = "pyronaut-processor/build/install/micronaut-pyronaut-processor/bin/pyronaut-processor" + private static final String PYRONAUT_TEST_EXECUTABLE = "pyronaut-test/build/install/micronaut-pyronaut-test/bin/pyronaut-test" + private static final String PYRONAUT_TEST_RESOURCES_SERVER_EXECUTABLE = "pyronaut-test-resources-server/build/install/micronaut-pyronaut-test-resources-server/bin/pyronaut-test-resources-server" + private static final String PYRONAUT_CLI_PYTHONPATH = "pyronaut/src/main/python" + private static final String PYRONAUT_FIXTURE_LAUNCHER_TASK = ":micronaut-functional-test:installFixtureLaunchers" + private static final List PYRONAUT_FIXTURE_STAGE_TASKS = List.of( + ":micronaut-functional-test:stagePyronautFixtureArtifacts", + ":micronaut-functional-test:stageMicronautPlatformFixtureArtifact", + ":micronaut-functional-test:stageMicronautCoreFixtureArtifacts", + ":micronaut-functional-test:stageMicronautDataFixtureArtifacts", + ":micronaut-functional-test:stageSourcegenFixtureArtifacts", + ":micronaut-functional-test:stageIncludedCoreExternalFixtureArtifacts", + ":micronaut-functional-test:stageMicronautTestFixtureArtifacts" + ) @Override void apply(Project project) { GuideProjectGenerator projectGenerator = new GuideProjectGenerator() Directory guidesDir = project.layout.projectDirectory.dir("guides") Provider codeDir = project.layout.buildDirectory.dir("code") + Provider localPyronautPath = localGitPath(project, LOCAL_GIT_PYRONAUT_PROPERTY, LOCAL_GIT_PYRONAUT_ENV) + Provider localPyronautCoreVersion = configuredValue(project, LOCAL_PYRONAUT_CORE_VERSION_PROPERTY, LOCAL_PYRONAUT_CORE_VERSION_ENV, DEFAULT_LOCAL_PYRONAUT_CORE_VERSION) + Provider localPyronautPlatformVersion = configuredValue(project, LOCAL_PYRONAUT_PLATFORM_VERSION_PROPERTY, LOCAL_PYRONAUT_PLATFORM_VERSION_ENV, DEFAULT_LOCAL_PYRONAUT_PLATFORM_VERSION) + Provider localPyronautRepository = localPyronautPath.map(path -> new File(path, PYRONAUT_FIXTURE_REPOSITORY).absolutePath) + Provider localPyronautInstallExecutable = localPyronautPath.map(path -> new File(path, PYRONAUT_INSTALL_EXECUTABLE).absolutePath) + Provider localPyronautValidateConfigExecutable = localPyronautPath.map(path -> new File(path, PYRONAUT_VALIDATE_CONFIG_EXECUTABLE).absolutePath) + Provider localPyronautProcessExecutable = localPyronautPath.map(path -> new File(path, PYRONAUT_PROCESS_EXECUTABLE).absolutePath) + Provider localPyronautTestExecutable = localPyronautPath.map(path -> new File(path, PYRONAUT_TEST_EXECUTABLE).absolutePath) + Provider localPyronautTestResourcesServerExecutable = localPyronautPath.map(path -> new File(path, PYRONAUT_TEST_RESOURCES_SERVER_EXECUTABLE).absolutePath) + Provider localPyronautCliPythonPath = localPyronautPath.map(path -> new File(path, PYRONAUT_CLI_PYTHONPATH).absolutePath) + TaskProvider stageLocalPyronautArtifactsTask = registerStageLocalPyronautArtifactsTask(project, localPyronautRepository) Properties testProps = guidesDir.file("tests.properties").asFile.withInputStream { inputStream -> new Properties().tap { load(inputStream) @@ -78,32 +120,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 +155,59 @@ 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, + stageLocalPyronautArtifactsTask, + localPyronautRepository, + localPyronautCoreVersion, + localPyronautPlatformVersion, + localPyronautInstallExecutable, + localPyronautValidateConfigExecutable, + localPyronautProcessExecutable, + localPyronautTestExecutable, + localPyronautTestResourcesServerExecutable, + localPyronautCliPythonPath + ) + 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 +221,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 +249,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 +264,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 +305,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 +319,42 @@ class GuidesPlugin implements Plugin { } } + private static Provider localGitPath(Project project, + String propertyName, + String environmentName) { + project.providers.gradleProperty(propertyName) + .orElse(project.providers.environmentVariable(environmentName)) + } + + private static Provider configuredValue(Project project, + String propertyName, + String environmentName, + String defaultValue) { + project.providers.gradleProperty(propertyName) + .orElse(project.providers.environmentVariable(environmentName)) + .orElse(defaultValue) + } + + private static TaskProvider registerStageLocalPyronautArtifactsTask(Project project, + Provider localPyronautRepository) { + IncludedBuild includedBuild = project.gradle.includedBuilds.find { IncludedBuild build -> + build.name == PYRONAUT_INCLUDED_BUILD_NAME + } + project.tasks.register("stageLocalPyronautArtifacts") { Task it -> + it.group = "build setup" + it.description = "Stages Pyronaut artifacts from the local included Pyronaut checkout into its fixture repository." + it.outputs.dir(localPyronautRepository.map(path -> new File(path))) + if (includedBuild != null) { + it.dependsOn(PYRONAUT_FIXTURE_STAGE_TASKS.collect { String taskPath -> includedBuild.task(taskPath) }) + it.dependsOn(includedBuild.task(PYRONAUT_FIXTURE_LAUNCHER_TASK)) + } else { + it.doFirst { + throw new GradleException("Python guide tests require an included '${PYRONAUT_INCLUDED_BUILD_NAME}' build. Configure ${LOCAL_GIT_PYRONAUT_PROPERTY} or ${LOCAL_GIT_PYRONAUT_ENV}.") + } + } + } + } + /** * https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore */ @@ -279,7 +425,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 +434,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 +522,46 @@ class GuidesPlugin implements Plugin { } } + private static TaskProvider registerPythonTestScriptRunnerTask(Project project, + String taskSlug, + Guide metadata, + TaskProvider pythonTestScriptTask, + TaskProvider stageLocalPyronautArtifactsTask, + Provider localPyronautRepository, + Provider localPyronautCoreVersion, + Provider localPyronautPlatformVersion, + Provider localPyronautInstallExecutable, + Provider localPyronautValidateConfigExecutable, + Provider localPyronautProcessExecutable, + Provider localPyronautTestExecutable, + Provider localPyronautTestResourcesServerExecutable, + Provider localPyronautCliPythonPath) { + project.tasks.register("${taskSlug}RunPythonTestScript", TestScriptRunnerTask) { TestScriptRunnerTask it -> + it.onlyIf { !Utils.skipBecauseOfJavaVersion(metadata) } + + 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.environment.put("PYRONAUT_LOCAL_REPOSITORY", localPyronautRepository) + it.environment.put("PYRONAUT_LOCAL_CORE_VERSION", localPyronautCoreVersion) + it.environment.put("PYRONAUT_LOCAL_PLATFORM_VERSION", localPyronautPlatformVersion) + it.environment.put("PYRONAUT_INSTALL_EXECUTABLE", localPyronautInstallExecutable) + it.environment.put("PYRONAUT_VALIDATE_CONFIG_EXECUTABLE", localPyronautValidateConfigExecutable) + it.environment.put("PYRONAUT_PROCESS_EXECUTABLE", localPyronautProcessExecutable) + it.environment.put("PYRONAUT_PROCESSOR_EXECUTABLE", localPyronautProcessExecutable) + it.environment.put("PYRONAUT_TEST_EXECUTABLE", localPyronautTestExecutable) + it.environment.put("PYRONAUT_TEST_RESOURCES_SERVER_EXECUTABLE", localPyronautTestResourcesServerExecutable) + it.environment.put("PYRONAUT_CLI_PYTHONPATH", localPyronautCliPythonPath) + 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"))) + it.dependsOn(stageLocalPyronautArtifactsTask) + } + } + private static TaskProvider registerDocTask(Project project, Guide metadata, Directory guidesDir, @@ -443,21 +646,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 +778,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 +790,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..63b4cbf32cf 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,22 @@ 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.GROOVY; +import static io.micronaut.starter.options.Language.PYTHON; @Singleton public class DefaultGuideProjectGenerator implements GuideProjectGenerator { @@ -86,7 +92,8 @@ public void generate(@NonNull File outputDirectory, @NonNull JdkVersion javaVersion, @NonNull App app) throws IOException { List appFeatures = new ArrayList<>(GuideUtils.getAppFeatures(app, guidesOption.getLanguage())); - if (!guidesConfiguration.getJdkVersionsSupportedByGraalvm().contains(javaVersion)) { + if (guidesOption.getLanguage() == GROOVY || + !guidesConfiguration.getJdkVersionsSupportedByGraalvm().contains(javaVersion)) { appFeatures.remove("graalvm"); } @@ -114,12 +121,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..6b5faf3647b 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,49 @@ 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"); + 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/java/io/micronaut/guides/feature/H2Runtime.java b/buildSrc/src/main/java/io/micronaut/guides/feature/H2Runtime.java new file mode 100644 index 00000000000..be704798909 --- /dev/null +++ b/buildSrc/src/main/java/io/micronaut/guides/feature/H2Runtime.java @@ -0,0 +1,11 @@ +package io.micronaut.guides.feature; + +import io.micronaut.starter.build.dependencies.Scope; +import jakarta.inject.Singleton; + +@Singleton +public class H2Runtime extends AbstractFeature { + protected H2Runtime() { + super("h2-runtime", "h2", Scope.RUNTIME); + } +} diff --git a/buildSrc/src/main/java/io/micronaut/guides/feature/SpringSecurityCrypto.java b/buildSrc/src/main/java/io/micronaut/guides/feature/SpringSecurityCrypto.java new file mode 100644 index 00000000000..7c5900ef406 --- /dev/null +++ b/buildSrc/src/main/java/io/micronaut/guides/feature/SpringSecurityCrypto.java @@ -0,0 +1,27 @@ +package io.micronaut.guides.feature; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.starter.application.generator.GeneratorContext; +import io.micronaut.starter.build.dependencies.Dependency; +import io.micronaut.starter.build.dependencies.Scope; +import jakarta.inject.Singleton; + +@Requires(missingClasses = "io.micronaut.starter.feature.security.SpringSecurityCrypto") +@Singleton +public class SpringSecurityCrypto extends AbstractFeature { + + public SpringSecurityCrypto() { + super("spring-security-crypto"); + } + + @Override + public void apply(GeneratorContext generatorContext) { + super.apply(generatorContext); + + generatorContext.addDependency(Dependency.builder() + .groupId("org.slf4j") + .artifactId("jcl-over-slf4j") + .scope(Scope.RUNTIME) + .build()); + } +} 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/pom.xml b/buildSrc/src/main/resources/pom.xml index c69fd8a95d3..1595ddda82f 100644 --- a/buildSrc/src/main/resources/pom.xml +++ b/buildSrc/src/main/resources/pom.xml @@ -12,6 +12,10 @@ + + com.h2database + h2 + org.skyscreamer jsonassert 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..1d937912192 --- /dev/null +++ b/buildSrc/src/main/resources/pyronaut-test-functions.sh @@ -0,0 +1,223 @@ +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_local_repositories () { + if [ -z "${PYRONAUT_LOCAL_REPOSITORY:-}" ]; then + return 0 + fi + + "$(pyronaut_repo_python)" -c 'exec(__import__("sys").stdin.read())' "$PYRONAUT_LOCAL_REPOSITORY" <<'PY' +from pathlib import Path +import json +import re +import sys + +repository = sys.argv[1] +pyproject = Path("pyproject.toml") +text = pyproject.read_text(encoding="utf-8") +section = "[tool.pyronaut]" +section_index = text.find(section) +if section_index < 0: + raise SystemExit("Missing [tool.pyronaut] 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] +repositories = f'repositories = [{json.dumps(repository)}, "mavenCentral", "https://central.sonatype.com/repository/maven-snapshots/"]' +section_text, count = re.subn(r'(?ms)^repositories\s*=\s*\[[^\]]*\]', repositories, section_text, count=1) +if count == 0: + section_text = section_text.replace(section + "\n", section + "\n" + repositories + "\n", 1) +text = text[:section_index] + section_text + text[next_section_index:] +pyproject.write_text(text, encoding="utf-8") +PY +} + +set_pyronaut_local_versions () { + if [ -z "${PYRONAUT_LOCAL_CORE_VERSION:-}" ] && [ -z "${PYRONAUT_LOCAL_PLATFORM_VERSION:-}" ]; then + return 0 + fi + + "$(pyronaut_repo_python)" -c 'exec(__import__("sys").stdin.read())' "${PYRONAUT_LOCAL_CORE_VERSION:-}" "${PYRONAUT_LOCAL_PLATFORM_VERSION:-}" <<'PY' +from pathlib import Path +import re +import sys + +core_version = sys.argv[1] +platform_version = sys.argv[2] +pyproject = Path("pyproject.toml") +text = pyproject.read_text(encoding="utf-8") + +def set_section_version(source, section, version): + if not version: + return source + section_index = source.find(section) + if section_index < 0: + raise SystemExit(f"Missing {section} in pyproject.toml") + + next_section_index = source.find("\n[", section_index + len(section)) + if next_section_index < 0: + next_section_index = len(source) + section_text = source[section_index:next_section_index] + replacement = f'version = "{version}"' + section_text, count = re.subn(r'(?m)^version\s*=\s*"[^"]*"', replacement, section_text, count=1) + if count == 0: + section_text = section_text.replace(section + "\n", section + "\n" + replacement + "\n", 1) + return source[:section_index] + section_text + source[next_section_index:] + +text = set_section_version(text, "[tool.pyronaut.core]", core_version) +text = set_section_version(text, "[tool.pyronaut.platform]", platform_version) +pyproject.write_text(text, encoding="utf-8") +PY +} + +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 +} + +set_pyronaut_native_toolchain () { + if [ "${PYRONAUT_USE_NATIVE_TOOLCHAIN:-}" != "true" ]; then + return 0 + fi + + "$(pyronaut_repo_python)" -c 'exec(__import__("sys").stdin.read())' <<'PY' +from pathlib import Path + +pyproject = Path("pyproject.toml") +text = pyproject.read_text(encoding="utf-8") +section = "[tool.pyronaut.toolchain]" +section_index = text.find(section) +if section_index < 0: + separator = "" if not text or text.endswith("\n") else "\n" + text += f'{separator}\n{section}\ntype = "native"\n' +else: + 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] + lines = section_text.splitlines(keepends=True) + for index, line in enumerate(lines): + if line.lstrip().startswith('type') and '=' in line: + line_ending = '\n' if line.endswith('\n') else '' + lines[index] = 'type = "native"' + line_ending + break + else: + lines.insert(1, 'type = "native"\n') + section_text = "".join(lines) + text = text[:section_index] + section_text + text[next_section_index:] +pyproject.write_text(text, encoding="utf-8") +PY +} + +set_pyronaut_project_venv () { + if [ -e .venv ] || [ -L .venv ] || [ -z "${VIRTUAL_ENV:-}" ] || [ ! -x "$VIRTUAL_ENV/bin/python" ]; then + return 0 + fi + ln -s "$VIRTUAL_ENV" .venv +} + +pyronaut_dependency_cache () { + echo "${PYRONAUT_DEPENDENCY_CACHE:-.pyronaut-m2}" +} + +run_pyronaut_install () { + local install_args=() + if [ -n "${PYRONAUT_LOCAL_REPOSITORY:-}" ]; then + install_args+=(--local-repository "$(pyronaut_dependency_cache)") + if [ "${PYRONAUT_REFRESH_DEPENDENCIES:-true}" = "true" ]; then + install_args+=(--refresh) + fi + fi + + if [ -n "${PYRONAUT_INSTALL_EXECUTABLE:-}" ]; then + "$PYRONAUT_INSTALL_EXECUTABLE" "${install_args[@]}" + else + pyronaut install "${install_args[@]}" + fi +} + +run_pyronaut_validate_config () { + if [ -n "${PYRONAUT_VALIDATE_CONFIG_EXECUTABLE:-}" ]; then + "$PYRONAUT_VALIDATE_CONFIG_EXECUTABLE" + else + pyronaut validate-config + fi +} + +run_pyronaut_process () { + if [ -n "${PYRONAUT_PROCESS_EXECUTABLE:-}" ]; then + "$PYRONAUT_PROCESS_EXECUTABLE" + else + pyronaut process + fi +} + +run_pyronaut_cli () { + if [ -n "${PYRONAUT_CLI_PYTHONPATH:-}" ] && [ -d "$PYRONAUT_CLI_PYTHONPATH" ]; then + PYTHONPATH="${PYRONAUT_CLI_PYTHONPATH}${PYTHONPATH:+:$PYTHONPATH}" "$(pyronaut_repo_python)" -m pyronaut_cli_v2 "$@" + else + pyronaut "$@" + fi +} + +run_pyronaut_test () { + local test_args=() + if [ -n "${PYRONAUT_LOCAL_REPOSITORY:-}" ]; then + test_args+=(--local-repository "$(pyronaut_dependency_cache)") + fi + run_pyronaut_cli test "${test_args[@]}" +} + +run_pyronaut_tests () { + if [ -z "${PYRONAUT_LOCAL_REPOSITORY:-}" ]; then + set_pyronaut_local_versions + set_pyronaut_test_resources + set_pyronaut_native_toolchain + set_pyronaut_project_venv + run_pyronaut_install && run_pyronaut_validate_config && run_pyronaut_process && run_pyronaut_test + return $? + fi + + local tmp_dir + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/pyronaut-guide.XXXXXX")" + tar --exclude='./.micronaut' --exclude='./__pyronaut__' --exclude='./.pytest_cache' --exclude='./build' --exclude='./dist' --exclude='./*.egg-info' -cf - . | (cd "$tmp_dir" && tar -xf -) + ( + cd "$tmp_dir" + set_pyronaut_local_repositories + set_pyronaut_local_versions + set_pyronaut_test_resources + set_pyronaut_native_toolchain + set_pyronaut_project_venv + run_pyronaut_install && run_pyronaut_validate_config && run_pyronaut_process && run_pyronaut_test + ) + local result=$? + rm -rf "$tmp_dir" + return $result +} 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..a7ecbbad9fc --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/GuidesPluginTest.java @@ -0,0 +1,78 @@ +package io.micronaut.guides; + +import io.micronaut.guides.tasks.AsciidocGenerationTask; +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 static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertAll; + +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()); + } + + private Project project() 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", + "languages": ["JAVA", "GROOVY", "KOTLIN", "PYTHON"], + "apps": [{"name": "default", "features": []}] + } + """); + 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/AppTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/AppTest.java index d080db0041a..203acd40d52 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/AppTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/AppTest.java @@ -92,7 +92,7 @@ void buildGeneratesCorrectJsonSchema(ResourceLoader resourceLoader) throws IOExc String expected = """ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://micronaut-projects.github.io/micronaut-guides/schemas/app.schema.json", + "$id": "https://guides.micronaut.io/schemas/app.schema.json", "title": "App", "type": "object", "properties": { 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..f14bdb0f4bc 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,7 +87,14 @@ void pythonDependencyMacrosAreOmitted() { assertEquals(""" Before - + [source, toml] + .pyproject.toml + ---- + [tool.pyronaut.dependencies] + runtime = [ + "io.micronaut.views:micronaut-views-thymeleaf", + ] + ---- After """, result); 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/GuideProjectGeneratorTest.java b/buildSrc/src/test/java/io/micronaut/guides/core/GuideProjectGeneratorTest.java index 09d06759bf7..a19693bab5b 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/GuideProjectGeneratorTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/GuideProjectGeneratorTest.java @@ -8,14 +8,17 @@ import jakarta.inject.Inject; import org.gradle.api.JavaVersion; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.File; import java.nio.file.Paths; +import java.nio.file.Path; import java.time.LocalDate; import java.util.List; import java.util.Map; import static io.micronaut.guides.core.TestUtils.readFile; +import static io.micronaut.starter.options.JdkVersion.JDK_25; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -215,5 +218,28 @@ void testGenerateOracleAtpMavenProjectWithOracleFreeTestResources() { assertTrue(result.contains("micronaut-test-resources-jdbc-oracle-free"), result); } + @Test + void testGenerateOracleAtpPythonProjectWithH2Runtime(@TempDir Path outputDirectory) throws Exception { + App app = new App( + "default", "example.micronaut", ApplicationType.DEFAULT, "Micronaut", + List.of("data-jdbc", "flyway", "oracle-cloud-atp"), + List.of(), List.of(), List.of(), List.of(), null, null, null, null, false, + List.of("h2-runtime") + ); + Guide guide = GuideTestUtils.guideWithSlug("oracle-atp-h2-runtime"); + GuidesOption option = new GuidesOption(BuildTool.PYRONAUT, Language.PYTHON, TestFramework.PYTEST); + + ((DefaultGuideProjectGenerator) guideProjectGenerator).generate( + outputDirectory.toFile(), guide, option, JDK_25, app); + + File dest = outputDirectory.resolve(MacroUtils.getSourceDir(guide.slug(), option)).toFile(); + String result = readFile(new File(dest, "pyproject.toml")); + String runtime = result.substring(result.indexOf("runtime = ["), result.indexOf("build = [")); + assertTrue(runtime.contains("\"com.h2database:h2\""), result); + assertTrue(runtime.contains("\"io.micronaut.oraclecloud:micronaut-oraclecloud-atp\""), result); + String configuration = readFile(new File(dest, "config/application.toml")); + assertTrue(configuration.contains("dialect = 'ORACLE'"), configuration); + } + } 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..4ee8579b058 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"); @@ -221,17 +230,16 @@ void buildGeneratesCorrectJsonSchema(ResourceLoader resourceLoader) throws IOExc String expected = """ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://micronaut-projects.github.io/micronaut-guides/schemas/guide.schema.json", + "$id": "https://guides.micronaut.io/schemas/guide.schema.json", "title": "Guide", "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 + "$ref": "https://guides.micronaut.io/schemas/app.schema.json" + } }, "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..4ef0572f49a 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,72 @@ 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("PYRONAUT_LOCAL_REPOSITORY")); + assertTrue(result.contains("PYRONAUT_LOCAL_CORE_VERSION")); + assertTrue(result.contains("PYRONAUT_LOCAL_PLATFORM_VERSION")); + assertTrue(result.contains("PYRONAUT_INSTALL_EXECUTABLE")); + assertTrue(result.contains("PYRONAUT_REFRESH_DEPENDENCIES")); + assertTrue(result.contains("PYRONAUT_DEPENDENCY_CACHE:-.pyronaut-m2")); + assertTrue(result.contains("PYRONAUT_PROCESS_EXECUTABLE")); + assertTrue(result.contains("PYRONAUT_CLI_PYTHONPATH")); + assertTrue(result.contains("set_pyronaut_local_versions")); + int runPyronautTests = result.indexOf("run_pyronaut_tests () {"); + assertTrue(runPyronautTests >= 0); + int directBranch = result.indexOf("if [ -z \"${PYRONAUT_LOCAL_REPOSITORY:-}\" ]; then", runPyronautTests); + int tempBranch = result.indexOf("local tmp_dir", directBranch); + String directBranchScript = result.substring(directBranch, tempBranch); + int localVersionsInDirectBranch = directBranchScript.indexOf("set_pyronaut_local_versions"); + int testResourcesInDirectBranch = directBranchScript.indexOf("set_pyronaut_test_resources"); + assertTrue(localVersionsInDirectBranch >= 0 + && localVersionsInDirectBranch < testResourcesInDirectBranch); + assertTrue(result.contains("[tool.pyronaut.core]")); + assertTrue(result.contains("[tool.pyronaut.platform]")); + assertFalse(result.contains("PYRONAUT_LOCAL_VERSION")); + assertTrue(result.contains("run_pyronaut_install")); + assertTrue(result.contains("run_pyronaut_process")); + assertTrue(result.contains("run_pyronaut_cli")); + assertTrue(result.contains("test_args+=(--local-repository \"$(pyronaut_dependency_cache)\")")); + assertTrue(result.contains("run_pyronaut_cli test \"${test_args[@]}\"")); + assertFalse(result.contains("\"$PYRONAUT_TEST_EXECUTABLE\"")); + assertTrue(result.contains("set_pyronaut_local_repositories")); + 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")); + assertTrue(result.contains("tar --exclude='./.micronaut'")); + assertFalse(result.contains("./gradlew -q check")); + } + @Test void testGenerateNative() { String path = "src/test/resources/guides"; @@ -134,17 +201,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 diff --git a/gradle/local-composite-builds.gradle b/gradle/local-composite-builds.gradle new file mode 100644 index 00000000000..c4d419495f2 --- /dev/null +++ b/gradle/local-composite-builds.gradle @@ -0,0 +1,151 @@ +def micronautCoreModules = [ + 'micronaut-aop', + 'micronaut-buffer-netty', + 'micronaut-context', + 'micronaut-context-propagation', + 'micronaut-context-python', + 'micronaut-core', + 'micronaut-core-bom', + 'micronaut-core-processor', + 'micronaut-core-reactive', + 'micronaut-discovery-core', + 'micronaut-function', + 'micronaut-function-client', + 'micronaut-function-web', + 'micronaut-graal', + 'micronaut-http', + 'micronaut-http-client', + 'micronaut-http-client-core', + 'micronaut-http-client-jdk', + 'micronaut-http-netty', + 'micronaut-http-netty-http3', + 'micronaut-http-server', + 'micronaut-http-server-netty', + 'micronaut-http-validation', + 'micronaut-inject', + 'micronaut-inject-groovy', + 'micronaut-inject-java', + 'micronaut-inject-kotlin', + 'micronaut-inject-python', + 'micronaut-jackson-core', + 'micronaut-jackson-databind', + 'micronaut-json-core', + 'micronaut-management', + 'micronaut-messaging', + 'micronaut-module-info', + 'micronaut-module-info-runtime', + 'micronaut-retry', + 'micronaut-router', + 'micronaut-runtime', + 'micronaut-runtime-osx', + 'micronaut-websocket' +] + +def micronautStarterModules = [ + 'micronaut-starter-core': ':starter-core', + 'micronaut-starter-api': ':starter-api', + 'micronaut-starter-aws-cdk': ':starter-aws-cdk', + 'micronaut-starter-aws-lambda': ':starter-aws-lambda', + 'micronaut-starter-azure-function': ':starter-azure-function', + 'micronaut-starter-gcp-function': ':starter-gcp-function', + 'micronaut-starter-web-netty': ':starter-web-netty', + 'micronaut-starter-web-servlet': ':starter-web-servlet', + 'micronaut-cli': ':micronaut-cli' +] + +def pyronautModules = [ + 'micronaut-pyronaut': ':micronaut-pyronaut', + 'micronaut-pyronaut-bom': ':micronaut-pyronaut-bom', + 'micronaut-pyronaut-config-model': ':micronaut-pyronaut-config-model', + 'micronaut-pyronaut-install': ':micronaut-pyronaut-install', + 'micronaut-pyronaut-logging': ':micronaut-pyronaut-logging', + 'micronaut-pyronaut-logback': ':micronaut-pyronaut-logback', + 'micronaut-pyronaut-native-build': ':micronaut-pyronaut-native-build', + 'micronaut-pyronaut-processor': ':micronaut-pyronaut-processor', + 'micronaut-pyronaut-pytest': ':micronaut-pyronaut-pytest', + 'micronaut-pyronaut-requests': ':micronaut-pyronaut-requests', + 'micronaut-pyronaut-run': ':micronaut-pyronaut-run', + 'micronaut-pyronaut-test': ':micronaut-pyronaut-test', + 'micronaut-pyronaut-test-resources-server': ':micronaut-pyronaut-test-resources-server', + 'micronaut-pyronaut-tui': ':micronaut-pyronaut-tui', + 'micronaut-pyronaut-validate-config': ':micronaut-pyronaut-validate-config' +] + +def localGitPath = { String propertyName, String environmentName -> + providers.gradleProperty(propertyName).orNull ?: providers.environmentVariable(environmentName).orNull +} + +def discoverGraalPyBundleRepository = { File checkoutDir -> + File bundleRoot = new File(checkoutDir, 'checkouts/maven-bundles/graalpy') + if (!bundleRoot.isDirectory()) { + return null + } + List candidates = bundleRoot.listFiles() + ?.findAll { File file -> + file.isDirectory() && + new File(file, 'org/graalvm/python/python').exists() && + new File(file, 'org/graalvm/python/python-embedding').exists() + } + ?.sort { File left, File right -> right.lastModified() <=> left.lastModified() } ?: [] + candidates.isEmpty() ? null : candidates.first().absolutePath +} + +def isUsableGraalPyBundleRepository = { String repository -> + repository != null && + new File(repository, 'org/graalvm/python/python').exists() && + new File(repository, 'org/graalvm/python/python-embedding').exists() +} + +def configureGraalPyBundleRepository = { File checkoutDir -> + if (isUsableGraalPyBundleRepository(System.getProperty('micronaut.graalpy.bundle.repo'))) { + return + } + String repository = discoverGraalPyBundleRepository(checkoutDir) + if (repository != null) { + System.setProperty('micronaut.graalpy.bundle.repo', repository) + System.setProperty('pyronaut.graalpy.bundle.repo', repository) + System.setProperty('org.gradle.project.micronaut.graalpy.bundle.repo', repository) + System.setProperty('org.gradle.project.pyronaut.graalpy.bundle.repo', repository) + } +} + +if (providers.gradleProperty('micronaut.include.graalpy.extensions').orNull == null && + System.getProperty('org.gradle.project.micronaut.include.graalpy.extensions') == null) { + System.setProperty('org.gradle.project.micronaut.include.graalpy.extensions', 'false') +} + +def includeLocalBuild = { String buildName, String group, File directory, Map modules -> + if (directory?.isDirectory() && new File(directory, '.git').exists()) { + includeBuild(directory) { + name = buildName + dependencySubstitution { + modules.each { artifactId, projectPath -> + substitute(module("${group}:${artifactId}")).using(project(projectPath)) + } + } + } + } +} + +def localMicronautCore = localGitPath('local.git.micronaut-core', 'LOCAL_GIT_MICRONAUT_CORE') +File localMicronautCoreDir = localMicronautCore ? file(localMicronautCore) : null +if (localMicronautCoreDir != null) { + configureGraalPyBundleRepository(localMicronautCoreDir) +} +includeLocalBuild( + 'micronaut-core', + 'io.micronaut', + localMicronautCoreDir, + micronautCoreModules.collectEntries { [(it): ":${it}"] } +) + +def localPyronaut = localGitPath('local.git.pyronaut', 'LOCAL_GIT_PYRONAUT') +File localPyronautDir = localPyronaut ? file(localPyronaut) : null +if (localPyronautDir != null) { + configureGraalPyBundleRepository(localPyronautDir) +} +includeLocalBuild('pyronaut', 'io.micronaut.pyronaut', localPyronautDir, pyronautModules) + +def localMicronautStarter = localGitPath('local.git.micronaut-starter', 'LOCAL_GIT_MICRONAUT_STARTER') +File localMicronautStarterDir = localMicronautStarter ? file(localMicronautStarter) : null +includeLocalBuild('micronaut-starter', 'io.micronaut.starter', localMicronautStarterDir, micronautStarterModules) diff --git a/guides/distribution-base/groovy/ttfr.sh b/guides/distribution-base/groovy/ttfr.sh index 55e0e25f8bf..f3ba334018f 100644 --- a/guides/distribution-base/groovy/ttfr.sh +++ b/guides/distribution-base/groovy/ttfr.sh @@ -49,7 +49,7 @@ execute() { mytime() { exec 3>&1 4>&2 - mytime=$(TIMEFORMAT="%3R"; { time $1 1>&3 2>&4; } 2>&1) + mytime=$(TIMEFORMAT="%3R"; { time $1 1>&3 2>&4; } 2>&1) || return $? exec 3>&- 4>&- echo $mytime } @@ -57,17 +57,23 @@ mytime() { if [[ "$TYPE" == "java" ]]; then java -jar $1 & PID=$! + trap 'kill -9 "$PID"' EXIT TTFR=$(mytime execute) kill -9 $PID + trap - EXIT elif [[ "$TYPE" == "docker" ]]; then CONTAINER=$(docker run -d --rm -p $PORT:$PORT --privileged $1) + trap 'docker container kill "$CONTAINER" > /dev/null' EXIT TTFR=$(mytime execute) docker container kill $CONTAINER > /dev/null + trap - EXIT else $1 & PID=$! + trap 'kill -9 "$PID"' EXIT TTFR=$(mytime execute) kill -9 $PID + trap - EXIT fi if [ "$TTFR" != "" ]; then diff --git a/guides/distribution-base/java/ttfr.sh b/guides/distribution-base/java/ttfr.sh index 47bce240f9a..37511ec6f16 100644 --- a/guides/distribution-base/java/ttfr.sh +++ b/guides/distribution-base/java/ttfr.sh @@ -51,7 +51,7 @@ execute() { mytime() { exec 3>&1 4>&2 - mytime=$(TIMEFORMAT="%3R"; { time $1 1>&3 2>&4; } 2>&1) + mytime=$(TIMEFORMAT="%3R"; { time $1 1>&3 2>&4; } 2>&1) || return $? exec 3>&- 4>&- echo $mytime } @@ -59,22 +59,30 @@ mytime() { if [[ "$TYPE" == "crac" ]]; then java -XX:CRaCRestoreFrom=$1 & PID=$! + trap 'kill -9 "$PID"' EXIT TTFR=$(mytime execute) kill -9 $PID + trap - EXIT elif [[ "$TYPE" == "java" ]]; then java -jar $1 & PID=$! + trap 'kill -9 "$PID"' EXIT TTFR=$(mytime execute) kill -9 $PID + trap - EXIT elif [[ "$TYPE" == "docker" ]]; then CONTAINER=$(docker run -d --rm -p $PORT:$PORT --privileged $1) + trap 'docker container kill "$CONTAINER" > /dev/null' EXIT TTFR=$(mytime execute) docker container kill $CONTAINER > /dev/null + trap - EXIT else $1 & PID=$! + trap 'kill -9 "$PID"' EXIT TTFR=$(mytime execute) kill -9 $PID + trap - EXIT fi if [ "$TTFR" != "" ]; then diff --git a/guides/distribution-base/kotlin/ttfr.sh b/guides/distribution-base/kotlin/ttfr.sh index 55e0e25f8bf..f3ba334018f 100644 --- a/guides/distribution-base/kotlin/ttfr.sh +++ b/guides/distribution-base/kotlin/ttfr.sh @@ -49,7 +49,7 @@ execute() { mytime() { exec 3>&1 4>&2 - mytime=$(TIMEFORMAT="%3R"; { time $1 1>&3 2>&4; } 2>&1) + mytime=$(TIMEFORMAT="%3R"; { time $1 1>&3 2>&4; } 2>&1) || return $? exec 3>&- 4>&- echo $mytime } @@ -57,17 +57,23 @@ mytime() { if [[ "$TYPE" == "java" ]]; then java -jar $1 & PID=$! + trap 'kill -9 "$PID"' EXIT TTFR=$(mytime execute) kill -9 $PID + trap - EXIT elif [[ "$TYPE" == "docker" ]]; then CONTAINER=$(docker run -d --rm -p $PORT:$PORT --privileged $1) + trap 'docker container kill "$CONTAINER" > /dev/null' EXIT TTFR=$(mytime execute) docker container kill $CONTAINER > /dev/null + trap - EXIT else $1 & PID=$! + trap 'kill -9 "$PID"' EXIT TTFR=$(mytime execute) kill -9 $PID + trap - EXIT fi if [ "$TTFR" != "" ]; then diff --git a/guides/distribution-base/python/src/example/micronaut/controller.py b/guides/distribution-base/python/src/example/micronaut/controller.py new file mode 100644 index 00000000000..f7538f6187d --- /dev/null +++ b/guides/distribution-base/python/src/example/micronaut/controller.py @@ -0,0 +1,11 @@ +from micronaut.http.annotation import Get + + +@Get("/") +def index() -> str: + return "Hello World" + + +@Get("/hello/{name}") +def hello(name: str) -> dict[str, str]: + return {"message": f"Hello {name}!"} diff --git a/guides/distribution-base/python/ttfr.sh b/guides/distribution-base/python/ttfr.sh new file mode 100644 index 00000000000..4b4569d1d97 --- /dev/null +++ b/guides/distribution-base/python/ttfr.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +set -e + +TYPE="docker" +PORT=8080 +DELAY=20 + +usage() { + echo "$0: Time to first request for native or docker applications" + echo "" + echo " $0 [-d|-n] [-p port] ARTIFACT" + echo "" + echo " -d : ARTIFACT is a docker image (default)" + echo " -n : ARTIFACT is a native executable" + echo " -p : port to check (default 8080)" + echo "" +} + +while getopts 'dnp:' flag; do + case "${flag}" in + d) TYPE="docker" ;; + n) TYPE="native" ;; + p) PORT="${OPTARG}" ;; + *) usage + exit 1 ;; + esac +done +shift $(($OPTIND - 1)) +echo $1 + +if [ $# -eq 0 ]; then + echo "Needs the docker image or native executable to run" + exit 1 +fi + +execute() { + local END=$((SECONDS+DELAY)) + while ! curl -o /dev/null -s "http://localhost:${PORT}"; do + if [ $SECONDS -gt $END ]; then + echo "No response from the app in $DELAY seconds" >&2 + exit 1 + fi + sleep 0.001; + done +} + +mytime() { + exec 3>&1 4>&2 + mytime=$(TIMEFORMAT="%3R"; { time $1 1>&3 2>&4; } 2>&1) || return $? + exec 3>&- 4>&- + echo $mytime +} + +if [[ "$TYPE" == "docker" ]]; then + CONTAINER=$(docker run -d --rm -p $PORT:$PORT --privileged $1) + trap 'docker container kill "$CONTAINER" > /dev/null' EXIT + TTFR=$(mytime execute) + docker container kill $CONTAINER > /dev/null + trap - EXIT +else + $1 & + PID=$! + trap 'kill -9 "$PID"' EXIT + TTFR=$(mytime execute) + kill -9 $PID + trap - EXIT +fi + +if [ "$TTFR" != "" ]; then + echo "${TTFR} seconds" +else + exit 1 +fi diff --git a/guides/hello-base/hellocontroller.adoc b/guides/hello-base/hellocontroller.adoc index c07fe894c96..297a3a59104 100644 --- a/guides/hello-base/hellocontroller.adoc +++ b/guides/hello-base/hellocontroller.adoc @@ -1,11 +1,29 @@ === Controller +:exclude-for-languages:python + To create a microservice that responds with "Hello World", you first need a controller. source:HelloController[] -:exclude-for-languages:python + callout:controller[arg0=/hello] :exclude-for-languages: callout:get[arg0=index,arg1=/hello] callout:text-plain[] -<.> A String "Hello World" is returned as the result. \ No newline at end of file +<.> A String "Hello World" is returned as the result. + +:exclude-for-languages: + +:only-for-languages:python + +To create a microservice that responds with "Hello World", you first need a route function. + +Create a route: + +source:HelloController[] + +callout:get[arg0=index,arg1=/hello] +callout:text-plain[] +<.> A String "Hello World" is returned as the result. + +:only-for-languages: diff --git a/guides/hello-base/python/src/example/micronaut/hello_controller.py b/guides/hello-base/python/src/example/micronaut/hello_controller.py index 6e23798b5fb..e3b3c6e7809 100644 --- a/guides/hello-base/python/src/example/micronaut/hello_controller.py +++ b/guides/hello-base/python/src/example/micronaut/hello_controller.py @@ -1,8 +1,7 @@ from micronaut.http import MediaType from micronaut.http.annotation import Get -from micronaut.http.annotation import Produces -@Get("/hello") # <1> -@Produces(MediaType.TEXT_PLAIN) # <2> + +@Get(value="/hello", produces=MediaType.TEXT_PLAIN) # <1> <2> def index() -> str: return "Hello World" # <3> diff --git a/guides/micronaut-cloud-database-base/create-app.adoc b/guides/micronaut-cloud-database-base/create-app.adoc index 9b7161a52eb..7b9d3f4e20c 100644 --- a/guides/micronaut-cloud-database-base/create-app.adoc +++ b/guides/micronaut-cloud-database-base/create-app.adoc @@ -1,3 +1,10 @@ == Creating the Application +:exclude-for-languages:python Download the complete solution for the guideLink:micronaut-data-jdbc-repository[Access a database with Micronaut Data JDBC] guide. You will use the sample application as a starting point. +:exclude-for-languages: +:only-for-languages:python +Download and unzip the link:micronaut-data-jdbc-repository-pyronaut-python.zip[Python JDBC solution] from the guideLink:micronaut-data-jdbc-repository-pyronaut-python[Access a database with Micronaut Data JDBC] guide. Use that application's directory for the commands below; this cloud guide does not supply a separate application. + +The reused application's `config/application.toml` already selects the `MYSQL` dialect and MySQL JDBC driver and enables Flyway. Configure the cloud connection with environment variables; keep credentials out of source control. +:only-for-languages: diff --git a/guides/micronaut-cloud-database-base/metadata.json b/guides/micronaut-cloud-database-base/metadata.json index d6f5f8ae085..75fd2f78403 100644 --- a/guides/micronaut-cloud-database-base/metadata.json +++ b/guides/micronaut-cloud-database-base/metadata.json @@ -1,5 +1,5 @@ { "publish": false, "tags": ["cloud", "database", "micronaut-data", "jdbc", "flyway", "mysql"], - "apps": [{"name": "default"}] + "apps": [] } diff --git a/guides/micronaut-cloud-oidc-base/metadata.json b/guides/micronaut-cloud-oidc-base/metadata.json index faaf5e1219c..fa575008ab8 100644 --- a/guides/micronaut-cloud-oidc-base/metadata.json +++ b/guides/micronaut-cloud-oidc-base/metadata.json @@ -3,6 +3,8 @@ "tags": ["cloud", "security", "oauth2", "oidc", "openid-connect"], "apps": [{ "name": "default", - "features": ["security-oauth2", "security-jwt", "security-session", "views-thymeleaf"] + "features": ["security-oauth2", "security-jwt", "security-session", "views-thymeleaf"], + "excludeSource": ["Controller", "Services"], + "excludeTest": ["ApplicationTest", "DefaultTest"] }] -} \ No newline at end of file +} diff --git a/guides/micronaut-cloud-oidc-base/python/config/application-dev.toml b/guides/micronaut-cloud-oidc-base/python/config/application-dev.toml new file mode 100644 index 00000000000..1830830e6d2 --- /dev/null +++ b/guides/micronaut-cloud-oidc-base/python/config/application-dev.toml @@ -0,0 +1,2 @@ +[micronaut.security.oauth2.clients.default] +enabled = false diff --git a/guides/micronaut-cloud-oidc-base/python/config/application.toml b/guides/micronaut-cloud-oidc-base/python/config/application.toml new file mode 100644 index 00000000000..8f29913d4bb --- /dev/null +++ b/guides/micronaut-cloud-oidc-base/python/config/application.toml @@ -0,0 +1,24 @@ +[micronaut.application] +name = "micronautguide" + +# <1> +[micronaut.security] +authentication = "idtoken" + +# <2> +# <3> +[micronaut.security.oauth2.clients.micronaut] +client-id = "${OAUTH_CLIENT_ID:xxx}" +# <3> +client-secret = "${OAUTH_CLIENT_SECRET:yyy}" +# <3> +[micronaut.security.oauth2.clients.micronaut.openid] +issuer = "${OAUTH_ISSUER:https://idcs.example.identity.oraclecloud.com/}" + +[micronaut.security.endpoints.logout] +enabled = true +# <4> +get-allowed = true + +[netty.default.allocator] +max-order = 3 diff --git a/guides/micronaut-cloud-oidc-base/python/config/views/home.html b/guides/micronaut-cloud-oidc-base/python/config/views/home.html new file mode 100644 index 00000000000..11b1a0fcc2b --- /dev/null +++ b/guides/micronaut-cloud-oidc-base/python/config/views/home.html @@ -0,0 +1,27 @@ + + + + Micronaut - OpenID Connect + + +

Micronaut - OpenID Connect

+ +

Username:

+

Username: Anonymous

+ + +
+

User Information:

+
    +
+
  • :
  • + + + + + diff --git a/guides/micronaut-cloud-oidc-base/python/src/example/micronaut/home_controller.py b/guides/micronaut-cloud-oidc-base/python/src/example/micronaut/home_controller.py new file mode 100644 index 00000000000..ecdd5830d78 --- /dev/null +++ b/guides/micronaut-cloud-oidc-base/python/src/example/micronaut/home_controller.py @@ -0,0 +1,17 @@ +from micronaut.http.annotation import Get +from micronaut.security.annotation import Secured +from micronaut.security.rules import SecurityRule +from micronaut.views import View + + +@Secured(SecurityRule.IS_ANONYMOUS) # <1> +@View("home") # <2> +@Get("/") # <3> +def index() -> dict: + return {} + + +@Secured(SecurityRule.IS_AUTHENTICATED) # <4> +@Get("/secure") # <5> +def secured() -> dict: + return {"secured": True} # <6> diff --git a/guides/micronaut-cloud-oidc-base/python/tests-config/application-test.toml b/guides/micronaut-cloud-oidc-base/python/tests-config/application-test.toml new file mode 100644 index 00000000000..3ea976d1b47 --- /dev/null +++ b/guides/micronaut-cloud-oidc-base/python/tests-config/application-test.toml @@ -0,0 +1,8 @@ +[micronaut.security.oauth2] +enabled = false + +[micronaut.security.oauth2.clients.micronaut] +enabled = false + +[micronaut.security.oauth2.clients.default] +enabled = false diff --git a/guides/micronaut-cloud-oidc-base/python/tests/example/micronaut/test_home_controller.py b/guides/micronaut-cloud-oidc-base/python/tests/example/micronaut/test_home_controller.py new file mode 100644 index 00000000000..1e8ea49cd1f --- /dev/null +++ b/guides/micronaut-cloud-oidc-base/python/tests/example/micronaut/test_home_controller.py @@ -0,0 +1,41 @@ +import pytest +import requests + +from pyronaut.test import MicronautTest, micronaut_test_fixture + + +@pytest.fixture(scope="module") +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest( + environments=["test"], + transactional=False, + ), + ) + yield fixture + fixture.stop() + + +@pytest.fixture +def client(my_context): + return requests.with_context(my_context) + + +def test_home_page_renders_anonymous_user(client): + response = client.get("/", timeout=90) + + assert response.status_code == 200 + assert "Username: Anonymous" in response.text + + +def test_secure_page_rejects_anonymous_user(client): + response = client.get( + "/secure", + allow_redirects=False, + headers={"Accept": "text/html"}, + timeout=90, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/" diff --git a/guides/micronaut-cloud-oidc-base/python/tests/test_application.py b/guides/micronaut-cloud-oidc-base/python/tests/test_application.py new file mode 100644 index 00000000000..f0407fdfdb1 --- /dev/null +++ b/guides/micronaut-cloud-oidc-base/python/tests/test_application.py @@ -0,0 +1,20 @@ +import pytest + +from pyronaut.test import MicronautTest, micronaut_test_fixture + + +@pytest.fixture +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest( + environments=["test"], + transactional=False, + ), + ) + yield fixture + fixture.stop() + + +def test_context_loads(my_context): + assert my_context.isRunning() diff --git a/guides/micronaut-cloud-trace-base/python/src/example/micronaut/inventory_service.py b/guides/micronaut-cloud-trace-base/python/src/example/micronaut/inventory_service.py new file mode 100644 index 00000000000..13942fb0d8a --- /dev/null +++ b/guides/micronaut-cloud-trace-base/python/src/example/micronaut/inventory_service.py @@ -0,0 +1,77 @@ +from typing import Annotated + +import java +from jakarta.inject import Inject, Singleton + +from .warehouse_client import WarehouseClient + +Span = java.type("io.opentelemetry.api.trace.Span") +SpanAttribute = java.type("io.opentelemetry.instrumentation.annotations.SpanAttribute") +Tracer = java.type("io.opentelemetry.api.trace.Tracer") +WithSpan = java.type("io.opentelemetry.instrumentation.annotations.WithSpan") + + +@Singleton +class InventoryService: + def __init__( + self, + tracer: Annotated[Tracer, Inject], + warehouse: Annotated[WarehouseClient, Inject], + ): # <1> + self.tracer = tracer + self.warehouse = warehouse + self.inventory = { + "laptop": 4, + "desktop": 2, + "monitor": 11, + } + self.store_name = "my_store" + + def get_product_names(self) -> list[str]: + return list(self.inventory.keys()) + + @WithSpan("stock-counts") # <2> + def get_stock_counts( + self, + item: Annotated[str, SpanAttribute("inventory.item")], + ) -> dict[str, int]: # <3> + counts = {} + if item in self.inventory: + count = self.inventory[item] + counts["store"] = count + + if count < 10: + counts["warehouse"] = self._in_warehouse(self.store_name, item) + + return counts + + def _in_warehouse(self, store: str, item: str) -> int: + Span.current().setAttribute("inventory.store-name", store) # <4> + return self.warehouse.get_item_count(store, self._get_upc(item)) + + def order(self, item: str, count: int) -> None: + self._order_from_warehouse(item, count) + if item in self.inventory: + count += self.inventory[item] + self.inventory[item] = count + + def _order_from_warehouse(self, item: str, count: int) -> None: + span = ( + self.tracer.spanBuilder("warehouse-order") # <5> + .setAttribute("item", item) + .setAttribute("count", count) + .startSpan() + ) + + payload = { + "store": self.store_name, + "product": item, + "amount": count, + "upc": self._get_upc(item), + } + + self.warehouse.order(payload) + span.end() + + def _get_upc(self, item: str) -> int: + return abs(hash(item)) diff --git a/guides/micronaut-cloud-trace-base/python/src/example/micronaut/store_controller.py b/guides/micronaut-cloud-trace-base/python/src/example/micronaut/store_controller.py new file mode 100644 index 00000000000..5e90a1f8786 --- /dev/null +++ b/guides/micronaut-cloud-trace-base/python/src/example/micronaut/store_controller.py @@ -0,0 +1,46 @@ +from typing import Annotated + +from micronaut.http import HttpStatus +from micronaut.http.annotation import Body, Controller, Get, Post, Status +from micronaut.scheduling import TaskExecutors +from micronaut.scheduling.annotation import ExecuteOn +from micronaut.tracing.annotation import ContinueSpan, NewSpan, SpanTag + +from .inventory_service import InventoryService + + +@ExecuteOn(TaskExecutors.BLOCKING) +@Controller("/store") +class StoreController: + def __init__(self, inventory: InventoryService): + self.inventory = inventory + + @Post("/order") + @Status(HttpStatus.CREATED) + @NewSpan("store.order") # <1> + def order( + self, + item: Annotated[str, SpanTag("order.item"), Body("item")], + count: Annotated[int, SpanTag, Body("count")], + ) -> None: # <2> + self.inventory.order(item, count) + + @Get("/inventory") # <3> + def get_inventory(self) -> list[dict]: + return [ + self.get_inventory_item(product) + for product in self.inventory.get_product_names() + ] + + @Get("/inventory/{item}") + @ContinueSpan # <4> + def get_inventory_item( + self, + item: Annotated[str, SpanTag("item")], + ) -> dict: # <5> + counts = dict(self.inventory.get_stock_counts(item)) + if not counts: + counts["note"] = "Not available at store" + + counts["item"] = item + return counts diff --git a/guides/micronaut-cloud-trace-base/python/src/example/micronaut/warehouse_client.py b/guides/micronaut-cloud-trace-base/python/src/example/micronaut/warehouse_client.py new file mode 100644 index 00000000000..fcdd6ed4e40 --- /dev/null +++ b/guides/micronaut-cloud-trace-base/python/src/example/micronaut/warehouse_client.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod +from typing import Annotated + +import java +from micronaut.http.annotation import Get, Post, QueryValue +from micronaut.http.client.annotation import Client +from micronaut.tracing.annotation import ContinueSpan, SpanTag + +SpanAttribute = java.type("io.opentelemetry.instrumentation.annotations.SpanAttribute") +WithSpan = java.type("io.opentelemetry.instrumentation.annotations.WithSpan") + + +@Client("/warehouse") # <1> +class WarehouseClient(ABC): + + @Post("/order") + @WithSpan + @abstractmethod + def order( + self, + payload: Annotated[dict, SpanTag("warehouse.order")], + ) -> None: + ... + + @Get("/count") + @ContinueSpan + @abstractmethod + def get_item_count( + self, + store: Annotated[str, QueryValue], + upc: Annotated[int, SpanAttribute, QueryValue], + ) -> int: + ... diff --git a/guides/micronaut-cloud-trace-base/python/src/example/micronaut/warehouse_controller.py b/guides/micronaut-cloud-trace-base/python/src/example/micronaut/warehouse_controller.py new file mode 100644 index 00000000000..3a7a39f8a9a --- /dev/null +++ b/guides/micronaut-cloud-trace-base/python/src/example/micronaut/warehouse_controller.py @@ -0,0 +1,21 @@ +import random +from time import sleep + +from micronaut.http import HttpResponse +from micronaut.http.annotation import Controller, Get, Post +from micronaut.scheduling import TaskExecutors +from micronaut.scheduling.annotation import ExecuteOn + + +@ExecuteOn(TaskExecutors.BLOCKING) # <1> +@Controller("/warehouse") # <2> +class WarehouseController: + + @Get("/count") # <3> + def get_item_count(self) -> HttpResponse: + return HttpResponse.ok(random.randint(0, 10)) + + @Post("/order") # <4> + def order(self) -> HttpResponse: + sleep(0.5) + return HttpResponse.accepted() diff --git a/guides/micronaut-cloud-trace-base/python/tests-config/application-test.toml b/guides/micronaut-cloud-trace-base/python/tests-config/application-test.toml new file mode 100644 index 00000000000..8bd508976b3 --- /dev/null +++ b/guides/micronaut-cloud-trace-base/python/tests-config/application-test.toml @@ -0,0 +1,2 @@ +[otel.traces] +exporter = "none" diff --git a/guides/micronaut-cloud-trace-base/python/tests/example/micronaut/test_store_controller.py b/guides/micronaut-cloud-trace-base/python/tests/example/micronaut/test_store_controller.py new file mode 100644 index 00000000000..be8d77938e6 --- /dev/null +++ b/guides/micronaut-cloud-trace-base/python/tests/example/micronaut/test_store_controller.py @@ -0,0 +1,61 @@ +import pytest +import requests + +from pyronaut.test import MicronautTest, micronaut_test_fixture + + +@pytest.fixture +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest(environments=["test"], transactional=False), + ) + yield fixture + fixture.stop() + + +@pytest.fixture +def client(my_context): + return requests.with_context(my_context) + + +def test_inventory_item(client): + response = client.get("/store/inventory/laptop") + + assert response.status_code == 200 + inventory = response.json() + assert inventory["item"] == "laptop" + assert inventory["store"] == 4 + assert "warehouse" in inventory + + +def test_inventory_item_not_found(client): + response = client.get("/store/inventory/chair") + + assert response.status_code == 200 + inventory = response.json() + assert inventory["item"] == "chair" + assert inventory["note"] == "Not available at store" + + +def test_inventory_all(client): + response = client.get("/store/inventory") + + assert response.status_code == 200 + inventory = response.json() + assert len(inventory) == 3 + names = {item["item"] for item in inventory} + assert names == {"desktop", "monitor", "laptop"} + + +def test_order(client): + response = client.post("/store/order", json={"item": "desktop", "count": 8}) + + assert response.status_code == 201 + + response = client.get("/store/inventory/desktop") + assert response.status_code == 200 + inventory = response.json() + assert inventory["item"] == "desktop" + assert inventory["store"] == 10 + assert "warehouse" not in inventory diff --git a/guides/micronaut-cloud-trace-base/python/tests/test_application.py b/guides/micronaut-cloud-trace-base/python/tests/test_application.py new file mode 100644 index 00000000000..fa35f4ebaf1 --- /dev/null +++ b/guides/micronaut-cloud-trace-base/python/tests/test_application.py @@ -0,0 +1,17 @@ +import pytest + +from pyronaut.test import MicronautTest, micronaut_test_fixture + + +@pytest.fixture +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest(environments=["test"], transactional=False), + ) + yield fixture + fixture.stop() + + +def test_context_loads(my_context): + assert my_context.isRunning() diff --git a/guides/micronaut-data-many-to-many-base/python/config/db/liquibase-changelog.xml b/guides/micronaut-data-many-to-many-base/python/config/db/liquibase-changelog.xml new file mode 100644 index 00000000000..415d85947fd --- /dev/null +++ b/guides/micronaut-data-many-to-many-base/python/config/db/liquibase-changelog.xml @@ -0,0 +1,8 @@ + + + + diff --git a/guides/micronaut-data-many-to-many-base/python/tests/example/micronaut/test_many_to_many.py b/guides/micronaut-data-many-to-many-base/python/tests/example/micronaut/test_many_to_many.py index f160b184cb6..3f3d6a62b4a 100644 --- a/guides/micronaut-data-many-to-many-base/python/tests/example/micronaut/test_many_to_many.py +++ b/guides/micronaut-data-many-to-many-base/python/tests/example/micronaut/test_many_to_many.py @@ -37,8 +37,10 @@ def user_role_repo(my_context): def test_many_to_many_persistence(role_repo, user_repo, user_role_repo): + assert role_repo.findByAuthority(ROLE_ADMIN) is None role_user = role_repo.save(ROLE_USER) role_admin = role_repo.save(ROLE_ADMIN) + assert role_repo.findByAuthority(ROLE_ADMIN).id == role_admin.id assert user_repo.findByUsername(U_SERGIO) is None diff --git a/guides/micronaut-email/configuration.adoc b/guides/micronaut-email/configuration.adoc index 03e1e9be9eb..73b34033474 100644 --- a/guides/micronaut-email/configuration.adoc +++ b/guides/micronaut-email/configuration.adoc @@ -2,7 +2,11 @@ If you want to send every email with the same address, you can set it via configuration: - +:exclude-for-languages:python resource:application.properties[tag=mail] +:exclude-for-languages: +:only-for-languages:python +resource:application.toml[tag=mail] +:only-for-languages: This is possible thanks to https://micronaut-projects.github.io/micronaut-email/latest/guide/index.html#decorators[email decorators]. diff --git a/guides/micronaut-email/python/config/application.toml b/guides/micronaut-email/python/config/application.toml new file mode 100644 index 00000000000..177888891fe --- /dev/null +++ b/guides/micronaut-email/python/config/application.toml @@ -0,0 +1,10 @@ +[micronaut.application] +name = "demo" + +[netty.default.allocator] +max-order = 3 + +#tag::mail[] +[micronaut.email.from] +email = "john@micronaut.example" +#end::mail[] diff --git a/guides/micronaut-mcp-diskspace-http/java/src/test/java/example/micronaut/MyToolsTest.java b/guides/micronaut-mcp-diskspace-http/java/src/test/java/example/micronaut/MyToolsTest.java index d599b7af7bc..301e8bf6fa9 100644 --- a/guides/micronaut-mcp-diskspace-http/java/src/test/java/example/micronaut/MyToolsTest.java +++ b/guides/micronaut-mcp-diskspace-http/java/src/test/java/example/micronaut/MyToolsTest.java @@ -48,6 +48,6 @@ void mcpListTools(McpSyncClient mcpClient) { McpSchema.Tool tool = listToolsResult.tools().get(0); assertEquals("freeDiskSpace", tool.name()); assertEquals("Free Disk Space", tool.title()); - assertEquals("Return the free disk space in the users computer", tool.description()); + assertEquals("Return the free disk space in the user's computer", tool.description()); } -} \ No newline at end of file +} diff --git a/guides/micronaut-mcp-diskspace/configuration.adoc b/guides/micronaut-mcp-diskspace/configuration.adoc index afac88cc5be..e983373f6e8 100644 --- a/guides/micronaut-mcp-diskspace/configuration.adoc +++ b/guides/micronaut-mcp-diskspace/configuration.adoc @@ -1,3 +1,13 @@ == Configuration -resource:application.properties[tag=mcp] \ No newline at end of file +:exclude-for-languages:python + +resource:application.properties[tag=mcp] + +:exclude-for-languages: + +:only-for-languages:python + +resource:application.toml[tag=mcp] + +:only-for-languages: diff --git a/guides/micronaut-mcp-diskspace/disk-utils.adoc b/guides/micronaut-mcp-diskspace/disk-utils.adoc index 203320325a8..58eeb38ed3f 100644 --- a/guides/micronaut-mcp-diskspace/disk-utils.adoc +++ b/guides/micronaut-mcp-diskspace/disk-utils.adoc @@ -1,5 +1,17 @@ == Disk Utils +:exclude-for-languages:python + Create a utility class to get the disk space information of the computer. source:DiskUtils[] + +:exclude-for-languages: + +:only-for-languages:python + +Create a utility function to get the disk space information of the computer. + +source:DiskUtils[] + +:only-for-languages: diff --git a/guides/micronaut-mcp-diskspace/java/src/main/java/example/micronaut/MyTools.java b/guides/micronaut-mcp-diskspace/java/src/main/java/example/micronaut/MyTools.java index 3ca81fc44f6..83e2faf83db 100644 --- a/guides/micronaut-mcp-diskspace/java/src/main/java/example/micronaut/MyTools.java +++ b/guides/micronaut-mcp-diskspace/java/src/main/java/example/micronaut/MyTools.java @@ -22,7 +22,7 @@ @Singleton // <1> class MyTools { @Tool(title = "Free Disk Space", - description = "Return the free disk space in the users computer") // <2> + description = "Return the free disk space in the user's computer") // <2> String freeDiskSpace() { return DiskUtils.freeDiskSpace(); } diff --git a/guides/micronaut-mcp-diskspace/python/src/example/micronaut/disk_utils.py b/guides/micronaut-mcp-diskspace/python/src/example/micronaut/disk_utils.py new file mode 100644 index 00000000000..aa5be19bf2e --- /dev/null +++ b/guides/micronaut-mcp-diskspace/python/src/example/micronaut/disk_utils.py @@ -0,0 +1,8 @@ +from pathlib import Path +import shutil + + +def free_disk_space() -> str: + usage = shutil.disk_usage(Path("/")) + free_gb = usage.free / (1024.0 * 1024 * 1024) + return f"Free disk space: {free_gb:.2f} GB" diff --git a/guides/micronaut-mcp-diskspace/python/src/example/micronaut/my_tools.py b/guides/micronaut-mcp-diskspace/python/src/example/micronaut/my_tools.py new file mode 100644 index 00000000000..3e705d0fb73 --- /dev/null +++ b/guides/micronaut-mcp-diskspace/python/src/example/micronaut/my_tools.py @@ -0,0 +1,14 @@ +from jakarta.inject import Singleton +from micronaut.mcp.annotations import Tool + +from example.micronaut.disk_utils import free_disk_space + + +@Singleton # <1> +class MyTools: + @Tool( + title="Free Disk Space", + description="Return the free disk space in the user's computer", + ) # <2> + def freeDiskSpace(self) -> str: + return free_disk_space() diff --git a/guides/micronaut-mcp-diskspace/tool.adoc b/guides/micronaut-mcp-diskspace/tool.adoc index d94e6e3de44..5d9567b495b 100644 --- a/guides/micronaut-mcp-diskspace/tool.adoc +++ b/guides/micronaut-mcp-diskspace/tool.adoc @@ -6,7 +6,22 @@ ____ Tools: Executable functions that allow models to perform actions or retrieve information ____ +:exclude-for-languages:python + source:MyTools[] callout:singleton[] -callout:mcp-tool[] \ No newline at end of file +callout:mcp-tool[] + +:exclude-for-languages: + +:only-for-languages:python + +Create a singleton bean with a tool method: + +source:MyTools[] + +callout:singleton[] +callout:mcp-tool[] + +:only-for-languages: diff --git a/guides/micronaut-mcp-weather/json-schema-generation.adoc b/guides/micronaut-mcp-weather/json-schema-generation.adoc index 19fb44ee97c..63d754e0be6 100644 --- a/guides/micronaut-mcp-weather/json-schema-generation.adoc +++ b/guides/micronaut-mcp-weather/json-schema-generation.adoc @@ -2,9 +2,9 @@ An MCP Tool can define its https://modelcontextprotocol.io/specification/2025-11-25/server/tools#output-schema[input schema] using JSON Schema. -Thanks to Micronaut JSON Schema, we can generate the JSON Schema at compile-time given a Java file. +Thanks to Micronaut JSON Schema, we can generate the JSON Schema at compile-time given a source file. -Create two Java records to model the tools' inputs. +Create two types to model the tools' inputs. `GetAlertInput` will be the input schema of the `getAlerts` tool. diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/get_alert_input.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/get_alert_input.py new file mode 100644 index 00000000000..e4f875e699b --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/get_alert_input.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from micronaut.jsonschema import JsonSchema +from micronaut.serde.annotation import Serdeable + + +@Serdeable # <1> +@dataclass +@JsonSchema # <2> +class GetAlertInput: + """Input for the getAlerts tool.""" + + state: str diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/point.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/point.py new file mode 100644 index 00000000000..9ecd2be30ad --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/point.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from micronaut.jsonschema import JsonSchema +from micronaut.serde.annotation import Serdeable + + +@Serdeable # <1> +@dataclass +@JsonSchema # <2> +class Point: + """Input for the getWeatherForecastByLocation tool.""" + + latitude: float + longitude: float diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/tools.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/tools.py new file mode 100644 index 00000000000..53614c2a64e --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/tools.py @@ -0,0 +1,22 @@ +from jakarta.inject import Singleton +from micronaut.mcp.annotations import Tool + +from example.micronaut.get_alert_input import GetAlertInput +from example.micronaut.point import Point +from example.micronaut.weather.weather_client import WeatherClient + + +@Singleton # <1> +class Tools: + def __init__(self, weather_client: WeatherClient): # <2> + self.weather_client = weather_client + + @Tool( + description="Get weather forecast for specific latitude/longitude coordinates", + ) # <3> + def getWeatherForecastByLocation(self, point: Point) -> str: + return self.weather_client.formatted_forecast(point.latitude, point.longitude) + + @Tool(description="Get weather alerts for a US state") # <3> + def getAlerts(self, input: GetAlertInput) -> str: + return self.weather_client.formatted_alerts(input.state) diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/alerts.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/alerts.py new file mode 100644 index 00000000000..5259269f132 --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/alerts.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + +from example.micronaut.weather.model.feature import Feature + + +@Serdeable +@dataclass +class Alerts: + context: list[str] | None = None + type: str | None = None + features: list[Feature] | None = None + title: str | None = None + updated: str | None = None diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/feature.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/feature.py new file mode 100644 index 00000000000..acadb0c4906 --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/feature.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + +from example.micronaut.weather.model.properties import Properties + + +@Serdeable +@dataclass +class Feature: + id: str | None = None + type: str | None = None + geometry: object | None = None + properties: Properties | None = None diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/forecast.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/forecast.py new file mode 100644 index 00000000000..9ee5e27d674 --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/forecast.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + +from example.micronaut.weather.model.forecast_properties import ForecastProperties + + +@Serdeable +@dataclass +class Forecast: + properties: ForecastProperties | None = None diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/forecast_properties.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/forecast_properties.py new file mode 100644 index 00000000000..2eac8133442 --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/forecast_properties.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + +from example.micronaut.weather.model.period import Period + + +@Serdeable +@dataclass +class ForecastProperties: + periods: list[Period] | None = None diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/grid.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/grid.py new file mode 100644 index 00000000000..e0d02fc27cd --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/grid.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + + +@Serdeable +@dataclass +class Grid: + id: str + x: str + y: str diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/location.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/location.py new file mode 100644 index 00000000000..50dde7259a1 --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/location.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + + +@Serdeable +@dataclass +class Location: + latitude: float + longitude: float diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/period.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/period.py new file mode 100644 index 00000000000..090a38addbd --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/period.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + + +@Serdeable +@dataclass +class Period: + name: str | None = None + temperature: int = 0 + temperatureUnit: str | None = None + windSpeed: str | None = None + windDirection: str | None = None + detailedForecast: str | None = None diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/point_properties.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/point_properties.py new file mode 100644 index 00000000000..5d9f696f49b --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/point_properties.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + + +@Serdeable +@dataclass +class PointProperties: + forecast: str | None = None diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/point_response.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/point_response.py new file mode 100644 index 00000000000..51d265d109f --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/point_response.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + +from example.micronaut.weather.model.point_properties import PointProperties + + +@Serdeable +@dataclass +class PointResponse: + properties: PointProperties | None = None diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/properties.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/properties.py new file mode 100644 index 00000000000..de276115e9a --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/model/properties.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from micronaut.serde.annotation import Serdeable + + +@Serdeable +@dataclass +class Properties: + id: str | None = None + areaDesc: str | None = None + event: str | None = None + severity: str | None = None + description: str | None = None + instruction: str | None = None diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/weather_client.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/weather_client.py new file mode 100644 index 00000000000..21a5e33e869 --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/weather_client.py @@ -0,0 +1,54 @@ +from typing import Annotated + +import java +from jakarta.inject import Inject, Singleton +from micronaut.core.type import Argument +from micronaut.http import HttpHeaders, HttpRequest +from micronaut.http.client import HttpClient +from micronaut.http.client.annotation import Client + +from example.micronaut.weather import weather_utils + + +AlertsClass = java.type("example.micronaut.weather.model.Alerts") +ForecastClass = java.type("example.micronaut.weather.model.Forecast") +PointResponseClass = java.type("example.micronaut.weather.model.PointResponse") + + +@Singleton +class WeatherClient: + http_client: Annotated[HttpClient, Inject, Client(id="weather")] + + def get_alerts(self, state: str): + request = self._request(f"/alerts/active/area/{state}") + return self.http_client.toBlocking().retrieve(request, Argument.of(AlertsClass)) + + def get_points(self, latitude: float, longitude: float): + request = self._request(f"/points/{latitude},{longitude}") + return self.http_client.toBlocking().retrieve(request, Argument.of(PointResponseClass)) + + def get_forecast(self, grid_id: str, grid_x: str, grid_y: str): + request = self._request(f"/gridpoints/{grid_id}/{grid_x},{grid_y}/forecast") + return self.http_client.toBlocking().retrieve(request, Argument.of(ForecastClass)) + + def formatted_alerts(self, state: str) -> str: + return weather_utils.format_alerts(self.get_alerts(state)) + + def formatted_forecast(self, latitude: float, longitude: float) -> str: + forecast = self.forecast_for_location(latitude, longitude) + if forecast is None: + return "No forecast available for requested location" + return weather_utils.format_forecast(forecast) + + def forecast_for_location(self, latitude: float, longitude: float): + forecast_url = weather_utils.get_forecast_url(self.get_points(latitude, longitude)) + if forecast_url is None: + return None + grid = weather_utils.parse_grid(forecast_url) + if grid is None: + return None + return self.get_forecast(grid.id, grid.x, grid.y) + + @staticmethod + def _request(uri: str) -> HttpRequest: + return HttpRequest.GET(uri).header(HttpHeaders.USER_AGENT, "MCP Server") diff --git a/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/weather_utils.py b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/weather_utils.py new file mode 100644 index 00000000000..aeaa728aeb5 --- /dev/null +++ b/guides/micronaut-mcp-weather/python/src/example/micronaut/weather/weather_utils.py @@ -0,0 +1,48 @@ +import re + +from example.micronaut.weather.model.alerts import Alerts +from example.micronaut.weather.model.forecast import Forecast +from example.micronaut.weather.model.grid import Grid +from example.micronaut.weather.model.point_response import PointResponse + + +GRID_URL_PATTERN = re.compile(r".*/gridpoints/([A-Z]{3})/(\d+),(\d+)/forecast$") + + +def parse_grid(url: str) -> Grid | None: + match = GRID_URL_PATTERN.match(url) + if match is None: + return None + return Grid(match.group(1), match.group(2), match.group(3)) + + +def get_forecast_url(points: PointResponse) -> str | None: + return points.properties.forecast if points.properties else None + + +def format_forecast(forecast: Forecast) -> str: + periods = forecast.properties.periods if forecast.properties else [] + if not periods: + return "No forecast available for requested location" + return "\n---\n".join( + f"""Temperature: {period.temperature}\u00b0{period.temperatureUnit} +Wind: {period.windSpeed} {period.windDirection} +Forecast: {period.detailedForecast} +""" + for period in periods + ) + + +def format_alerts(alerts: Alerts) -> str: + features = alerts.features or [] + if not features: + return "There are no watches, warnings or advisories" + return "\n---\n".join( + f"""Event: {properties.event} +Area: {properties.areaDesc} +Severity: {properties.severity} +Description: {properties.description} +Instructions: {properties.instruction}""" + for properties in (feature.properties for feature in features) + if properties + ) diff --git a/guides/micronaut-object-storage-base/python/src/example/micronaut/profile_pictures_api.py b/guides/micronaut-object-storage-base/python/src/example/micronaut/profile_pictures_api.py new file mode 100644 index 00000000000..86bb5f0efd7 --- /dev/null +++ b/guides/micronaut-object-storage-base/python/src/example/micronaut/profile_pictures_api.py @@ -0,0 +1,29 @@ +from typing import Protocol + +from micronaut.http import HttpRequest, HttpResponse, HttpStatus, MediaType +from micronaut.http.annotation import Delete, Get, Post, Status +from micronaut.http.multipart import CompletedFileUpload +from micronaut.http.server.types.files import StreamedFile + + +# tag::class[] +class ProfilePicturesApi(Protocol): + + @Post(uri="/{userId}", consumes=MediaType.MULTIPART_FORM_DATA) # <1> + def upload( + self, + fileUpload: CompletedFileUpload, + userId: str, + request: HttpRequest, + ) -> HttpResponse: + ... + + @Get("/{userId}") # <2> + def download(self, userId: str) -> HttpResponse[StreamedFile] | None: + ... + + @Status(HttpStatus.NO_CONTENT) # <3> + @Delete("/{userId}") # <4> + def delete(self, userId: str) -> None: + ... +# end::class[] diff --git a/guides/micronaut-object-storage-base/testing-upload.adoc b/guides/micronaut-object-storage-base/testing-upload.adoc index 13e8b900e0f..6580b1c8bb2 100644 --- a/guides/micronaut-object-storage-base/testing-upload.adoc +++ b/guides/micronaut-object-storage-base/testing-upload.adoc @@ -2,7 +2,13 @@ ==== If you want to upload a file larger than 1MB, you need to configure: +:exclude-for-languages:python resource:application.properties[tag=server-max-file-size] +:exclude-for-languages: + +:only-for-languages:python +resource:application.toml[tag=server-max-file-size] +:only-for-languages: ==== Assuming you have locally a profile picture in a `profile.jpg` file, you can send it to your application with: @@ -19,4 +25,4 @@ connection: keep-alive transfer-encoding: chunked ---- -Note the `Location` and `ETag` headers. \ No newline at end of file +Note the `Location` and `ETag` headers. diff --git a/guides/micronaut-openapi-base/openapi-intro.adoc b/guides/micronaut-openapi-base/openapi-intro.adoc index 5f71ede959f..ebe060d136e 100644 --- a/guides/micronaut-openapi-base/openapi-intro.adoc +++ b/guides/micronaut-openapi-base/openapi-intro.adoc @@ -30,7 +30,12 @@ source:Option[] callout:serdeable[] callout:constraints[] +:exclude-for-languages:python The following Java record represents a Micronaut Guide: +:exclude-for-languages: +:only-for-languages:python +The following Python dataclass represents a Micronaut Guide. Its publication date uses `datetime.date`; Pyronaut retains the date schema and ISO-formatted JSON value. Swagger annotations are imported from their Python-facing `swagger` modules rather than resolved with `java.type`. +:only-for-languages: source:Guide[] callout:serdeable[] @@ -50,15 +55,26 @@ test:LatestGuidesControllerTest[] callout:micronaut-test[] callout:http-client[] -== @OpenApiSpecification +== OpenAPI Application Metadata -The Micronaut OpenAPI integration requires a class to be annotated with `@OpenApiSpecification`. Modify `Application.java` and annotate it. +:exclude-for-languages:python +The Micronaut OpenAPI integration requires a class to be annotated with `@OpenApiSpecification`. +Modify `Application.java` and annotate it. +:exclude-for-languages: +:only-for-languages:python +The Micronaut OpenAPI integration reads `OpenAPIDefinition` from the `Application` class in `application.py`. +:only-for-languages: source:Application[] == OpenAPI Generated at Compile Time +:exclude-for-languages:python By adding the `@OpenApiSpecification` annotation to `Application`, an OpenAPI specification is generated at compile time. You can test it as follows: +:exclude-for-languages: +:only-for-languages:python +Annotating `Application` with `OpenAPIDefinition` adds the application metadata to the OpenAPI specification generated at compile time. You can test it as follows: +:only-for-languages: test:OpenApiGeneratedTest[] callout:micronaut-test-start-application-false[] @@ -67,7 +83,12 @@ callout:micronaut-test-start-application-false[] We can expose the OpenAPI specification generated at compile time as a static resource with the following configuration: +:exclude-for-languages:python resource:application.properties[tag=static-resources-swagger] +:exclude-for-languages: +:only-for-languages:python +resource:application.toml[tag=static-resources-swagger] +:only-for-languages: You can test that the OpenAPI specification is available: diff --git a/guides/micronaut-openapi-base/python/config/application.toml b/guides/micronaut-openapi-base/python/config/application.toml new file mode 100644 index 00000000000..71a3f610b2b --- /dev/null +++ b/guides/micronaut-openapi-base/python/config/application.toml @@ -0,0 +1,8 @@ +[micronaut.application] +name = "micronautguide" + +# tag::static-resources-swagger[] +[micronaut.router.static-resources.swagger] +paths = "classpath:META-INF/swagger" +mapping = "/swagger/**" +# end::static-resources-swagger[] diff --git a/guides/micronaut-openapi-base/python/src/example/micronaut/application.py b/guides/micronaut-openapi-base/python/src/example/micronaut/application.py new file mode 100644 index 00000000000..2e1f205374c --- /dev/null +++ b/guides/micronaut-openapi-base/python/src/example/micronaut/application.py @@ -0,0 +1,14 @@ +from swagger.v3.oas.annotations import OpenAPIDefinition +from swagger.v3.oas.annotations.info import Info +from swagger.v3.oas.annotations.servers import Server + + +@OpenAPIDefinition( + info=Info( + title="micronaut-guides", + version="1.0", + ), + servers=[Server(url="https://guides.micronaut.io")], +) # <1> +class Application: + pass diff --git a/guides/micronaut-openapi-base/python/src/example/micronaut/build_tool.py b/guides/micronaut-openapi-base/python/src/example/micronaut/build_tool.py new file mode 100644 index 00000000000..cfda11cb8b9 --- /dev/null +++ b/guides/micronaut-openapi-base/python/src/example/micronaut/build_tool.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class BuildTool(Enum): + GRADLE = "GRADLE" + MAVEN = "MAVEN" + PYRONAUT = "PYRONAUT" diff --git a/guides/micronaut-openapi-base/python/src/example/micronaut/guide.py b/guides/micronaut-openapi-base/python/src/example/micronaut/guide.py new file mode 100644 index 00000000000..3e5d5ab39fb --- /dev/null +++ b/guides/micronaut-openapi-base/python/src/example/micronaut/guide.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from datetime import date +from typing import Annotated + +from jakarta.validation import Valid +from jakarta.validation.constraints import NotBlank, NotNull, Size +from micronaut.serde.annotation import Serdeable +from swagger.v3.oas.annotations.media import Schema + +from .option import Option + + +@Serdeable # <1> +@dataclass +class Guide: + title: Annotated[str, NotBlank] + intro: Annotated[str, NotBlank] + authors: Annotated[list[str], NotNull, Size(min=1)] # <2> + tags: list[str] | None + categories: Annotated[list[str], NotNull, Size(min=1)] + publicationDate: Annotated[ + date, + NotNull, + Schema(format="yyyy-MM-dd", example="2018-05-23"), + ] # <3> + slug: Annotated[str, NotBlank] + url: Annotated[str, NotBlank] + options: Annotated[list[Option], NotNull, Size(min=1), Valid] diff --git a/guides/micronaut-openapi-base/python/src/example/micronaut/language.py b/guides/micronaut-openapi-base/python/src/example/micronaut/language.py new file mode 100644 index 00000000000..2c704c53312 --- /dev/null +++ b/guides/micronaut-openapi-base/python/src/example/micronaut/language.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class Language(Enum): + GROOVY = "GROOVY" + JAVA = "JAVA" + KOTLIN = "KOTLIN" + PYTHON = "PYTHON" diff --git a/guides/micronaut-openapi-base/python/src/example/micronaut/latest_guides_controller.py b/guides/micronaut-openapi-base/python/src/example/micronaut/latest_guides_controller.py new file mode 100644 index 00000000000..48e312ee40c --- /dev/null +++ b/guides/micronaut-openapi-base/python/src/example/micronaut/latest_guides_controller.py @@ -0,0 +1,65 @@ +from datetime import date +from micronaut.http.annotation import Controller, Get + +from .build_tool import BuildTool +from .guide import Guide +from .language import Language +from .option import Option + + +@Controller("/latest") # <1> +class LatestGuidesController: + + GUIDES = [ + Guide( + "Creating your first Micronaut application", + "Learn how to create a Hello World Micronaut application with a controller and a functional test.", + ["Ivan Lopez", "Sergio del Amo"], + ["junit", "getting_started", "graalvm"], + ["Getting Started"], + date(2018, 5, 23), + "creating-your-first-micronaut-app", + "https://guides.micronaut.io/latest/creating-your-first-micronaut-app.html", + [ + Option( + Language.JAVA, + BuildTool.GRADLE, + "https://guides.micronaut.io/latest/creating-your-first-micronaut-app-gradle-java.html", + ), + Option( + Language.GROOVY, + BuildTool.GRADLE, + "https://guides.micronaut.io/latest/creating-your-first-micronaut-app-gradle-groovy.html", + ), + Option( + Language.KOTLIN, + BuildTool.GRADLE, + "https://guides.micronaut.io/latest/creating-your-first-micronaut-app-gradle-kotlin.html", + ), + Option( + Language.JAVA, + BuildTool.MAVEN, + "https://guides.micronaut.io/latest/creating-your-first-micronaut-app-maven-java.html", + ), + Option( + Language.GROOVY, + BuildTool.MAVEN, + "https://guides.micronaut.io/latest/creating-your-first-micronaut-app-maven-groovy.html", + ), + Option( + Language.KOTLIN, + BuildTool.MAVEN, + "https://guides.micronaut.io/latest/creating-your-first-micronaut-app-maven-kotlin.html", + ), + Option( + Language.PYTHON, + BuildTool.PYRONAUT, + "https://guides.micronaut.io/latest/creating-your-first-micronaut-app-pyronaut-python.html", + ), + ], + ) + ] + + @Get("/guides.json") # <2> + def latest_guides(self) -> list[Guide]: + return self.GUIDES diff --git a/guides/micronaut-openapi-base/python/src/example/micronaut/option.py b/guides/micronaut-openapi-base/python/src/example/micronaut/option.py new file mode 100644 index 00000000000..ac1ce059591 --- /dev/null +++ b/guides/micronaut-openapi-base/python/src/example/micronaut/option.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Annotated + +from jakarta.validation.constraints import NotBlank, NotNull +from micronaut.serde.annotation import Serdeable + +from .build_tool import BuildTool +from .language import Language + + +@Serdeable # <1> +@dataclass +class Option: + language: Annotated[Language, NotNull] # <2> + buildTool: Annotated[BuildTool, NotNull] # <2> + url: Annotated[str, NotBlank] # <2> diff --git a/guides/micronaut-openapi-base/python/tests/example/micronaut/test_home_controller.py b/guides/micronaut-openapi-base/python/tests/example/micronaut/test_home_controller.py new file mode 100644 index 00000000000..c294945ab1f --- /dev/null +++ b/guides/micronaut-openapi-base/python/tests/example/micronaut/test_home_controller.py @@ -0,0 +1,29 @@ +import pytest +import requests + +from pyronaut.test import MicronautTest, micronaut_test_fixture + + +@pytest.fixture +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest( + transactional=False, + properties={"micronaut.http.client.follow-redirects": "false"}, + ), # <1> <2> + ) + yield fixture + fixture.stop() + + +@pytest.fixture +def client(my_context): + return requests.with_context(my_context) # <3> + + +def test_home_controller_is_hidden(client): + response = client.get("/swagger/micronaut-guides-1.0.yml") + + assert response.status_code == 200 + assert "operationId: home" not in response.text # <4> diff --git a/guides/micronaut-openapi-base/python/tests/example/micronaut/test_latest_guides_controller.py b/guides/micronaut-openapi-base/python/tests/example/micronaut/test_latest_guides_controller.py new file mode 100644 index 00000000000..2672aa89519 --- /dev/null +++ b/guides/micronaut-openapi-base/python/tests/example/micronaut/test_latest_guides_controller.py @@ -0,0 +1,32 @@ +import pytest +import requests + +from pyronaut.test import MicronautTest, micronaut_test_fixture + + +@pytest.fixture +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest(transactional=False), # <1> + ) + yield fixture + fixture.stop() + + +@pytest.fixture +def client(my_context): + return requests.with_context(my_context) # <2> + + +def test_guides_endpoint(client): + response = client.get("/latest/guides.json") + + assert response.status_code == 200 + assert "2018-05-23" in response.text + assert response.json()[0]["publicationDate"] == "2018-05-23" + assert { + "language": "PYTHON", + "buildTool": "PYRONAUT", + "url": "https://guides.micronaut.io/latest/creating-your-first-micronaut-app-pyronaut-python.html", + } in response.json()[0]["options"] diff --git a/guides/micronaut-openapi-base/python/tests/example/micronaut/test_open_api_exposed.py b/guides/micronaut-openapi-base/python/tests/example/micronaut/test_open_api_exposed.py new file mode 100644 index 00000000000..4d54d6b7e02 --- /dev/null +++ b/guides/micronaut-openapi-base/python/tests/example/micronaut/test_open_api_exposed.py @@ -0,0 +1,25 @@ +import pytest +import requests + +from pyronaut.test import MicronautTest, micronaut_test_fixture + + +@pytest.fixture +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest(transactional=False), # <1> + ) + yield fixture + fixture.stop() + + +@pytest.fixture +def client(my_context): + return requests.with_context(my_context) # <2> + + +def test_openapi(client): + response = client.get("/swagger/micronaut-guides-1.0.yml") + + assert response.status_code == 200 diff --git a/guides/micronaut-openapi-base/python/tests/example/micronaut/test_open_api_generated.py b/guides/micronaut-openapi-base/python/tests/example/micronaut/test_open_api_generated.py new file mode 100644 index 00000000000..9709a37226f --- /dev/null +++ b/guides/micronaut-openapi-base/python/tests/example/micronaut/test_open_api_generated.py @@ -0,0 +1,23 @@ +import pytest + +from micronaut.core.io import ResourceLoader +from pyronaut.test import MicronautTest, micronaut_test_fixture + + +@pytest.fixture +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest(start_application=False), # <1> + ) + yield fixture + fixture.stop() + + +@pytest.fixture +def resource_loader(my_context): + return my_context[ResourceLoader] + + +def test_build_generates_openapi(resource_loader): + assert resource_loader.getResource("META-INF/swagger/micronaut-guides-1.0.yml").isPresent() diff --git a/settings.gradle b/settings.gradle index 1cb3bcef89d..fdf2928e085 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,3 +11,5 @@ plugins { rootProject.name = "micronaut-guides" include 'cli' + +apply from: file('gradle/local-composite-builds.gradle') diff --git a/src/docs/common/snippets/common-enable-distributed-configuration.adoc b/src/docs/common/snippets/common-enable-distributed-configuration.adoc index 1740b7ab4ce..37964755790 100644 --- a/src/docs/common/snippets/common-enable-distributed-configuration.adoc +++ b/src/docs/common/snippets/common-enable-distributed-configuration.adoc @@ -1,5 +1,21 @@ === Enable Distributed Configuration +:only-for-languages:python + +Use Micronaut's native configuration-import integration in `config/application-cloud.toml`: + +resource:application-cloud.toml[tag=distributedconfiguration] + +<1> Select the Micronaut secrets-manager integration. +<2> Identify the remote configuration source. +<3> Set the project identifier or authentication mode as described below. + +Do not create `bootstrap.properties` or `bootstrap.toml`. Keep ordinary settings in `config/application.toml` and activate the `cloud` environment when you want to load remote secrets. Local tests use the `test` environment and do not contact the cloud service. A local database test does not verify access to your remote secrets. + +:only-for-languages: + +:exclude-for-languages:python + Create a `bootstrap.properties` file in the `resources` directory to https://docs.micronaut.io/latest/guide/#bootstrap[enable distributed configuration]. Add the following: @@ -27,4 +43,6 @@ You can disable distributed configuration in a test by annotating a test with: ---- @Property(name = "micronaut.config-client.enabled", value = StringUtils.FALSE) @MicronautTest ----- \ No newline at end of file +---- + +:exclude-for-languages:
    ${javaImg}${kotlinImg}${groovyImg}${pythonImg}${pythonImg}
    ${pyronautImg}Read