Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions beast-base/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,38 @@
</systemPropertyVariables>
</configuration>
</plugin>
<!-- Copy version.xml to target/ so BEASTClassLoader.initServices()
finds it when scanning classpath entry target/classes -->
<!-- Publish test JAR so downstream packages can use BEASTTestCase etc. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<goals><goal>test-jar</goal></goals>
</execution>
</executions>
</plugin>
<!-- Embed version.xml in JAR so downstream packages can discover
beast.base services via BEASTClassLoader.initServices() -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>embed-version-xml-in-jar</id>
<phase>generate-resources</phase>
<goals><goal>copy-resources</goal></goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}</outputDirectory>
<resources>
<resource>
<directory>${project.basedir}/..</directory>
<includes>
<include>version.xml</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
<execution>
<id>copy-version-xml</id>
<phase>generate-test-resources</phase>
Expand Down
186 changes: 157 additions & 29 deletions beast-pkgmgmt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,38 +214,19 @@ Skipping modules with unsatisfied dependencies in .../MM: [beast.morph.models.fx

No special configuration or flags are needed.

## Service discovery

BEAST uses a dual service discovery mechanism:

1. **Primary: `module-info.java` `provides` declarations** — the recommended
approach for BEAST 3 packages. When a `ModuleLayer` is created, all
`provides` declarations from its modules are merged into the
`BEASTClassLoader` service registry. IDEs like IntelliJ resolve these
automatically from the module path.
## Class loading vs service discovery

2. **Supplementary: `version.xml`** — each package includes a `version.xml`
listing `<service>` entries with `<provider>` elements. This is parsed at
startup and used alongside module descriptors. It is required for backward
compatibility mappings (`<map>` elements) and for declaring dependencies
(`<depends>`).
These are two separate concerns that `BEASTClassLoader` handles:

Both mechanisms feed into the same registry in `BEASTClassLoader`. When code
calls `BEASTClassLoader.loadService(BEASTInterface.class)`, all providers
from both sources are returned.
- **Class loading** answers: "Given the fully qualified name
`beast.base.evolution.datatype.Nucleotide`, find and load that class."
- **Service discovery** answers: "What classes implement the
`DataType` interface?" (i.e. which classes should be registered as
available data types?)

### How services are merged

When a package has both `module-info.java` `provides` declarations and
`version.xml` `<service>` entries, both are registered. The service sets are
unioned — duplicates are harmlessly deduplicated via `Set.add()`.

For the class-loader map (used by `BEASTClassLoader.forName()`), the
first-registered loader wins (`putIfAbsent`). Since Maven packages are
loaded before ZIP packages, Maven takes precedence when both formats provide
the same service class. Within a single package, `version.xml` services are
registered before `module-info.java` services, but because they share the
same `ModuleLayer` class-loader this has no practical effect.
A class can be loadable without being discovered as a service provider,
and vice versa. Both must work for BEAST to function: the service registry
tells BEAST *which* classes to use, and the class loader actually loads them.

## Class loading

Expand All @@ -261,6 +242,67 @@ This means classes from external packages are found without any classpath
manipulation — the plugin `ModuleLayer` mechanism handles isolation and
visibility.

## Service discovery

BEAST uses a dual service discovery mechanism. When code calls
`BEASTClassLoader.loadService(DataType.class)`, both mechanisms are
consulted and their results merged.

### Primary: module descriptors

`BEASTClassLoader` scans `provides` declarations from `module-info.java`
in `ModuleLayer.boot()` and all plugin layers. This is the recommended
approach for BEAST 3 packages, and is the mechanism used when modules are
on the JPMS module path (e.g. in an IDE).

**Important limitation**: Maven Surefire runs tests on the *classpath*, not
the module path, even for projects with `module-info.java`. This means
`ModuleLayer.boot()` contains no BEAST modules during test execution, and
module descriptor scanning finds nothing. This is a Maven tooling limitation,
not a BEAST bug.

### Supplementary: version.xml scanning

`BEASTClassLoader.initServices()` scans for `version.xml` files and parses
their `<service>` entries. It searches two places:

1. **`java.class.path`** — for each classpath entry, navigate up two parent
directories and look for `version.xml`. This works for beast3's own tests
because `beast-base/target/classes` is on the classpath, and going up two
levels reaches `beast3/version.xml`.

2. **`BEAST_PACKAGE_PATH`** — environment variable or `-D` system property.
Scans each colon-separated entry:
- If a directory: looks for `version.xml` in that directory
- If a JAR file: looks for `version.xml` inside the JAR

This mechanism is essential for downstream package tests, where beast-base
is a Maven dependency (JAR in `~/.m2/`) rather than a sibling module with
`target/classes` on the classpath.

`version.xml` is also required for backward compatibility mappings (`<map>`
elements) and for declaring package dependencies (`<depends>`).

### How services are merged

When a package has both `module-info.java` `provides` declarations and
`version.xml` `<service>` entries, both are registered. The service sets are
unioned — duplicates are harmlessly deduplicated via `Set.add()`.

For the class-loader map (used by `BEASTClassLoader.forName()`), the
first-registered loader wins (`putIfAbsent`). Since Maven packages are
loaded before ZIP packages, Maven takes precedence when both formats provide
the same service class.

### Service discovery in different contexts

| Context | Module descriptors | version.xml scan |
|---------|-------------------|-----------------|
| **IDE** (module path) | Works: modules in boot layer | Not needed (but harmless) |
| **End user** (runtime) | Works: plugin `ModuleLayer`s | Also works: ZIP package dirs |
| **beast3 own tests** (surefire) | Does NOT work: classpath, not module path | Works: `target/classes` parent walk |
| **Downstream package tests** (surefire) | Does NOT work | Works via `BEAST_PACKAGE_PATH` pointing at beast-base JAR |

## Package structure conventions

External packages follow the conventions in
Expand Down Expand Up @@ -321,3 +363,89 @@ Custom Maven repositories can be added via:
```
packagemanager -addMavenRepository https://example.com/maven/
```

## Testing downstream packages

External packages that depend on beast3 via Maven need specific configuration
for their tests to work. The core issue is that Maven Surefire runs tests on
the classpath, not the JPMS module path, so the primary service discovery
mechanism (module descriptors) does not work during tests. Instead, service
discovery falls back to `version.xml` scanning.

### Required POM configuration

```xml
<dependencies>
<!-- beast3 core (compile scope) -->
<dependency>
<groupId>io.github.compevol</groupId>
<artifactId>beast-base</artifactId>
<version>${beast.version}</version>
</dependency>

<!-- Test utilities: BEASTTestCase, TestOperator, etc.
Uses 'optional' scope so it lands on the module path
at compile time (required for JPMS visibility). -->
<dependency>
<groupId>io.github.compevol</groupId>
<artifactId>beast-test-utils</artifactId>
<version>${beast.version}</version>
<optional>true</optional>
</dependency>

<!-- Add 'requires static beast.test.utils' to your module-info.java -->
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>
--add-reads YOUR.MODULE=ALL-UNNAMED
--add-reads YOUR.MODULE=beast.test.utils
--add-reads beast.base=ALL-UNNAMED
--add-reads beast.pkgmgmt=ALL-UNNAMED
</argLine>
<systemPropertyVariables>
<!-- Point at your own version.xml AND the beast-base JAR
so that beast.base services (DataType, etc.) are
discovered via version.xml scanning. -->
<BEAST_PACKAGE_PATH>
${project.build.outputDirectory}:${settings.localRepository}/io/github/compevol/beast-base/${beast.version}/beast-base-${beast.version}.jar
</BEAST_PACKAGE_PATH>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
```

### Why this is needed

1. **`BEAST_PACKAGE_PATH`** — Surefire runs on the classpath, so the
two-parent-directory walk in `initServices()` does not find beast-base's
`version.xml` (it's inside a JAR in `~/.m2/`, not in a sibling
`target/classes`). Pointing `BEAST_PACKAGE_PATH` at the JAR triggers the
JAR-internal `version.xml` scan.

2. **`--add-reads`** — Even though surefire uses the classpath, JPMS module
declarations are still partially enforced. The `--add-reads` flags allow
your module's test code to access the unnamed module (where JUnit and
beast-test-utils live at runtime).

3. **`beast-test-utils`** — Provides `BEASTTestCase` and `TestOperator` in
a proper JPMS module (`beast.test.utils`). This avoids split-package
errors: if your tests were in package `test.beast`, they would clash with
the `test.beast` package exported by `beast.test.utils`. Use subpackages
like `test.beast.evolution.likelihood` (fine) or `test.yourpackage`
(safest).

### Split package pitfall

Do NOT put test classes directly in the `test.beast` package. The
`beast.test.utils` module exports `test.beast`, so any other module with
classes in `test.beast` triggers a JPMS split-package error. Subpackages
like `test.beast.core` or `test.beast.evolution.tree` are fine because JPMS
treats each dotted package name as independent.
39 changes: 38 additions & 1 deletion beast-pkgmgmt/src/main/java/beast/pkgmgmt/BEASTClassLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ private static void collectProviders(ModuleLayer layer, String serviceName, Set<
}
}

/** Initialise services by scanning the classpath for version.xml files. */
/** Initialise services by scanning the classpath and BEAST_PACKAGE_PATH for version.xml files. */
public static void initServices() {
versionXmlScanned = true;
String classPath = System.getProperty("java.class.path");
Expand All @@ -234,6 +234,43 @@ public static void initServices() {
// ignore
}
initServices("/" + classPath + "/");

// Also scan BEAST_PACKAGE_PATH for version.xml files.
// This is needed for downstream package tests where beast-base
// is a Maven dependency (JAR in ~/.m2/) rather than a sibling
// module with target/classes on the classpath.
String packagePath = System.getProperty("BEAST_PACKAGE_PATH");
if (packagePath == null) {
packagePath = System.getenv("BEAST_PACKAGE_PATH");
}
if (packagePath != null) {
for (String dir : packagePath.split(File.pathSeparator)) {
File d = new File(dir);
if (d.isDirectory()) {
File vf = new File(d, "version.xml");
if (vf.exists()) {
addServices(vf.getAbsolutePath());
}
} else if (d.isFile() && dir.endsWith(".jar")) {
// Look for version.xml inside the JAR
try (java.util.jar.JarFile jar = new java.util.jar.JarFile(d)) {
java.util.jar.JarEntry entry = jar.getJarEntry("version.xml");
if (entry != null) {
// Extract to a temp file so addServices() can parse it
java.io.File tmp = java.io.File.createTempFile("beast-version-", ".xml");
tmp.deleteOnExit();
try (java.io.InputStream is = jar.getInputStream(entry);
java.io.OutputStream os = new java.io.FileOutputStream(tmp)) {
is.transferTo(os);
}
addServices(tmp.getAbsolutePath());
}
} catch (Exception e) {
// ignore
}
}
}
}
}

/** Initialise services from a given classpath string. */
Expand Down
33 changes: 33 additions & 0 deletions beast-test-utils/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>io.github.compevol</groupId>
<artifactId>beast3</artifactId>
<version>2.8.0-SNAPSHOT</version>
</parent>

<artifactId>beast-test-utils</artifactId>
<name>BEAST Test Utilities</name>
<description>Shared test utilities for BEAST packages (BEASTTestCase, TestOperator, etc.)</description>

<dependencies>
<dependency>
<groupId>io.github.compevol</groupId>
<artifactId>beast-base</artifactId>
</dependency>
<dependency>
<groupId>io.github.compevol</groupId>
<artifactId>beast-pkgmgmt</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>

</project>
8 changes: 8 additions & 0 deletions beast-test-utils/src/main/java/module-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
open module beast.test.utils {
requires beast.base;
requires beast.pkgmgmt;
requires org.junit.jupiter.api;

exports test.beast;
exports test.beast.evolution.operator;
}
Loading