From 951296162cb5f064538b9800f4ea53cfb8d43060 Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Tue, 6 Oct 2026 16:23:26 +0200 Subject: [PATCH 1/5] Extract Python guide infrastructure for independent master review Preserve committed source and existing tests; separate shared prerequisites from guide changes. Co-authored-by: Graeme Rocher Co-authored-by: Nemanja Mikic Co-authored-by: Nemanja Mikic Co-authored-by: Sergio del Amo --- .github/workflows/python-guides.yml | 49 ++++ README.md | 16 ++ buildSrc/build.gradle | 4 +- buildSrc/settings.gradle | 10 + .../guides/GuideProjectGenerator.groovy | 168 +++++++++-- .../io/micronaut/guides/GuidesPlugin.groovy | 262 ++++++++++++++++-- .../io/micronaut/guides/IndexGenerator.groovy | 17 +- .../guides/TestScriptGenerator.groovy | 37 ++- .../guides/tasks/PythonTestScriptTask.groovy | 44 +++ .../tasks/SampleProjectGenerationTask.groovy | 26 +- .../tasks/TestScriptRunnerWorkAction.groovy | 2 +- .../guides/tasks/TestScriptTask.groovy | 4 +- .../java/io/micronaut/guides/core/App.java | 43 ++- .../core/DefaultFilesTransferUtility.java | 103 +++++-- .../guides/core/DefaultGuideParser.java | 25 +- .../core/DefaultGuideProjectGenerator.java | 28 +- .../core/DefaultTestScriptGenerator.java | 53 +++- .../guides/core/DependencyLines.java | 60 +++- .../java/io/micronaut/guides/core/Guide.java | 37 ++- .../guides/core/GuideGenerationUtils.java | 11 + .../io/micronaut/guides/core/GuideUtils.java | 49 +++- .../io/micronaut/guides/core/MacroUtils.java | 22 ++ .../core/SourceBlockMacroSubstitution.java | 47 +++- .../guides/core/TestScriptGenerator.java | 4 + .../core/ZipIncludeMacroSubstitution.java | 3 +- .../micronaut/guides/feature/H2Runtime.java | 11 + .../guides/feature/SpringSecurityCrypto.java | 27 ++ .../main/resources/guide-metadata.schema.json | 17 +- buildSrc/src/main/resources/pom.xml | 4 + .../main/resources/pyronaut-test-functions.sh | 223 +++++++++++++++ .../micronaut/guides/DocsOnlyGuideTest.java | 133 +++++++++ .../guides/GuideAsciidocGeneratorTest.java | 40 ++- .../guides/GuideProjectGeneratorTest.java | 76 +++++ .../io/micronaut/guides/GuidesPluginTest.java | 78 ++++++ .../micronaut/guides/IndexGeneratorTest.java | 112 ++++++++ .../io/micronaut/guides/core/AppTest.java | 2 +- .../core/DependencyMacroSubstitutionTest.java | 11 +- .../guides/core/FilesTransferUtilityTest.java | 23 ++ .../guides/core/GuideGenerationUtilsTest.java | 33 +++ .../guides/core/GuideParserTest.java | 2 + .../core/GuideProjectGeneratorTest.java | 26 ++ .../guides/core/GuideProjectZipperTest.java | 3 +- .../io/micronaut/guides/core/GuideTest.java | 18 +- .../micronaut/guides/core/GuideUtilsTest.java | 39 +++ .../micronaut/guides/core/MacroUtilsTest.java | 5 + .../core/SourceMacroSubstitutionTest.java | 14 + .../core/TestMacroSubstitutionTest.java | 13 + .../guides/core/TestScriptGeneratorTest.java | 77 ++++- .../tasks/PythonTestScriptTaskTest.java | 28 ++ .../SampleProjectGenerationTaskTest.java | 140 ++++++++++ .../test/resources/expected_test_script.sh | 16 -- .../resources/expected_test_script_native.sh | 16 -- .../python-application-config/metadata.json | 10 + .../python/config/application.toml | 2 + .../python/tests-config/application-test.toml | 2 + .../src/main/resources/bootstrap.properties | 1 + .../test/resources/bootstrap-test.properties | 1 + gradle/local-composite-builds.gradle | 151 ++++++++++ settings.gradle | 2 + ...mmon-enable-distributed-configuration.adoc | 20 +- 60 files changed, 2321 insertions(+), 179 deletions(-) create mode 100644 .github/workflows/python-guides.yml create mode 100644 buildSrc/settings.gradle create mode 100644 buildSrc/src/main/groovy/io/micronaut/guides/tasks/PythonTestScriptTask.groovy create mode 100644 buildSrc/src/main/java/io/micronaut/guides/feature/H2Runtime.java create mode 100644 buildSrc/src/main/java/io/micronaut/guides/feature/SpringSecurityCrypto.java create mode 100644 buildSrc/src/main/resources/pyronaut-test-functions.sh create mode 100644 buildSrc/src/test/java/io/micronaut/guides/DocsOnlyGuideTest.java create mode 100644 buildSrc/src/test/java/io/micronaut/guides/GuideProjectGeneratorTest.java create mode 100644 buildSrc/src/test/java/io/micronaut/guides/GuidesPluginTest.java create mode 100644 buildSrc/src/test/java/io/micronaut/guides/tasks/PythonTestScriptTaskTest.java create mode 100644 buildSrc/src/test/java/io/micronaut/guides/tasks/SampleProjectGenerationTaskTest.java create mode 100644 buildSrc/src/test/resources/file-transfer/python-application-config/metadata.json create mode 100644 buildSrc/src/test/resources/file-transfer/python-application-config/python/config/application.toml create mode 100644 buildSrc/src/test/resources/file-transfer/python-application-config/python/tests-config/application-test.toml create mode 100644 buildSrc/src/test/resources/file-transfer/python-application-config/src/main/resources/bootstrap.properties create mode 100644 buildSrc/src/test/resources/file-transfer/python-application-config/src/test/resources/bootstrap-test.properties create mode 100644 gradle/local-composite-builds.gradle diff --git a/.github/workflows/python-guides.yml b/.github/workflows/python-guides.yml new file mode 100644 index 00000000000..e8710469b46 --- /dev/null +++ b/.github/workflows/python-guides.yml @@ -0,0 +1,49 @@ +name: Validate Python Guides +on: + push: + branches: + - python + paths: + - '.github/workflows/python-guides.yml' + - 'buildSrc/**' + - 'guides/**' + - 'settings.gradle' + - 'src/docs/common/**' + pull_request: + branches: + - python + paths: + - '.github/workflows/python-guides.yml' + - 'buildSrc/**' + - 'guides/**' + - 'settings.gradle' + - 'src/docs/common/**' + +jobs: + validate: + name: Generate Python Guides + runs-on: ubuntu-latest + env: + LOCAL_GIT_MICRONAUT_STARTER: ${{ github.workspace }}/micronaut-starter + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6.0.2 + with: + repository: n0tl3ss/micronaut-starter + # Pin micronaut-starter#3136 until its fix is merged upstream. + ref: 763920aaa46c06aeebbaba995a66fc3bef643801 + path: micronaut-starter + - 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..ecb12e9e102 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -36,7 +36,7 @@ repositories { } ext { - micronautStarterVersion = "5.2.0" + micronautStarterVersion = "5.2.0-SNAPSHOT" } 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/GuideProjectGenerator.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/GuideProjectGenerator.groovy index 4284a174924..05d5387eae4 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 @@ -166,13 +168,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 +187,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 +210,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.contains('_') || 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 +281,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 +309,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 +330,6 @@ class GuideProjectGenerator implements AutoCloseable { } } - Path sourcePath = Paths.get(inputDir.absolutePath, appName, language) if (!Files.exists(sourcePath)) { sourcePath.toFile().mkdir() } @@ -303,6 +341,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..fac89293093 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,6 +179,9 @@ kill_kotlin_daemon () { bashScript << """\ """ for (GuidesOption guidesOption : guidesOptionList) { + if (pythonTest != isPyronautPython(guidesOption)) { + continue + } String folder = GuideProjectGenerator.folderName(metadata.slug(), guidesOption) BuildTool buildTool = guidesOption.getBuildTool() if (buildTool == PYRONAUT && System.getenv('CI') != null) { @@ -242,9 +259,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 +298,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..6dec74d94c5 --- /dev/null +++ b/buildSrc/src/main/groovy/io/micronaut/guides/tasks/PythonTestScriptTask.groovy @@ -0,0 +1,44 @@ +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 + + @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..9fc035876a5 100644 --- a/buildSrc/src/main/resources/guide-metadata.schema.json +++ b/buildSrc/src/main/resources/guide-metadata.schema.json @@ -46,6 +46,14 @@ "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" + }, + "python": { + "description": "Set it to true to generate a Python/Pyronaut variant 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 +117,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 +174,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..163145b1a06 --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/DocsOnlyGuideTest.java @@ -0,0 +1,133 @@ +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.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)) + ); + try (GuideProjectGenerator generator = new GuideProjectGenerator()) { + generator.generateOne(guide, tempDir.toFile(), tempDir.resolve("code").toFile()); + } + try (var paths = Files.list(tempDir.resolve("code"))) { + assertEquals(0, paths.count()); + } + } + + @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..c35213d72e4 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; @@ -22,6 +23,41 @@ 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.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()); + + String java = Files.readString(output.resolve("guide-gradle-java.adoc")); + String python = Files.readString(output.resolve("guide-pyronaut-python.adoc")); + assertTrue(java.contains("before")); + assertFalse(java.contains("python-only")); + assertTrue(java.contains("after")); + assertTrue(python.contains("before")); + assertTrue(python.contains("python-only")); + assertTrue(python.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..c68161566a2 --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/GuideProjectGeneratorTest.java @@ -0,0 +1,76 @@ +package io.micronaut.guides; + +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 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 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..99fb4c8678e 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/IndexGeneratorTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/IndexGeneratorTest.java @@ -1,16 +1,128 @@ 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("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("python.svg")); + 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..24ae18a3ff7 100644 --- a/buildSrc/src/test/java/io/micronaut/guides/core/GuideParserTest.java +++ b/buildSrc/src/test/java/io/micronaut/guides/core/GuideParserTest.java @@ -39,8 +39,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..3e5891e011a --- /dev/null +++ b/buildSrc/src/test/java/io/micronaut/guides/tasks/PythonTestScriptTaskTest.java @@ -0,0 +1,28 @@ +package io.micronaut.guides.tasks; + +import org.gradle.api.tasks.InputFile; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PythonTestScriptTaskTest { + + @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/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: From e0add739975e2c72280669461d988e55aed49a41 Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Tue, 6 Oct 2026 16:27:27 +0200 Subject: [PATCH 2/5] Use released official Starter for Python guide validation on master Keep the released platform coordinates, remove the obsolete Starter fork checkout, and validate master, python and python-guide-support without native image compilation. --- .github/workflows/python-guides.yml | 12 ++++-------- buildSrc/build.gradle | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-guides.yml b/.github/workflows/python-guides.yml index e8710469b46..4de11934ac3 100644 --- a/.github/workflows/python-guides.yml +++ b/.github/workflows/python-guides.yml @@ -2,7 +2,9 @@ name: Validate Python Guides on: push: branches: + - master - python + - python-guide-support paths: - '.github/workflows/python-guides.yml' - 'buildSrc/**' @@ -11,7 +13,9 @@ on: - 'src/docs/common/**' pull_request: branches: + - master - python + - python-guide-support paths: - '.github/workflows/python-guides.yml' - 'buildSrc/**' @@ -23,16 +27,8 @@ jobs: validate: name: Generate Python Guides runs-on: ubuntu-latest - env: - LOCAL_GIT_MICRONAUT_STARTER: ${{ github.workspace }}/micronaut-starter steps: - uses: actions/checkout@v6.0.2 - - uses: actions/checkout@v6.0.2 - with: - repository: n0tl3ss/micronaut-starter - # Pin micronaut-starter#3136 until its fix is merged upstream. - ref: 763920aaa46c06aeebbaba995a66fc3bef643801 - path: micronaut-starter - uses: actions/setup-java@v5.2.0 with: distribution: adopt diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index ecb12e9e102..9512488e84c 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -36,7 +36,7 @@ repositories { } ext { - micronautStarterVersion = "5.2.0-SNAPSHOT" + micronautStarterVersion = "5.2.1" } dependencies { From c96ff0ec64ad4bffb73463f31a9ad0930fe03776 Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Tue, 6 Oct 2026 16:38:47 +0200 Subject: [PATCH 3/5] Extract Python guide guide:micronaut-data-mongodb-synchronous for independent master review Preserve committed source and existing tests; separate shared prerequisites from guide changes. Co-authored-by: Graeme Rocher Co-authored-by: Nemanja Mikic Co-authored-by: Nemanja Mikic --- .../metadata.json | 6 +- .../micronaut-data-mongodb-synchronous.adoc | 38 ++++++- .../python/config/application.toml | 6 + .../micronaut/default_fruit_service.py | 25 +++++ .../python/src/example/micronaut/fruit.py | 15 +++ .../src/example/micronaut/fruit_controller.py | 38 +++++++ .../src/example/micronaut/fruit_repository.py | 11 ++ .../src/example/micronaut/fruit_service.py | 21 ++++ .../micronaut/test_controller_isolation.py | 61 +++++++++++ .../micronaut/test_fruit_controller.py | 103 ++++++++++++++++++ .../test_fruit_validation_controller.py | 25 +++++ .../python/tests/test_application.py | 17 +++ 12 files changed, 358 insertions(+), 8 deletions(-) create mode 100644 guides/micronaut-data-mongodb-synchronous/python/config/application.toml create mode 100644 guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/default_fruit_service.py create mode 100644 guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit.py create mode 100644 guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_controller.py create mode 100644 guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_repository.py create mode 100644 guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_service.py create mode 100644 guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_controller_isolation.py create mode 100644 guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_fruit_controller.py create mode 100644 guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_fruit_validation_controller.py create mode 100644 guides/micronaut-data-mongodb-synchronous/python/tests/test_application.py diff --git a/guides/micronaut-data-mongodb-synchronous/metadata.json b/guides/micronaut-data-mongodb-synchronous/metadata.json index cf4f9b72d11..503004a96ce 100644 --- a/guides/micronaut-data-mongodb-synchronous/metadata.json +++ b/guides/micronaut-data-mongodb-synchronous/metadata.json @@ -5,11 +5,13 @@ "tags": ["database", "micronaut-data", "mongodb"], "categories": ["MongoDB"], "publicationDate": "2022-05-05", - "languages": ["JAVA", "GROOVY", "KOTLIN"], + "languages": ["JAVA", "GROOVY", "KOTLIN", "PYTHON"], "apps": [ { "name": "default", - "features": ["data-mongodb"] + "features": ["data-mongodb"], + "pythonFeatures": ["validation"], + "excludeSource": ["Controller", "Services"] } ] } diff --git a/guides/micronaut-data-mongodb-synchronous/micronaut-data-mongodb-synchronous.adoc b/guides/micronaut-data-mongodb-synchronous/micronaut-data-mongodb-synchronous.adoc index e6eb0f64a46..50ece1b8140 100644 --- a/guides/micronaut-data-mongodb-synchronous/micronaut-data-mongodb-synchronous.adoc +++ b/guides/micronaut-data-mongodb-synchronous/micronaut-data-mongodb-synchronous.adoc @@ -34,17 +34,22 @@ dependency:mongodb-driver-sync[groupId=org.mongodb] :dependencies: -:exclude-for-languages:groovy,kotlin - === MappedEntity -Create a `Fruit` `MappedEntity` +Create a `Fruit` mapped entity: source:Fruit[] +:exclude-for-languages:python callout:mapped-entity[] callout:mapped-entity-id[] callout:constraints[] +:exclude-for-languages: +:only-for-languages:python +<1> The `@MappedEntity` annotation maps the class to the collection. +<2> Use `@NotBlank` to ensure the `name` is not blank. +<3> `@Id` and `@GeneratedValue` define the generated identifier. +:only-for-languages: === Repository @@ -54,6 +59,9 @@ source:FruitRepository[] <1> Annotate with `@MongoRepository`. <2> Add a finder for finding fruit by a list of names. +:only-for-languages:python +<3> The derived query `getById` returns a `Fruit` or `None`, as declared by its `Fruit | None` return type. Its name follows Micronaut Data's derived-query convention. +:only-for-languages: === Service @@ -74,6 +82,7 @@ Create `FruitController`: source:FruitController[] +:exclude-for-languages:python callout:controller[arg0=/fruits] callout:executes-on[] callout:constructor-di[arg0=FruitService] @@ -84,12 +93,29 @@ callout:valid[] callout:get[arg0=find,arg1=/fruits/\{id\}] callout:get[arg0=findByNameInList,arg1=/fruits/q] <.> Bind a list of Strings to the query parameter `names` - -=== Test Client - +:exclude-for-languages: +:only-for-languages:python +<1> Inject the `FruitService` through the constructor. +<2> Execute blocking MongoDB operations on the blocking executor. +<3> Maps a `GET` request to `/fruits`. +<4> Maps a `POST` request to `/fruits`. +<5> You can specify the HTTP status code via the `@Status` annotation. +<6> Add `@Valid` to any method parameter that requires validation. +<7> Maps a `GET` request to `/fruits/{id}`. +<8> Maps a `GET` request to `/fruits/q`. +<9> Bind a list of Strings to the query parameter `names`. +:only-for-languages: + +=== Writing Tests + +:exclude-for-languages:python Add a https://docs.micronaut.io/latest/guide/#httpClient[Micronaut declarative HTTP Client] to `src/test` to ease the testing of the application's API. test:FruitClient[] +:exclude-for-languages: +:only-for-languages:python +Create pytest tests that use the Pyronaut `requests` integration to call the application's API. +:only-for-languages: Then create a test that verifies the validation of the `Fruit` entity when we create a new entity via `POST`: diff --git a/guides/micronaut-data-mongodb-synchronous/python/config/application.toml b/guides/micronaut-data-mongodb-synchronous/python/config/application.toml new file mode 100644 index 00000000000..bbd7b36eb1c --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/config/application.toml @@ -0,0 +1,6 @@ +[micronaut.application] +name = "micronautguide" + +# On CI with limited resources, mongo can take too long to start +[micronaut.http.client] +read-timeout = "120s" diff --git a/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/default_fruit_service.py b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/default_fruit_service.py new file mode 100644 index 00000000000..d9f403f911d --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/default_fruit_service.py @@ -0,0 +1,25 @@ +from jakarta.inject import Singleton + +from .fruit import Fruit +from .fruit_repository import FruitRepository +from .fruit_service import FruitService + + +@Singleton # <1> +class DefaultFruitService(FruitService): + def __init__(self, fruit_repository: FruitRepository): + self.fruit_repository = fruit_repository + + def list_fruits(self) -> list[Fruit]: + return list(self.fruit_repository.findAll()) + + def save(self, fruit: Fruit) -> Fruit: + if fruit.id is None: + return self.fruit_repository.save(fruit) + return self.fruit_repository.update(fruit) + + def find(self, id: str) -> Fruit | None: + return self.fruit_repository.getById(id) + + def find_by_name_in_list(self, names: list[str]) -> list[Fruit]: + return list(self.fruit_repository.findByNameInList(names)) diff --git a/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit.py b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit.py new file mode 100644 index 00000000000..047ee884068 --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from typing import Annotated + +from jakarta.validation.constraints import NotBlank +from micronaut.data.annotation import GeneratedValue, Id, MappedEntity +from micronaut.serde.annotation import Serdeable + + +@Serdeable +@MappedEntity # <1> +@dataclass +class Fruit: + name: Annotated[str, NotBlank] # <2> + description: str | None = None + id: Annotated[str | None, Id, GeneratedValue] = None # <3> diff --git a/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_controller.py b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_controller.py new file mode 100644 index 00000000000..0f36501f6dc --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_controller.py @@ -0,0 +1,38 @@ +from typing import Annotated + +from jakarta.validation import Valid +from jakarta.validation.constraints import NotNull +from micronaut.http import HttpStatus +from micronaut.http.annotation import Body, Controller, Get, Post, Put, QueryValue, Status +from micronaut.scheduling import TaskExecutors +from micronaut.scheduling.annotation import ExecuteOn + +from .fruit import Fruit +from .fruit_service import FruitService + +@ExecuteOn(TaskExecutors.BLOCKING) # <2> +@Controller("/fruits") +class FruitController: + def __init__(self, fruit_service: FruitService): # <1> + self.fruit_service = fruit_service + + @Get # <3> + def list_fruits(self) -> list[Fruit]: + return self.fruit_service.list_fruits() + + @Post # <4> + @Status(HttpStatus.CREATED) # <5> + def save(self, fruit: Annotated[Fruit, Body, Valid]) -> Fruit: # <6> + return self.fruit_service.save(fruit) + + @Put + def update(self, fruit: Annotated[Fruit, Body, Valid]) -> Fruit: + return self.fruit_service.save(fruit) + + @Get("/{id}") # <7> + def find(self, id: str) -> Fruit | None: + return self.fruit_service.find(id) + + @Get("/q") # <8> + def query(self, names: Annotated[list[str], QueryValue, NotNull]) -> list[Fruit]: # <9> + return self.fruit_service.find_by_name_in_list(names) diff --git a/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_repository.py b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_repository.py new file mode 100644 index 00000000000..690fb9e31fb --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_repository.py @@ -0,0 +1,11 @@ +from micronaut.data.mongodb.annotation import MongoRepository +from micronaut.data.repository import CrudRepository + +from .fruit import Fruit + + +@MongoRepository # <1> +class FruitRepository(CrudRepository[Fruit, str]): + def findByNameInList(self, names: list[str]) -> list[Fruit]: ... # <2> + + def getById(self, id: str) -> Fruit | None: ... # <3> diff --git a/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_service.py b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_service.py new file mode 100644 index 00000000000..b60982e5f1f --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/src/example/micronaut/fruit_service.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod + +from .fruit import Fruit + + +class FruitService(ABC): + @abstractmethod + def list_fruits(self) -> list[Fruit]: + pass + + @abstractmethod + def save(self, fruit: Fruit) -> Fruit: + pass + + @abstractmethod + def find(self, id: str) -> Fruit | None: + pass + + @abstractmethod + def find_by_name_in_list(self, names: list[str]) -> list[Fruit]: + pass diff --git a/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_controller_isolation.py b/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_controller_isolation.py new file mode 100644 index 00000000000..ddf592c3758 --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_controller_isolation.py @@ -0,0 +1,61 @@ +import pytest +import requests +from jakarta.inject import Singleton +from micronaut.context.annotation import Replaces, Requires +from pyronaut.test import MicronautTest, micronaut_test_fixture + +from example.micronaut.default_fruit_service import DefaultFruitService +from example.micronaut.fruit import Fruit +from example.micronaut.fruit_service import FruitService + + +@Singleton +@Replaces(DefaultFruitService) +@Requires(property="spec.name", value="controller-isolation") +class MockService(FruitService): + def list_fruits(self) -> list[Fruit]: + return [ + Fruit("apple", "red"), + Fruit("banana", "yellow"), + ] + + def save(self, fruit: Fruit) -> Fruit: + return fruit + + def find(self, id: str) -> Fruit | None: + return None + + def find_by_name_in_list(self, names: list[str]) -> list[Fruit]: + return [] + + +@pytest.fixture +def my_context(request): + fixture = micronaut_test_fixture( + request, + MicronautTest( + environments=["test"], + transactional=False, + properties={"spec.name": "controller-isolation"}, + ), + ) + yield fixture + fixture.stop() + + +@pytest.fixture +def client(my_context): + return requests.with_context(my_context) + + +def test_serialization(client): + response = client.get("/fruits") + + assert response.status_code == 200 + assert response.headers["Content-Type"].startswith("application/json") + + all_fruits = ",".join( + f"{fruit['name']}:{fruit['description']}" + for fruit in response.json() + ) + assert all_fruits == "apple:red,banana:yellow" diff --git a/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_fruit_controller.py b/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_fruit_controller.py new file mode 100644 index 00000000000..3cbb5edd7a4 --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_fruit_controller.py @@ -0,0 +1,103 @@ +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 fruit_repository(my_context): + return my_context["example.micronaut.FruitRepository"] + + +@pytest.fixture(autouse=True) +def clean_database(fruit_repository): + fruit_repository.deleteAll() + yield + fruit_repository.deleteAll() + + +@pytest.fixture +def client(my_context): + return requests.with_context(my_context) + + +def test_empty_database_contains_no_fruit(client): + response = client.get("/fruits") + + assert response.status_code == 200 + assert response.json() == [] + + +def test_find_non_existing_fruit_returns_404(client): + response = client.get("/fruits/000000000000000000000000") + + assert response.status_code == 404 + + +def test_find_fruit_by_id(client): + response = client.post("/fruits", json={"name": "banana"}) + assert response.status_code == 201 + banana = response.json() + + response = client.get(f"/fruits/{banana['id']}") + + assert response.status_code == 200 + assert response.json() == banana + + +def test_interaction_with_the_controller(client): + response = client.post("/fruits", json={"name": "banana"}) + assert response.status_code == 201 + banana = response.json() + + response = client.get("/fruits") + assert response.status_code == 200 + fruits = response.json() + assert len(fruits) == 1 + assert fruits[0]["name"] == banana["name"] + assert fruits[0].get("description") is None + + response = client.post( + "/fruits", + json={"name": "apple", "description": "Keeps the doctor away"}, + ) + assert response.status_code == 201 + + response = client.get("/fruits") + assert response.status_code == 200 + fruits = response.json() + assert any(fruit.get("description") == "Keeps the doctor away" for fruit in fruits) + + banana["description"] = "Yellow and curved" + response = client.put("/fruits", json=banana) + assert response.status_code == 200 + + response = client.get("/fruits") + assert response.status_code == 200 + descriptions = {fruit.get("description") for fruit in response.json()} + assert descriptions == {"Keeps the doctor away", "Yellow and curved"} + + +def test_search_works_as_expected(client): + client.post("/fruits", json={"name": "apple", "description": "Keeps the doctor away"}) + client.post("/fruits", json={"name": "pineapple", "description": "Delicious"}) + client.post("/fruits", json={"name": "lemon", "description": "Lemonentary my dear Dr Watson"}) + + response = client.get( + "/fruits/q", + params=[("names", "apple"), ("names", "pineapple")], + ) + + assert response.status_code == 200 + names = {fruit["name"] for fruit in response.json()} + assert names == {"apple", "pineapple"} diff --git a/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_fruit_validation_controller.py b/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_fruit_validation_controller.py new file mode 100644 index 00000000000..0d46b96b0f7 --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/python/tests/example/micronaut/test_fruit_validation_controller.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(environments=["test"], transactional=False), + ) + yield fixture + fixture.stop() + + +@pytest.fixture +def client(my_context): + return requests.with_context(my_context) + + +def test_fruit_is_validated(client): + response = client.post("/fruits", json={"name": "", "description": ""}) + + assert response.status_code == 400 diff --git a/guides/micronaut-data-mongodb-synchronous/python/tests/test_application.py b/guides/micronaut-data-mongodb-synchronous/python/tests/test_application.py new file mode 100644 index 00000000000..6b56df01785 --- /dev/null +++ b/guides/micronaut-data-mongodb-synchronous/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(my_context): + assert my_context.isRunning() From a7f3be4655d33ea98ff612fb61330c23789c2560 Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Tue, 6 Oct 2026 16:52:23 +0200 Subject: [PATCH 4/5] Keep dedicated Python test commands in CI-generated scripts Remove the obsolete environment-only skip; dedicated Python selection already separates Python and JVM scripts. The existing DocsOnlyGuideTest reproduces the bug with CI=true and the complete 149-test suite passes after the fix. Fixes #1916 --- .../main/groovy/io/micronaut/guides/TestScriptGenerator.groovy | 3 --- 1 file changed, 3 deletions(-) diff --git a/buildSrc/src/main/groovy/io/micronaut/guides/TestScriptGenerator.groovy b/buildSrc/src/main/groovy/io/micronaut/guides/TestScriptGenerator.groovy index fac89293093..2f0c6f46076 100644 --- a/buildSrc/src/main/groovy/io/micronaut/guides/TestScriptGenerator.groovy +++ b/buildSrc/src/main/groovy/io/micronaut/guides/TestScriptGenerator.groovy @@ -184,9 +184,6 @@ kill_kotlin_daemon () { } String folder = GuideProjectGenerator.folderName(metadata.slug(), guidesOption) BuildTool buildTool = guidesOption.getBuildTool() - if (buildTool == PYRONAUT && System.getenv('CI') != null) { - continue - } if (metadata.apps().any { it.name() == DEFAULT_APP_NAME } ) { if (GuideUtils.shouldSkip(metadata,buildTool, guidesOption.getLanguage())) { continue From d05c16c66544abce193afe62f8a9eaaa9697af48 Mon Sep 17 00:00:00 2001 From: Nemanja Mikic Date: Tue, 6 Oct 2026 16:59:33 +0200 Subject: [PATCH 5/5] Make general guide CI explicitly JVM-only at runtime Keep default guide task selection unchanged. The general workflow excludes only existing dedicated Python runtime tasks, while retaining JVM/native tests and Python generation, docs, ZIPs and scripts. A separately configured Python runtime workflow can use the original task selection. Matrix regression: 12 tests RED before the option, 12 GREEN after it; default selection, implicit/explicit apps, docs-only apps, JVM guides, transitive bases, deduplication and CLI controls preserved. Fixes #1922 --- .github/scripts/guide_ci_tasks.py | 24 ++++-- .github/scripts/test_guide_ci_tasks.py | 109 +++++++++++++++++++++++++ .github/workflows/gradle.yml | 3 + 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/.github/scripts/guide_ci_tasks.py b/.github/scripts/guide_ci_tasks.py index e3fa5b1b043..fd3712449a4 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: @@ -50,6 +51,7 @@ def load_guides(guides_dir: Path) -> dict[str, GuideMetadata]: slug=slug, publish=metadata.get("publish", True) is not False, base=metadata.get("base"), + python_app="PYTHON" in (metadata.get("languages") or []) and metadata.get("apps") != [], ) return guides @@ -75,12 +77,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 +132,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 +165,7 @@ def main() -> int: if args.changed_files is not None else git_changed_files(repo, args.base, args.head, args.diff_mode) ) - tasks = tasks_for_changed_files(changed_files, guides_dir) + tasks = tasks_for_changed_files(changed_files, guides_dir, jvm_only=args.jvm_only) matrix = matrix_for_tasks(tasks) print(f"Changed files: {json.dumps(changed_files)}", file=sys.stderr) diff --git a/.github/scripts/test_guide_ci_tasks.py b/.github/scripts/test_guide_ci_tasks.py index 5d0f07ade53..ce418001a86 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,107 @@ 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"]) + + self.assertEqual( + ["pythonGuideBuild"], + guide_ci_tasks.tasks_for_changed_files( + ["guides/python-guide/metadata.json"], guides_dir, + ), + ) + + def test_jvm_only_excludes_python_runner_for_implicit_apps(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 -x pythonGuideRunPythonTestScript"], + 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_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"], + ) + + 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"]) + + 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 +188,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 +198,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/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" \
${javaImg}${kotlinImg}${groovyImg}${pythonImg}${pythonImg}
${pyronautImg}Read