| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 9cb926c commit 57cd652
7 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,42 @@ | |||
| 1 | + package io.github.howtis.pythonembed.spring; | ||
| 2 | + | ||
| 3 | + import org.springframework.boot.actuate.health.Health; | ||
| 4 | + import org.springframework.boot.actuate.health.HealthIndicator; | ||
| 5 | + | ||
| 6 | + import java.util.Map; | ||
| 7 | + | ||
| 8 | + /** | ||
| 9 | + * Adapts a {@link PythonEmbedHealthIndicator} to Spring Boot 3.x | ||
| 10 | + * {@code org.springframework.boot.actuate.health.HealthIndicator}. | ||
| 11 | + * | ||
| 12 | + * <p>Registered by {@link PythonEmbedAutoConfiguration.HealthIndicatorConfigurationV3} | ||
| 13 | + * when Boot 3.x Actuator is on the classpath. | ||
| 14 | + */ | ||
| 15 | + final class HealthIndicatorAdapter implements HealthIndicator { | ||
| 16 | + | ||
| 17 | + private final PythonEmbedHealthIndicator delegate; | ||
| 18 | + | ||
| 19 | + HealthIndicatorAdapter(PythonEmbedHealthIndicator delegate) { | ||
| 20 | + this.delegate = delegate; | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + @Override | ||
| 24 | + public Health health() { | ||
| 25 | + PythonEmbedHealthIndicator.HealthData data = delegate.health(); | ||
| 26 | + if (data.up()) { | ||
| 27 | + Health.Builder builder = Health.up(); | ||
| 28 | + for (Map.Entry<String, Object> entry : data.details().entrySet()) { | ||
| 29 | + builder.withDetail(entry.getKey(), entry.getValue()); | ||
| 30 | + } | ||
| 31 | + return builder.build(); | ||
| 32 | + } | ||
| 33 | + Health.Builder builder = Health.down(); | ||
| 34 | + if (data.error() != null) { | ||
| 35 | + builder.withException(data.error()); | ||
| 36 | + } | ||
| 37 | + for (Map.Entry<String, Object> entry : data.details().entrySet()) { | ||
| 38 | + builder.withDetail(entry.getKey(), entry.getValue()); | ||
| 39 | + } | ||
| 40 | + return builder.build(); | ||
| 41 | + } | ||
| 42 | + } | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,92 @@ | |||
| 1 | + package io.github.howtis.pythonembed.spring; | ||
| 2 | + | ||
| 3 | + import org.slf4j.Logger; | ||
| 4 | + import org.slf4j.LoggerFactory; | ||
| 5 | + | ||
| 6 | + import java.lang.reflect.InvocationTargetException; | ||
| 7 | + import java.lang.reflect.Method; | ||
| 8 | + import java.lang.reflect.Proxy; | ||
| 9 | + import java.util.Map; | ||
| 10 | + | ||
| 11 | + /** | ||
| 12 | + * Adapts a {@link PythonEmbedHealthIndicator} to Spring Boot 4.x | ||
| 13 | + * {@code org.springframework.boot.health.contributor.HealthIndicator} via reflection. | ||
| 14 | + * | ||
| 15 | + * <p>Because the starter compiles against Spring Boot 3.x, Boot 4.x types are not | ||
| 16 | + * available at compile time. This adapter uses {@link Proxy} and reflection to | ||
| 17 | + * bridge the gap at runtime when Boot 4.x is on the classpath. | ||
| 18 | + * | ||
| 19 | + * <p>Registered by {@link PythonEmbedAutoConfiguration.HealthIndicatorConfigurationV4}. | ||
| 20 | + */ | ||
| 21 | + final class HealthIndicatorV4Adapter { | ||
| 22 | + | ||
| 23 | + private static final Logger log = LoggerFactory.getLogger(HealthIndicatorV4Adapter.class); | ||
| 24 | + | ||
| 25 | + static final String HEALTH_INDICATOR_CLASS = | ||
| 26 | + "org.springframework.boot.health.contributor.HealthIndicator"; | ||
| 27 | + static final String HEALTH_CLASS = | ||
| 28 | + "org.springframework.boot.health.contributor.Health"; | ||
| 29 | + | ||
| 30 | + private HealthIndicatorV4Adapter() { | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + /** | ||
| 34 | + * Creates a dynamic proxy implementing Boot 4.x {@code HealthIndicator}, | ||
| 35 | + * delegating to the given {@link PythonEmbedHealthIndicator}. | ||
| 36 | + */ | ||
| 37 | + static Object create(PythonEmbedHealthIndicator delegate) { | ||
| 38 | + try { | ||
| 39 | + Class<?> healthIndicatorClass = Class.forName(HEALTH_INDICATOR_CLASS); | ||
| 40 | + return Proxy.newProxyInstance( | ||
| 41 | + healthIndicatorClass.getClassLoader(), | ||
| 42 | + new Class<?>[]{healthIndicatorClass}, | ||
| 43 | + (proxy, method, args) -> { | ||
| 44 | + if ("health".equals(method.getName())) { | ||
| 45 | + return buildHealth(delegate.health()); | ||
| 46 | + } | ||
| 47 | + return method.invoke(proxy, args); | ||
| 48 | + }); | ||
| 49 | + } catch (ClassNotFoundException e) { | ||
| 50 | + throw new IllegalStateException( | ||
| 51 | + "Spring Boot 4.x HealthIndicator not found on classpath", e); | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + private static Object buildHealth(PythonEmbedHealthIndicator.HealthData data) { | ||
| 56 | + try { | ||
| 57 | + Class<?> healthClass = Class.forName(HEALTH_CLASS); | ||
| 58 | + Method upMethod = healthClass.getMethod("up"); | ||
| 59 | + Method downMethod = healthClass.getMethod("down"); | ||
| 60 | + Method withDetailMethod = healthClass.getMethod("withDetail", String.class, Object.class); | ||
| 61 | + Method withExceptionMethod = healthClass.getMethod("withException", Throwable.class); | ||
| 62 | + Method buildMethod = healthClass.getMethod("build"); | ||
| 63 | + | ||
| 64 | + Object builder; | ||
| 65 | + if (data.up()) { | ||
| 66 | + builder = upMethod.invoke(null); | ||
| 67 | + } else { | ||
| 68 | + builder = downMethod.invoke(null); | ||
| 69 | + if (data.error() != null) { | ||
| 70 | + builder = withExceptionMethod.invoke(builder, data.error()); | ||
| 71 | + } | ||
| 72 | + } | ||
| 73 | + for (Map.Entry<String, Object> entry : data.details().entrySet()) { | ||
| 74 | + builder = withDetailMethod.invoke(builder, entry.getKey(), entry.getValue()); | ||
| 75 | + } | ||
| 76 | + return buildMethod.invoke(builder); | ||
| 77 | + } catch (ClassNotFoundException e) { | ||
| 78 | + throw new IllegalStateException( | ||
| 79 | + "Spring Boot 4.x Health class not found on classpath", e); | ||
| 80 | + } catch (NoSuchMethodException | IllegalAccessException e) { | ||
| 81 | + throw new IllegalStateException( | ||
| 82 | + "Failed to reflectively access Boot 4.x Health API", e); | ||
| 83 | + } catch (InvocationTargetException e) { | ||
| 84 | + Throwable cause = e.getCause(); | ||
| 85 | + if (cause instanceof RuntimeException re) { | ||
| 86 | + throw re; | ||
| 87 | + } | ||
| 88 | + throw new IllegalStateException( | ||
| 89 | + "Boot 4.x Health builder threw an exception", cause); | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + } | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -146,25 +146,54 @@ public void destroy() { | |||
| 146 | 146 | } | |
| 147 | 147 | ||
| 148 | 148 | // ------------------------------------------------------------------ | |
| 149 | - // HealthIndicator (auto-detected when Actuator is on classpath) | ||
| 149 | + // HealthIndicator — Boot 3.x (Actuator on classpath) | ||
| 150 | 150 | // ------------------------------------------------------------------ | |
| 151 | 151 | ||
| 152 | - @org.springframework.context.annotation.Configuration(proxyBeanMethods = false) | ||
| 152 | + @Bean | ||
| 153 | 153 | @ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator") | |
| 154 | - static class HealthIndicatorConfiguration { | ||
| 154 | + @ConditionalOnMissingBean | ||
| 155 | + @ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "SINGLE", matchIfMissing = true) | ||
| 156 | + PythonEmbedHealthIndicator pythonEmbedHealthIndicator(PythonEmbed embed) { | ||
| 157 | + return new PythonEmbedHealthIndicator.Single(embed); | ||
| 158 | + } | ||
| 155 | 159 | ||
| 156 | - @Bean | ||
| 157 | - @ConditionalOnMissingBean | ||
| 158 | - @ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "SINGLE", matchIfMissing = true) | ||
| 159 | - PythonEmbedHealthIndicator pythonEmbedHealthIndicator(PythonEmbed embed) { | ||
| 160 | - return new PythonEmbedHealthIndicator.Single(embed); | ||
| 161 | - } | ||
| 160 | + @Bean | ||
| 161 | + @ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator") | ||
| 162 | + @ConditionalOnMissingBean | ||
| 163 | + @ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "POOL") | ||
| 164 | + PythonEmbedHealthIndicator pythonEmbedPoolHealthIndicator(PythonEmbedPool pool) { | ||
| 165 | + return new PythonEmbedHealthIndicator.Pool(pool); | ||
| 166 | + } | ||
| 162 | 167 | ||
| 163 | - @Bean | ||
| 164 | - @ConditionalOnMissingBean | ||
| 165 | - @ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "POOL") | ||
| 166 | - PythonEmbedHealthIndicator pythonEmbedPoolHealthIndicator(PythonEmbedPool pool) { | ||
| 167 | - return new PythonEmbedHealthIndicator.Pool(pool); | ||
| 168 | - } | ||
| 168 | + @Bean | ||
| 169 | + @ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator") | ||
| 170 | + @ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "SINGLE", matchIfMissing = true) | ||
| 171 | + HealthIndicatorAdapter healthIndicatorAdapter(PythonEmbed embed) { | ||
| 172 | + return new HealthIndicatorAdapter(new PythonEmbedHealthIndicator.Single(embed)); | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + @Bean | ||
| 176 | + @ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator") | ||
| 177 | + @ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "POOL") | ||
| 178 | + HealthIndicatorAdapter healthIndicatorPoolAdapter(PythonEmbedPool pool) { | ||
| 179 | + return new HealthIndicatorAdapter(new PythonEmbedHealthIndicator.Pool(pool)); | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + // ------------------------------------------------------------------ | ||
| 183 | + // HealthIndicator — Boot 4.x (HealthContributor on classpath) | ||
| 184 | + // ------------------------------------------------------------------ | ||
| 185 | + | ||
| 186 | + @Bean("pythonEmbedHealthIndicatorV4") | ||
| 187 | + @ConditionalOnClass(name = "org.springframework.boot.health.contributor.HealthIndicator") | ||
| 188 | + @ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "SINGLE", matchIfMissing = true) | ||
| 189 | + Object healthIndicatorV4(PythonEmbed embed) { | ||
| 190 | + return HealthIndicatorV4Adapter.create(new PythonEmbedHealthIndicator.Single(embed)); | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + @Bean("pythonEmbedPoolHealthIndicatorV4") | ||
| 194 | + @ConditionalOnClass(name = "org.springframework.boot.health.contributor.HealthIndicator") | ||
| 195 | + @ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "POOL") | ||
| 196 | + Object healthIndicatorPoolV4(PythonEmbedPool pool) { | ||
| 197 | + return HealthIndicatorV4Adapter.create(new PythonEmbedHealthIndicator.Pool(pool)); | ||
| 169 | 198 | } | |
| 170 | 199 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -5,22 +5,55 @@ | |||
| 5 | 5 | import io.github.howtis.pythonembed.PythonExecutionException; | |
| 6 | 6 | import org.slf4j.Logger; | |
| 7 | 7 | import org.slf4j.LoggerFactory; | |
| 8 | - import org.springframework.boot.actuate.health.Health; | ||
| 9 | - import org.springframework.boot.actuate.health.HealthIndicator; | ||
| 8 | + | ||
| 9 | + import java.util.LinkedHashMap; | ||
| 10 | + import java.util.Map; | ||
| 10 | 11 | ||
| 11 | 12 | /** | |
| 12 | - * Actuator health indicator for python-embed. | ||
| 13 | + * Health indicator logic for python-embed (Spring Boot version-agnostic). | ||
| 14 | + * | ||
| 15 | + * <p>Does not implement any Spring Boot health interface directly. | ||
| 16 | + * Instead, version-specific adapters ({@link HealthIndicatorAdapter} for | ||
| 17 | + * Boot 3.x, {@link HealthIndicatorV4Adapter} for Boot 4.x) wrap instances | ||
| 18 | + * of this class and expose them as the appropriate {@code HealthIndicator}. | ||
| 13 | 19 | * | |
| 14 | 20 | * <p>SINGLE mode: calls {@link PythonEmbed#health()} -- UP if the | |
| 15 | 21 | * Python process responds without error. | |
| 16 | 22 | * | |
| 17 | 23 | * <p>POOL mode: checks that the pool has at least {@link PythonEmbedPool#minPool()} | |
| 18 | 24 | * live instances ({@link PythonEmbedPool#size()} >= {@code minPool}). | |
| 19 | 25 | */ | |
| 20 | - public sealed abstract class PythonEmbedHealthIndicator implements HealthIndicator { | ||
| 26 | + public sealed abstract class PythonEmbedHealthIndicator { | ||
| 21 | 27 | ||
| 22 | 28 | private static final Logger log = LoggerFactory.getLogger(PythonEmbedHealthIndicator.class); | |
| 23 | 29 | ||
| 30 | + /** | ||
| 31 | + * Version-agnostic health result. | ||
| 32 | + * | ||
| 33 | + * @param up whether the component is healthy | ||
| 34 | + * @param details key-value details (may be empty) | ||
| 35 | + * @param error the exception that caused the failure (or null) | ||
| 36 | + */ | ||
| 37 | + public record HealthData(boolean up, Map<String, Object> details, Exception error) { | ||
| 38 | + public HealthData { | ||
| 39 | + details = Map.copyOf(details); | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + public static HealthData up(Map<String, Object> details) { | ||
| 43 | + return new HealthData(true, details, null); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + public static HealthData down(Exception error) { | ||
| 47 | + return new HealthData(false, Map.of(), error); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + public static HealthData down(Map<String, Object> details) { | ||
| 51 | + return new HealthData(false, details, null); | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + public abstract HealthData health(); | ||
| 56 | + | ||
| 24 | 57 | static final class Single extends PythonEmbedHealthIndicator { | |
| 25 | 58 | private final PythonEmbed embed; | |
| 26 | 59 | ||
@@ -29,20 +62,18 @@ static final class Single extends PythonEmbedHealthIndicator { | |||
| 29 | 62 | } | |
| 30 | 63 | ||
| 31 | 64 | @Override | |
| 32 | - public Health health() { | ||
| 65 | + public HealthData health() { | ||
| 33 | 66 | try { | |
| 34 | 67 | var info = embed.health(); | |
| 35 | - return Health.up() | ||
| 36 | - .withDetail("memoryRssKb", info.memoryRssKb()) | ||
| 37 | - .withDetail("refCount", info.refCount()) | ||
| 38 | - .withDetail("gcEnabled", info.gcEnabled()) | ||
| 39 | - .withDetail("gcCounts", info.gcCounts()) | ||
| 40 | - .build(); | ||
| 68 | + Map<String, Object> details = new LinkedHashMap<>(); | ||
| 69 | + details.put("memoryRssKb", info.memoryRssKb()); | ||
| 70 | + details.put("refCount", info.refCount()); | ||
| 71 | + details.put("gcEnabled", info.gcEnabled()); | ||
| 72 | + details.put("gcCounts", info.gcCounts()); | ||
| 73 | + return HealthData.up(details); | ||
| 41 | 74 | } catch (PythonExecutionException e) { | |
| 42 | 75 | log.warn("PythonEmbed health check failed", e); | |
| 43 | - return Health.down() | ||
| 44 | - .withException(e) | ||
| 45 | - .build(); | ||
| 76 | + return HealthData.down(e); | ||
| 46 | 77 | } | |
| 47 | 78 | } | |
| 48 | 79 | } | |
@@ -55,23 +86,20 @@ static final class Pool extends PythonEmbedHealthIndicator { | |||
| 55 | 86 | } | |
| 56 | 87 | ||
| 57 | 88 | @Override | |
| 58 | - public Health health() { | ||
| 89 | + public HealthData health() { | ||
| 59 | 90 | try { | |
| 60 | 91 | int size = pool.size(); | |
| 61 | 92 | int min = pool.minPool(); | |
| 62 | 93 | int active = pool.activeCount(); | |
| 63 | 94 | boolean healthy = size >= min; | |
| 64 | - var builder = healthy ? Health.up() : Health.down(); | ||
| 65 | - return builder | ||
| 66 | - .withDetail("size", size) | ||
| 67 | - .withDetail("minPool", min) | ||
| 68 | - .withDetail("activeCount", active) | ||
| 69 | - .build(); | ||
| 95 | + Map<String, Object> details = new LinkedHashMap<>(); | ||
| 96 | + details.put("size", size); | ||
| 97 | + details.put("minPool", min); | ||
| 98 | + details.put("activeCount", active); | ||
| 99 | + return healthy ? HealthData.up(details) : HealthData.down(details); | ||
| 70 | 100 | } catch (Exception e) { | |
| 71 | 101 | log.warn("PythonEmbedPool health check failed", e); | |
| 72 | - return Health.down() | ||
| 73 | - .withException(e) | ||
| 74 | - .build(); | ||
| 102 | + return HealthData.down(e); | ||
| 75 | 103 | } | |
| 76 | 104 | } | |
| 77 | 105 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -41,9 +41,6 @@ PythonEmbedPool mockPythonEmbedPool() { | |||
| 41 | 41 | ||
| 42 | 42 | @Test | |
| 43 | 43 | void contextLoadsInSingleMode() { | |
| 44 | - // Mock beans prevent real PythonEmbed/PythonEmbedPool creation | ||
| 45 | - // (real creation would fail without Python installed). | ||
| 46 | - // The context should load without errors. | ||
| 47 | 44 | contextRunner | |
| 48 | 45 | .withUserConfiguration(MockConfig.class) | |
| 49 | 46 | .withPropertyValues("python-embed.mode=SINGLE") | |
@@ -70,8 +67,8 @@ void singleModeHealthIndicatorCreatedWithActuator() { | |||
| 70 | 67 | .withUserConfiguration(MockConfig.class) | |
| 71 | 68 | .withPropertyValues("python-embed.mode=SINGLE") | |
| 72 | 69 | .run(ctx -> { | |
| 73 | - // Actuator IS on test classpath (spring-boot-starter-actuator) | ||
| 74 | 70 | assertThat(ctx.containsBean("pythonEmbedHealthIndicator")).isTrue(); | |
| 71 | + assertThat(ctx.containsBean("healthIndicatorAdapter")).isTrue(); | ||
| 75 | 72 | }); | |
| 76 | 73 | } | |
| 77 | 74 | ||
@@ -82,6 +79,7 @@ void poolModeHealthIndicatorCreatedWithActuator() { | |||
| 82 | 79 | .withPropertyValues("python-embed.mode=POOL") | |
| 83 | 80 | .run(ctx -> { | |
| 84 | 81 | assertThat(ctx.containsBean("pythonEmbedPoolHealthIndicator")).isTrue(); | |
| 82 | + assertThat(ctx.containsBean("healthIndicatorPoolAdapter")).isTrue(); | ||
| 85 | 83 | }); | |
| 86 | 84 | } | |
| 87 | 85 | ||
@@ -119,16 +117,11 @@ void poolCloserDestroyCallsPoolClose() { | |||
| 119 | 117 | ||
| 120 | 118 | @Test | |
| 121 | 119 | void userProvidedHealthIndicatorOverridesSingleMode() { | |
| 122 | - // Mockito cannot mock sealed PythonEmbedHealthIndicator directly; | ||
| 123 | - // use the concrete Single subclass (this still satisfies @ConditionalOnMissingBean) | ||
| 124 | 120 | contextRunner | |
| 125 | 121 | .withUserConfiguration(MockConfig.class, UserHealthIndicatorConfig.class) | |
| 126 | 122 | .withPropertyValues("python-embed.mode=SINGLE") | |
| 127 | 123 | .run(ctx -> { | |
| 128 | 124 | assertThat(ctx).hasNotFailed(); | |
| 129 | - // When a user provides their own indicator, the auto-configured one is skipped. | ||
| 130 | - // Here we get the single bean; it should be the user's config bean, not | ||
| 131 | - // the auto-configured one (which would use a different constructor). | ||
| 132 | 125 | var bean = ctx.getBean(PythonEmbedHealthIndicator.class); | |
| 133 | 126 | assertThat(bean).isNotNull(); | |
| 134 | 127 | }); | |
@@ -151,7 +144,6 @@ static class UserHealthIndicatorConfig { | |||
| 151 | 144 | @Bean | |
| 152 | 145 | @Primary | |
| 153 | 146 | PythonEmbedHealthIndicator customHealthIndicator() { | |
| 154 | - // Use concrete subclass — sealed PythonEmbedHealthIndicator cannot be mocked | ||
| 155 | 147 | return new PythonEmbedHealthIndicator.Single(mock(PythonEmbed.class)); | |
| 156 | 148 | } | |
| 157 | 149 | } | |
| Back | FazBrowse Home | New Git URL |
0 commit comments