diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000000..07a707b7ef --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,54 @@ +name: Nightly Integration Tests + +# The integration tests reach out to CATH, ECOD, RCSB, EBI, UniProt and others. +# That makes them valuable - they are how we find out that an upstream service +# has changed a URL, a format or a redirect - but it also makes them unsuitable +# as a gate on pull requests, because an outage anywhere blocks every +# contributor. Running them on a schedule keeps the coverage while decoupling it +# from people's ability to merge. +on: + schedule: + # 03:17 UTC daily. Off the hour deliberately: scheduled jobs that ask for + # exactly midnight queue behind everybody else's. + - cron: '17 3 * * *' + # Also runnable by hand, e.g. to confirm an upstream service is back. + workflow_dispatch: + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + integrationtest: + runs-on: ubuntu-latest + # These tests download large files from servers we do not control; the + # default 6 hour limit is far more than they need and far more than we want + # to spend if one of them hangs. + timeout-minutes: 90 + strategy: + matrix: + # One JDK only. The point of this run is to exercise the network paths, + # not the language level, which the pull request build already covers + # across 11, 17 and 21. + java: [21] + fail-fast: false + name: Integration tests, JDK ${{ matrix.java }} + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'oracle' + java-version: ${{ matrix.java }} + - name: Build and run integration tests + run: mvn verify --no-transfer-progress + - name: Upload surefire reports + # Kept on failure so an upstream break can be diagnosed after the fact: + # GitHub expires run logs after 90 days, and these reports carry the + # stack traces that say which service misbehaved. + if: failure() + uses: actions/upload-artifact@v4 + with: + name: surefire-reports + path: '**/target/surefire-reports/**' + retention-days: 30 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a0d31ee08a..340418cf68 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -30,8 +30,14 @@ jobs: with: distribution: 'oracle' java-version: ${{ matrix.java }} - - name: Build, test and integration test - run: mvn verify --no-transfer-progress + - name: Build and test (no integration tests) + # Integration tests are excluded here and run nightly instead, see + # nightly.yml. They depend on CATH, ECOD, RCSB, EBI and others being up + # and responsive, so running them on every pull request means a third + # party having a bad day blocks contributors, and a real regression + # cannot be told apart from the resulting noise. Master Build already + # excludes them for the same reason. + run: mvn verify -pl '!biojava-integrationtest' --no-transfer-progress # Note that 11 is not available in openjdk. So we need to do it with the Zulu distribution (see https://github.com/actions/setup-java) # When we drop 11, it will be safe to drop the copy-pasted workflow excerpt below @@ -54,5 +60,11 @@ jobs: with: distribution: 'zulu' java-version: ${{ matrix.java }} - - name: Build, test and integration test - run: mvn verify --no-transfer-progress + - name: Build and test (no integration tests) + # Integration tests are excluded here and run nightly instead, see + # nightly.yml. They depend on CATH, ECOD, RCSB, EBI and others being up + # and responsive, so running them on every pull request means a third + # party having a bad day blocks contributors, and a real regression + # cannot be told apart from the resulting noise. Master Build already + # excludes them for the same reason. + run: mvn verify -pl '!biojava-integrationtest' --no-transfer-progress diff --git a/CHANGELOG.md b/CHANGELOG.md index 07be3a5172..fd2a848907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,78 @@ BioJava Changelog ----------------- -BioJava 7.2.3 - future release +BioJava 7.3.0 +============================== +### Added +* Fetching, caching and display of electron density and cryo-EM maps #1134 +* Checksum verification (MD5, SHA-1, SHA-256) in `FileDownloadUtils`, replacing the previous + stub. Downloads from `files.wwpdb.org` and `files.rcsb.org` are checksummed from their `ETag` + without a second request #1133 +* `HttpStatusException`, so callers can tell "not there" from a transport failure #1133 +* `FileDownloadUtils.downloadFileWithValidation()`, which downloads and validates over a single + connection #1133 +* `LocalPDBDirectory.getMiddleHash(String)`, computing the two-character directory from the + right so that it is correct for both short and extended PDB IDs #1133 +* Support for the ECOD distribution format introduced at v294.1 #1141 #1139 + +### Performance +* Contact calculation is about 1.5x faster: squared distances compared against a squared cutoff, + primitive arrays in `GridCell` instead of boxed lists, and a pre-sized `AtomContactSet` #1147 +* ASA calculation is about 1.2-1.3x faster, by the same replacement of objects with flat + primitive arrays in the hot loops #1148 +* `EcodInstallation.getVersion()` reads the file header instead of parsing every domain. The + current release is 653 MB and holds nearly three million records #1141 + +### Fixed +* CATH downloads use https and check the response before caching it. The previous http URL began + redirecting, and the redirect body was cached as classification data #1133 #1138 +* CATH parsing no longer throws `ArrayIndexOutOfBoundsException` on blank or truncated lines, and + cached files are validated before being used #1145 +* Chemical component downloads reject redirects and error responses rather than caching them #1133 +* Redirects that `HttpURLConnection` does not follow by itself - 307, 308, and any that change + http to https - are now followed #1151 #1149 +* `FileDownloadUtils`: the `hash` argument was ignored by one overload; downloads could be + silently truncated; temporary files leaked on failure; `validateFile` threw on a file with no + parent directory and on an empty `.size` sidecar #1133 +* mmCIF writer emits the entry identifier as a data item, so `getPdbId()` survives a + write-then-read round trip #1144 #1143 +* The ECOD read lock is left balanced when a download fails, so the original error is no longer + replaced by `IllegalMonitorStateException` #1150 +* `FileParsingParameters.setParseCAOnly(true)` keeps only C-alpha atoms when reading mmCIF and + BinaryCIF, as it does for PDB files. Since the unified CIF parser it had dropped only non-CA + carbons, keeping every N, O and S atom. Groups and chains that hold no C-alpha (waters, + ligands, nucleotides) are no longer created at all in this mode +* Resolution parsing warns only when the values actually differ + +### Changed +* `createValidationFiles()` now defaults to `ETagPolicy.USE_IF_HEX_DIGEST`, so existing callers + begin recording checksums where the server offers one #1133 +* Integration tests run nightly rather than on every pull request #1137 #1135 +* Tests migrated to JUnit 5 #1125 #1126 #1038 +* Library upgrades #1130 #1132 + +### Removed +* The `junit-addons` test dependency, no longer needed after the JUnit 5 migration #1126 + +BioJava 7.2.6 +============================== +### Fixed +* Parsing of PDBx/mmCIF with empty database_PDB_rev.date + +BioJava 7.2.5 +============================== +### Fixed +* Fix NPE in Structure.toMMCIF() for some PDB entries (e.g. 2G10) +* Maven plugin duplication in main pom.xml +* Fixes for SonarQube S1155 +* Some library upgrades + +BioJava 7.2.4 +============================== +### Fixed +* Edge case in quaternary symmetry calculation #1120 + +BioJava 7.2.3 ============================== ### Fixed * Don't use label_seq_id in mmCIF output for non-polymers #1116 diff --git a/README.md b/README.md index af128cd92c..5c4b8f6ed2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Welcome to ![Build](https://github.com/biojava/biojava/actions/workflows/master.yml/badge.svg) -[![Version](http://img.shields.io/badge/version-7.2.2-blue.svg?style=flat)](https://github.com/biojava/biojava/releases/tag/biojava-7.2.2) [![License](http://img.shields.io/badge/license-LGPL_2.1-blue.svg?style=flat)](https://github.com/biojava/biojava/blob/master/LICENSE) [![Join the chat at https://gitter.im/biojava/biojava](https://badges.gitter.im/biojava/biojava.svg)](https://gitter.im/biojava/biojava?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Version](http://img.shields.io/badge/version-7.2.5-blue.svg?style=flat)](https://github.com/biojava/biojava/releases/tag/biojava-7.2.5) [![License](http://img.shields.io/badge/license-LGPL_2.1-blue.svg?style=flat)](https://github.com/biojava/biojava/blob/master/LICENSE) [![Join the chat at https://gitter.im/biojava/biojava](https://badges.gitter.im/biojava/biojava.svg)](https://gitter.im/biojava/biojava?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) BioJava is an open-source project dedicated to providing a Java framework for **processing biological data**. It provides analytical and statistical routines, parsers for common file formats, reference implementations of popular algorithms, and allows the manipulation of sequences and 3D structures. The goal of the biojava project is to facilitate rapid application development for bioinformatics. @@ -29,7 +29,7 @@ If you are using Maven you can add the BioJava repository by adding the followin org.biojava biojava-core - 7.2.2 + 7.2.5 diff --git a/biojava-aa-prop/pom.xml b/biojava-aa-prop/pom.xml index 0c1016c3b6..04db67d2ac 100644 --- a/biojava-aa-prop/pom.xml +++ b/biojava-aa-prop/pom.xml @@ -2,7 +2,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT 4.0.0 biojava-aa-prop @@ -70,12 +70,12 @@ org.biojava biojava-core - 7.2.3 + 7.3.0-SNAPSHOT org.biojava biojava-structure - 7.2.3 + 7.3.0-SNAPSHOT diff --git a/biojava-alignment/pom.xml b/biojava-alignment/pom.xml index 7bb6e3b2ee..7f0594181f 100644 --- a/biojava-alignment/pom.xml +++ b/biojava-alignment/pom.xml @@ -4,7 +4,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-alignment biojava-alignment @@ -47,7 +47,7 @@ org.biojava biojava-core - 7.2.3 + 7.3.0-SNAPSHOT compile diff --git a/biojava-core/pom.xml b/biojava-core/pom.xml index 890ce360bc..2085e47b5b 100644 --- a/biojava-core/pom.xml +++ b/biojava-core/pom.xml @@ -3,7 +3,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT 4.0.0 biojava-core diff --git a/biojava-core/src/main/java/org/biojava/nbio/core/alignment/SimpleAlignedSequence.java b/biojava-core/src/main/java/org/biojava/nbio/core/alignment/SimpleAlignedSequence.java index 331480bbef..99b70c036d 100644 --- a/biojava-core/src/main/java/org/biojava/nbio/core/alignment/SimpleAlignedSequence.java +++ b/biojava-core/src/main/java/org/biojava/nbio/core/alignment/SimpleAlignedSequence.java @@ -411,7 +411,7 @@ private void setLocation(List steps) { } // combine sublocations into 1 Location - if (sublocations.size() == 0) { + if (sublocations.isEmpty()) { location = null; } else if (sublocations.size() == 1) { location = sublocations.get(0); diff --git a/biojava-core/src/main/java/org/biojava/nbio/core/sequence/GeneSequence.java b/biojava-core/src/main/java/org/biojava/nbio/core/sequence/GeneSequence.java index f0f2662fea..638e4e68d9 100644 --- a/biojava-core/src/main/java/org/biojava/nbio/core/sequence/GeneSequence.java +++ b/biojava-core/src/main/java/org/biojava/nbio/core/sequence/GeneSequence.java @@ -119,7 +119,7 @@ public void addIntronsUsingExons() throws Exception { if (intronAdded) { //going to assume introns are correct return; } - if (exonSequenceList.size() == 0) { + if (exonSequenceList.isEmpty()) { return; } ExonComparator exonComparator = new ExonComparator(); diff --git a/biojava-core/src/main/java/org/biojava/nbio/core/sequence/location/InsdcParser.java b/biojava-core/src/main/java/org/biojava/nbio/core/sequence/location/InsdcParser.java index e49bd22216..2d43a481bf 100644 --- a/biojava-core/src/main/java/org/biojava/nbio/core/sequence/location/InsdcParser.java +++ b/biojava-core/src/main/java/org/biojava/nbio/core/sequence/location/InsdcParser.java @@ -260,7 +260,9 @@ private List parseLocationString(String string, int versus) { l.setPartialOn3prime(true); } - if (!(accession == null || "".equals(accession))) l.setAccession(new AccessionID(accession)); + if (accession != null && !"".equals(accession)) { + l.setAccession(new AccessionID(accession)); + } boundedLocationsCollection.add(l); diff --git a/biojava-core/src/main/java/org/biojava/nbio/core/sequence/storage/SequenceAsStringHelper.java b/biojava-core/src/main/java/org/biojava/nbio/core/sequence/storage/SequenceAsStringHelper.java index c2b02debee..4acd8969f1 100644 --- a/biojava-core/src/main/java/org/biojava/nbio/core/sequence/storage/SequenceAsStringHelper.java +++ b/biojava-core/src/main/java/org/biojava/nbio/core/sequence/storage/SequenceAsStringHelper.java @@ -44,7 +44,7 @@ public class SequenceAsStringHelper { */ public String getSequenceAsString(List parsedCompounds, CompoundSet compoundSet, Integer bioBegin, Integer bioEnd, Strand strand) { // TODO Optimise/cache. - if(parsedCompounds.size() == 0) + if(parsedCompounds.isEmpty()) return ""; StringBuilder builder = new StringBuilder(); if (strand.equals(Strand.NEGATIVE)) { diff --git a/biojava-core/src/main/java/org/biojava/nbio/core/util/Equals.java b/biojava-core/src/main/java/org/biojava/nbio/core/util/Equals.java index e8f78243ed..7e2c7128c9 100644 --- a/biojava-core/src/main/java/org/biojava/nbio/core/util/Equals.java +++ b/biojava-core/src/main/java/org/biojava/nbio/core/util/Equals.java @@ -49,7 +49,13 @@ public static boolean equal(boolean one, boolean two) { * @see #classEqual(Object, Object) */ public static boolean equal(Object one, Object two) { - return one == null && two == null || !(one == null || two == null) && (one == two || one.equals(two)); + if (one == two) { + return true; + } + if (one == null || two == null) { + return false; + } + return one.equals(two); } /** @@ -84,6 +90,12 @@ public static boolean equal(Object one, Object two) { * equal at the class level */ public static boolean classEqual(Object one, Object two) { - return one == two || !(one == null || two == null) && one.getClass() == two.getClass(); + if (one == two) { + return true; + } + if (one == null || two == null) { + return false; + } + return one.getClass() == two.getClass(); } } diff --git a/biojava-core/src/main/java/org/biojava/nbio/core/util/FileDownloadUtils.java b/biojava-core/src/main/java/org/biojava/nbio/core/util/FileDownloadUtils.java index 0b132b180e..63fee00d57 100644 --- a/biojava-core/src/main/java/org/biojava/nbio/core/util/FileDownloadUtils.java +++ b/biojava-core/src/main/java/org/biojava/nbio/core/util/FileDownloadUtils.java @@ -21,22 +21,29 @@ */ package org.biojava.nbio.core.util; +import java.io.BufferedInputStream; import java.io.File; import java.io.FileNotFoundException; -import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.PrintStream; import java.net.HttpURLConnection; +import java.net.MalformedURLException; import java.net.SocketTimeoutException; import java.net.URL; import java.net.URLConnection; -import java.nio.channels.Channels; -import java.nio.channels.ReadableByteChannel; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedHashSet; import java.util.Scanner; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,10 +54,48 @@ public class FileDownloadUtils { private static final String HASH_EXT = ".hash"; private static final Logger logger = LoggerFactory.getLogger(FileDownloadUtils.class); + /** Buffer used when streaming a file through a {@link MessageDigest}. */ + private static final int DIGEST_BUFFER_SIZE = 64 * 1024; + + /** Redirects to follow before giving up, in case a server sends us in a circle. */ + private static final int MAX_REDIRECTS = 5; + + /** A bare hex digest, optionally followed by whitespace and a file name (the + * layout written by md5sum, sha1sum and friends). */ + private static final Pattern BARE_HEX_HASH = Pattern.compile("^([0-9a-fA-F]{32,128})(?:[\\s*].*)?$"); + + /** The BSD layout, e.g. MD5 (file.txt) = d41d8cd9.... */ + private static final Pattern BSD_HASH = Pattern.compile("^\\w+\\s*\\(.*\\)\\s*=\\s*([0-9a-fA-F]{32,128})$"); + public enum Hash{ MD5, SHA1, SHA256, UNKNOWN } + /** + * What to do with the ETag response header when no explicit hash + * URL is available. + *

+ * Some archives — notably files.wwpdb.org and + * files.rcsb.org — return the MD5 digest of the content as + * the bare ETag value, which lets us record a real checksum + * without a second request. Others (the EBI servers, for instance) return a + * <modification-time>-<size> form instead; because that + * always contains a -, it can never be mistaken for a hex digest. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public enum ETagPolicy { + /** Never look at the ETag header. */ + IGNORE, + /** Record the ETag as a checksum when it is a bare hex digest + * of a length matching one of the supported algorithms. */ + USE_IF_HEX_DIGEST, + /** As {@link #USE_IF_HEX_DIGEST}, but log a warning when the header is + * missing or is not a usable digest. */ + REQUIRE + } + /** * Gets the file extension of a file, excluding '.'. * If the file name has no extension the file name is returned. @@ -95,44 +140,261 @@ public static void downloadFile(URL url, File destination) throws IOException { int maxTries = 10; int timeout = 60000; //60 sec - File tempFile = Files.createTempFile(getFilePrefix(destination), "." + getFileExtension(destination)).toFile(); + File tempFile = createTempFileFor(destination); - // Took following recipe from stackoverflow: - // http://stackoverflow.com/questions/921262/how-to-download-and-save-a-file-from-internet-using-java - // It seems to be the most efficient way to transfer a file - // See: http://docs.oracle.com/javase/7/docs/api/java/nio/channels/FileChannel.html - ReadableByteChannel rbc = null; - FileOutputStream fos = null; - while (true) { - try { - URLConnection connection = prepareURLConnection(url.toString(), timeout); - connection.connect(); - InputStream inputStream = connection.getInputStream(); - - rbc = Channels.newChannel(inputStream); - fos = new FileOutputStream(tempFile); - fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); - break; - } catch (SocketTimeoutException e) { - if (++count == maxTries) throw e; - } finally { - if (rbc != null) { - rbc.close(); + try { + while (true) { + try { + URLConnection connection = openConnectionFollowingRedirects(url, timeout); + checkHttpStatus(connection); + try (InputStream inputStream = connection.getInputStream()) { + // Files.copy loops until end of stream. FileChannel.transferFrom(), used + // here previously, is not guaranteed to drain a socket-backed channel in + // a single call and could silently truncate a download. + Files.copy(inputStream, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + break; + } catch (SocketTimeoutException e) { + if (++count == maxTries) throw e; } - if (fos != null) { - fos.close(); + } + + logger.debug("Copying temp file [{}] to final location [{}]", tempFile, destination); + Files.copy(tempFile.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING); + } finally { + // on every path, including failure: the temp file used to leak whenever + // the download threw. + deleteQuietly(tempFile); + } + } + + /** + * Downloads a file and writes its validation metadata in a single pass over a + * single connection. + *

+ * This is preferable to calling {@link #createValidationFiles(URL, File, URL, Hash)} + * followed by {@link #downloadFile(URL, File)}: those open separate connections, + * so the Content-Length recorded in the .size file + * comes from a different response than the bytes actually written. If the + * resource changes between the two requests, the cache entry is left + * permanently failing validation. + *

+ * The content is streamed to a temporary file and only moved into place once + * the declared length and (where available) the checksum have been confirmed, + * so a failed download never leaves a partial file at destination. + * + * @param url the remote file to download + * @param destination the local file to download into. Its parent directory must exist. + * @param hashURL URL of a file containing the expected hash. May be null. + * @param hash the hashing algorithm matching hashURL. Ignored when + * hashURL is null. + * @param eTagPolicy what to do with an ETag response header when + * hashURL is null. May be null, + * which is treated as {@link ETagPolicy#IGNORE}. + * @throws HttpStatusException if the server answered with a non-2xx status + * @throws IOException if the transfer failed, or the transferred content did not + * match the length or checksum the server declared + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static void downloadFileWithValidation(URL url, File destination, URL hashURL, Hash hash, + ETagPolicy eTagPolicy) throws IOException { + int timeout = 60000; //60 sec + ETagPolicy policy = eTagPolicy == null ? ETagPolicy.IGNORE : eTagPolicy; + + File tempFile = createTempFileFor(destination); + try { + URLConnection connection = openConnectionFollowingRedirects(url, timeout); + checkHttpStatus(connection); + + long declaredSize = connection.getContentLengthLong(); + String eTag = connection.getHeaderField("ETag"); + + // Only digest when we have something to compare against; hashing every + // download would cost CPU for no benefit. + Hash eTagHash = policy == ETagPolicy.IGNORE ? Hash.UNKNOWN : hashFromETag(eTag); + if (policy == ETagPolicy.REQUIRE && eTagHash == Hash.UNKNOWN) { + logger.warn("ETag [{}] of {} is not a usable hex digest; no checksum will be recorded.", eTag, url); + } + + MessageDigest digest = eTagHash == Hash.UNKNOWN ? null : newDigest(eTagHash); + long written; + try (InputStream raw = connection.getInputStream(); + InputStream in = digest == null ? raw : new DigestInputStream(raw, digest)) { + written = Files.copy(in, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + if (declaredSize >= 0 && written != declaredSize) { + throw new IOException(String.format( + "Incomplete download of %s: got %d bytes but the server declared %d.", + url, written, declaredSize)); + } + + String actualDigest = digest == null ? null : toHex(digest.digest()); + if (actualDigest != null && !actualDigest.equalsIgnoreCase(normalizeETag(eTag))) { + throw new IOException(String.format( + "Corrupt download of %s: %s of the content is %s but the server's ETag says %s.", + url, eTagHash, actualDigest, normalizeETag(eTag))); + } + + moveIntoPlace(tempFile, destination); + + // Sidecars are written only once the content is known good, so a failed + // download can never leave validation metadata describing a file that is + // not there. + writeSizeFile(destination, written); + if (hashURL != null) { + if (hash == null || hash == Hash.UNKNOWN) { + throw new IllegalArgumentException("Hash URL given but algorithm is unknown"); } + downloadFile(hashURL, hashFileFor(destination, hash)); + } else if (actualDigest != null) { + writeHashFile(destination, eTagHash, actualDigest); + } + } finally { + deleteQuietly(tempFile); + } + } + + /** + * Opens a connection, following any redirect that {@link HttpURLConnection} + * declines to follow itself. + *

+ * The JDK follows 301, 302 and 303 within a protocol, but it never follows 307 or + * 308, and it never follows a redirect that changes http to https. Both gaps have + * broken downloads in practice: CATH began answering http with a 301 to https, and + * ECOD now answers with a 308 to a rewritten path. A browser follows either without + * comment, so a service making that change has no reason to expect it to break us. + *

+ * A redirect from https to http is deliberately not followed: a redirect + * must never silently downgrade the transport. Such a response is returned as it is, + * for {@link #checkHttpStatus(URLConnection)} to reject. + * + * @param url the URL to open + * @param timeout connect and read timeout, in milliseconds + * @return a connected {@link URLConnection} at the final location + * @throws HttpStatusException if the redirects loop or exceed the limit + * @throws IOException if the connection could not be opened + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static URLConnection openConnectionFollowingRedirects(URL url, int timeout) throws IOException { + Set visited = new LinkedHashSet<>(); + URL current = url; + for (int hop = 0; hop <= MAX_REDIRECTS; hop++) { + if (!visited.add(current.toString())) { + throw new HttpStatusException(HttpURLConnection.HTTP_SEE_OTHER, url.toString(), + "Redirect loop: " + String.join(" -> ", visited)); + } + URLConnection connection = prepareURLConnection(current.toString(), timeout); + connection.connect(); + if (!(connection instanceof HttpURLConnection)) { + return connection; } + URL next = redirectTarget((HttpURLConnection) connection, current); + if (next == null) { + // either not a redirect, or one we decline to follow; the caller's + // checkHttpStatus decides what a non-2xx status means + return connection; + } + logger.info("{} redirects to {}; following.", current, next); + ((HttpURLConnection) connection).disconnect(); + current = next; } + throw new HttpStatusException(HttpURLConnection.HTTP_SEE_OTHER, url.toString(), + "More than " + MAX_REDIRECTS + " redirects starting at " + url); + } - logger.debug("Copying temp file [{}] to final location [{}]", tempFile, destination); - Files.copy(tempFile.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING); + /** + * Works out where a response redirects to, for the redirects the JDK leaves to us. + * + * @param http a connected connection whose status has not yet been acted on + * @param current the URL that was requested, used to resolve a relative location + * @return the redirect target, or null if this is not a redirect we should follow + * @throws IOException if the status could not be read + * @since 7.3.0 + */ + private static URL redirectTarget(HttpURLConnection http, URL current) throws IOException { + return redirectTargetFor(http.getResponseCode(), http.getHeaderField("Location"), current); + } - // delete the tmp file - tempFile.delete(); + /** + * Decides where a response redirects to, given only its status and location. Split + * out from {@link #redirectTarget(HttpURLConnection, URL)} so that the rules can be + * tested without standing up a server. + * + * @param code the HTTP status + * @param location the Location header, may be null, relative or absolute + * @param current the URL that was requested, used to resolve a relative location + * @return the redirect target, or null if this is not a redirect we should follow + * @since 7.3.0 + */ + static URL redirectTargetFor(int code, String location, URL current) { + // 301, 302 and 303 only reach us when the JDK declined them, which it does when + // the protocol changes. 307 and 308 it never follows at all. + boolean redirect = code == HttpURLConnection.HTTP_MOVED_PERM + || code == HttpURLConnection.HTTP_MOVED_TEMP + || code == HttpURLConnection.HTTP_SEE_OTHER + || code == 307 + || code == 308; + if (!redirect) { + return null; + } + if (location == null || location.trim().isEmpty()) { + logger.warn("{} returned {} with no Location header.", current, code); + return null; + } + URL target; + try { + // resolves a relative Location, which is what ECOD sends + target = new URL(current, location.trim()); + } catch (MalformedURLException e) { + logger.warn("{} returned {} to an unusable Location [{}].", current, code, location); + return null; + } + if ("https".equalsIgnoreCase(current.getProtocol()) + && !"https".equalsIgnoreCase(target.getProtocol())) { + logger.warn("Refusing to follow {} from {} to [{}]: a redirect must not downgrade https to {}.", + code, current, target, target.getProtocol()); + return null; + } + return target; + } + /** + * Verifies that an HTTP connection returned a 2xx status. Connections using a + * non-HTTP protocol (file:, ftp:, ...) are left alone. + *

+ * Without this check a 404 error page is written into the cache as though it + * were the requested file — and because the .size sidecar is + * then taken from that same error response, {@link #validateFile(File)} would + * subsequently declare it valid. + * + * @param connection an already connected {@link URLConnection} + * @throws HttpStatusException if the status is outside the 2xx range + * @throws IOException if the status could not be read + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static void checkHttpStatus(URLConnection connection) throws IOException { + if (!(connection instanceof HttpURLConnection)) { + return; + } + HttpURLConnection http = (HttpURLConnection) connection; + int code = http.getResponseCode(); + if (code >= 200 && code < 300) { + return; + } + if (code == 301 || code == 302 || code == 307 || code == 308) { + // openConnectionFollowingRedirects handles the redirects the JDK will not, + // so one reaching here was declined deliberately: an https to http + // downgrade, a missing or unusable Location, or too many hops. + logger.warn("{} returned redirect {} to [{}], which was not followed.", + connection.getURL(), code, http.getHeaderField("Location")); + } + throw new HttpStatusException(code, connection.getURL().toString(), http.getResponseMessage()); } - + /** * Creates validation files beside a file to be downloaded.
* Whenever possible, for a file.ext file, it creates @@ -146,9 +408,27 @@ public static void downloadFile(URL url, File destination) throws IOException { * @param hash The Hashing algorithm. Ignored if hashURL is null. */ public static void createValidationFiles(URL url, File localDestination, URL hashURL, Hash hash){ + createValidationFiles(url, localDestination, hashURL, hash, ETagPolicy.USE_IF_HEX_DIGEST); + } + + /** + * Creates validation files beside a file to be downloaded, with explicit control + * over how the ETag response header is treated. + * + * @param url the remote file URL to download + * @param localDestination the local file to download into + * @param hashURL the URL of the hash file to download. Can be null. + * @param hash The Hashing algorithm. Ignored if hashURL is null. + * @param eTagPolicy how to treat the ETag header when hashURL + * is null. May be null, treated as {@link ETagPolicy#IGNORE}. + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static void createValidationFiles(URL url, File localDestination, URL hashURL, Hash hash, + ETagPolicy eTagPolicy){ try { - URLConnection resourceConnection = url.openConnection(); - createValidationFiles(resourceConnection, localDestination, hashURL, FileDownloadUtils.Hash.UNKNOWN); + URLConnection resourceConnection = openConnectionFollowingRedirects(url, 60000); + createValidationFiles(resourceConnection, localDestination, hashURL, hash, eTagPolicy); } catch (IOException e) { logger.warn("could not open connection to resource file due to exception: {}", e.getMessage()); } @@ -169,31 +449,246 @@ public static void createValidationFiles(URL url, File localDestination, URL has * @since 7.0.0 */ public static void createValidationFiles(URLConnection resourceUrlConnection, File localDestination, URL hashURL, Hash hash){ + createValidationFiles(resourceUrlConnection, localDestination, hashURL, hash, ETagPolicy.USE_IF_HEX_DIGEST); + } + + /** + * Creates validation files beside a file to be downloaded, with explicit control + * over how the ETag response header is treated. + *

+ * Nothing is written when the connection reports a non-2xx status: previously an + * error page's Content-Length would be recorded as the expected + * size, so the cached error page then passed validation. + * + * @param resourceUrlConnection the remote file URLConnection to download + * @param localDestination the local file to download into + * @param hashURL the URL of the hash file to download. Can be null. + * @param hash The Hashing algorithm. Ignored if hashURL is null. + * @param eTagPolicy how to treat the ETag header when hashURL + * is null. May be null, treated as {@link ETagPolicy#IGNORE}. + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static void createValidationFiles(URLConnection resourceUrlConnection, File localDestination, URL hashURL, + Hash hash, ETagPolicy eTagPolicy){ + try { + checkHttpStatus(resourceUrlConnection); + } catch (IOException e) { + logger.warn("Not writing validation metadata for {}: {}", resourceUrlConnection.getURL(), e.getMessage()); + return; + } + long size = resourceUrlConnection.getContentLengthLong(); if(size == -1) { logger.debug("Could not find expected file size for resource {}. Size validation metadata file won't be available for this download.", resourceUrlConnection.getURL()); } else { logger.debug("Content-Length: {}", size); - File sizeFile = new File(localDestination.getParentFile(), localDestination.getName() + SIZE_EXT); - try (PrintStream sizePrintStream = new PrintStream(sizeFile)) { - sizePrintStream.print(size); - } catch (FileNotFoundException e) { - logger.warn("Could not write size validation metadata file due to exception: {}", e.getMessage()); - } + writeSizeFile(localDestination, size); } - - if(hashURL == null) + + if(hashURL == null) { + ETagPolicy policy = eTagPolicy == null ? ETagPolicy.IGNORE : eTagPolicy; + if (policy != ETagPolicy.IGNORE) { + String eTag = resourceUrlConnection.getHeaderField("ETag"); + Hash eTagHash = hashFromETag(eTag); + if (eTagHash == Hash.UNKNOWN) { + if (policy == ETagPolicy.REQUIRE) { + logger.warn("ETag [{}] of {} is not a usable hex digest; no checksum recorded.", + eTag, resourceUrlConnection.getURL()); + } + } else { + writeHashFile(localDestination, eTagHash, normalizeETag(eTag)); + } + } return; + } - if(hash == Hash.UNKNOWN) + if(hash == null || hash == Hash.UNKNOWN) throw new IllegalArgumentException("Hash URL given but algorithm is unknown"); try { - File hashFile = new File(localDestination.getParentFile(), String.format("%s%s_%s", localDestination.getName(), HASH_EXT, hash)); - downloadFile(hashURL, hashFile); + downloadFile(hashURL, hashFileFor(localDestination, hash)); + } catch (IOException e) { + logger.warn("Could not write validation hash file due to exception: {}", e.getMessage()); + } + } + + /** + * Determines which hashing algorithm an ETag header value + * represents, based on the length of the hex digest it contains. + *

+ * Only a value consisting solely of hex characters is considered. The + * <time>-<size> form used by nginx and Apache always + * contains a - and therefore never matches. + * + * @param eTagHeaderValue the raw header value, possibly quoted or weak-prefixed. + * May be null. + * @return the matching algorithm, or {@link Hash#UNKNOWN} if the value is not a + * hex digest of a recognised length + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static Hash hashFromETag(String eTagHeaderValue) { + String value = normalizeETag(eTagHeaderValue); + if (value == null || !value.matches("[0-9a-fA-F]+")) { + return Hash.UNKNOWN; + } + switch (value.length()) { + case 32: return Hash.MD5; + case 40: return Hash.SHA1; + case 64: return Hash.SHA256; + default: return Hash.UNKNOWN; + } + } + + /** + * Strips the weak-validator prefix and surrounding quotes from an + * ETag header value. + * + * @param eTagHeaderValue the raw header value. May be null. + * @return the bare value, or null if the input was null + * or blank + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static String normalizeETag(String eTagHeaderValue) { + if (eTagHeaderValue == null) { + return null; + } + String value = eTagHeaderValue.trim(); + if (value.startsWith("W/")) { + value = value.substring(2).trim(); + } + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + return value.isEmpty() ? null : value; + } + + /** + * Writes a <name>.hash_<ALGORITHM> sidecar file + * containing the given digest as bare lowercase hex. + * + * @param localDestination the file the digest describes + * @param hash the hashing algorithm + * @param hexDigest the digest, in hex + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static void writeHashFile(File localDestination, Hash hash, String hexDigest) { + if (hash == null || hash == Hash.UNKNOWN || hexDigest == null) { + return; + } + File hashFile = hashFileFor(localDestination, hash); + try (PrintStream out = new PrintStream(hashFile, StandardCharsets.UTF_8.name())) { + out.println(hexDigest.toLowerCase()); } catch (IOException e) { logger.warn("Could not write validation hash file due to exception: {}", e.getMessage()); } } + + /** + * Computes the digest of a file. + * + * @param file the file to digest + * @param hash the algorithm to use + * @return the digest as bare lowercase hex + * @throws IOException if the file could not be read + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static String computeHash(File file, Hash hash) throws IOException { + try (InputStream in = new BufferedInputStream(Files.newInputStream(file.toPath()), DIGEST_BUFFER_SIZE)) { + return computeHash(in, hash); + } + } + + /** + * Computes the digest of a stream. The stream is read to its end but not closed. + * + * @param in the stream to digest + * @param hash the algorithm to use + * @return the digest as bare lowercase hex + * @throws IOException if the stream could not be read + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static String computeHash(InputStream in, Hash hash) throws IOException { + MessageDigest digest = newDigest(hash); + byte[] buffer = new byte[DIGEST_BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + return toHex(digest.digest()); + } + + /** + * Checks a file against an expected digest. + * + * @param file the file to check + * @param hash the algorithm to use + * @param expectedHex the expected digest, in hex; compared case-insensitively + * @return true if the digests match + * @throws IOException if the file could not be read + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static boolean verifyHash(File file, Hash hash, String expectedHex) throws IOException { + return expectedHex != null && expectedHex.trim().equalsIgnoreCase(computeHash(file, hash)); + } + + /** + * The JDK name of a hashing algorithm, which differs from the enum constant for + * the SHA variants. + * + * @param hash the algorithm + * @return the name to pass to {@link MessageDigest#getInstance(String)} + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static String getAlgorithmName(Hash hash) { + switch (hash) { + case MD5: return "MD5"; + case SHA1: return "SHA-1"; + case SHA256: return "SHA-256"; + default: throw new IllegalArgumentException("Hashing algorithm not known: " + hash); + } + } + + /** + * Reads the expected digest out of a .hash_XXXX sidecar file. + *

+ * The file may have been downloaded verbatim from a server, so several common + * layouts are accepted: a bare hex digest, the md5sum style + * <hex>  <filename>, and the BSD style + * MD5 (<filename>) = <hex>. + * + * @param hashFile the sidecar file + * @return the digest in hex, or null if nothing recognisable was found + */ + static String parseHashFile(File hashFile) { + try (Scanner scanner = new Scanner(hashFile, StandardCharsets.UTF_8.name())) { + while (scanner.hasNextLine()) { + String line = scanner.nextLine().trim(); + if (line.isEmpty()) { + continue; + } + Matcher bare = BARE_HEX_HASH.matcher(line); + if (bare.matches()) { + return bare.group(1); + } + Matcher bsd = BSD_HASH.matcher(line); + if (bsd.matches()) { + return bsd.group(1); + } + return null; // first meaningful line was not a digest + } + } catch (IOException e) { + logger.warn("Could not read hash file [{}]: {}", hashFile, e.getMessage()); + } + return null; + } + /** * Validate a local file based on pre-existing metadata files for size and hash.
@@ -210,45 +705,154 @@ public static void createValidationFiles(URLConnection resourceUrlConnection, Fi * @since 7.0.0 */ public static boolean validateFile(File localFile) { - File sizeFile = new File(localFile.getParentFile(), localFile.getName() + SIZE_EXT); + // getParentFile() is null for a bare relative name such as new File("x.cif"), + // which used to make this method throw a NullPointerException. + File parent = localFile.getAbsoluteFile().getParentFile(); + if (parent == null) { + logger.debug("Cannot determine the parent directory of [{}]; nothing to validate against.", localFile); + return true; + } + + File sizeFile = new File(parent, localFile.getName() + SIZE_EXT); if(sizeFile.exists()) { try (Scanner scanner = new Scanner(sizeFile)) { - long expectedSize = scanner.nextLong(); - long actualSize = localFile.length(); - if (expectedSize != actualSize) { - logger.warn("File [{}] size ({}) does not match expected size ({}).", localFile, actualSize, expectedSize); - return false; + if (!scanner.hasNextLong()) { + // An empty or truncated .size file used to raise an unchecked + // NoSuchElementException that escaped the catch below. + logger.warn("Size metadata file [{}] is empty or malformed; skipping size validation.", sizeFile); + } else { + long expectedSize = scanner.nextLong(); + long actualSize = localFile.length(); + if (expectedSize != actualSize) { + logger.warn("File [{}] size ({}) does not match expected size ({}).", localFile, actualSize, expectedSize); + return false; + } } } catch (FileNotFoundException e) { logger.warn("could not validate size of file [{}] because no size metadata file exists.", localFile); } } - File[] hashFiles = localFile.getParentFile().listFiles(new FilenameFilter() { - final String hashPattern = String.format("%s%s_(%s|%s|%s)", localFile.getName(), HASH_EXT, Hash.MD5, Hash.SHA1, Hash.SHA256); + File[] hashFiles = parent.listFiles(new FilenameFilter() { + final String hashPattern = String.format("%s%s_(%s|%s|%s)", Pattern.quote(localFile.getName()), HASH_EXT, Hash.MD5, Hash.SHA1, Hash.SHA256); @Override public boolean accept(File dir, String name) { return name.matches(hashPattern); } }); - if(hashFiles.length > 0) { - File hashFile = hashFiles[0]; + // listFiles() returns null if the parent is not a directory or cannot be read. + if (hashFiles == null || hashFiles.length == 0) { + return true; + } + + // Verify against every sidecar present, not only the first one found. + for (File hashFile : hashFiles) { String name = hashFile.getName(); String algo = name.substring(name.lastIndexOf('_') + 1); - switch (Hash.valueOf(algo)) { - case MD5: - case SHA1: - case SHA256: - throw new UnsupportedOperationException("Not yet implemented"); - case UNKNOWN: - default: // No need. Already checked above + Hash hash; + try { + hash = Hash.valueOf(algo); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Hashing algorithm not known: " + algo, e); + } + if (hash == Hash.UNKNOWN) { throw new IllegalArgumentException("Hashing algorithm not known: " + algo); } + + String expected = parseHashFile(hashFile); + if (expected == null) { + // A sidecar we cannot read should not condemn an otherwise good download. + logger.warn("Could not read a digest from [{}]; skipping {} validation of [{}].", hashFile, hash, localFile); + continue; + } + try { + if (!verifyHash(localFile, hash, expected)) { + logger.warn("File [{}] {} does not match the expected digest {}.", localFile, hash, expected); + return false; + } + } catch (IOException e) { + logger.warn("Could not compute the {} of [{}]: {}", hash, localFile, e.getMessage()); + return false; + } } - + return true; } + /** + * The <name>.hash_<ALGORITHM> sidecar file for a + * downloaded file. + */ + private static File hashFileFor(File localDestination, Hash hash) { + return new File(localDestination.getAbsoluteFile().getParentFile(), + String.format("%s%s_%s", localDestination.getName(), HASH_EXT, hash)); + } + + /** + * Writes the <name>.size sidecar file. + */ + private static void writeSizeFile(File localDestination, long size) { + File sizeFile = new File(localDestination.getAbsoluteFile().getParentFile(), + localDestination.getName() + SIZE_EXT); + try (PrintStream sizePrintStream = new PrintStream(sizeFile, StandardCharsets.UTF_8.name())) { + sizePrintStream.print(size); + } catch (IOException e) { + logger.warn("Could not write size validation metadata file due to exception: {}", e.getMessage()); + } + } + + private static MessageDigest newDigest(Hash hash) { + try { + return MessageDigest.getInstance(getAlgorithmName(hash)); + } catch (NoSuchAlgorithmException e) { + // MD5, SHA-1 and SHA-256 are required of every Java platform. + throw new IllegalStateException("Required hashing algorithm is unavailable: " + hash, e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + /** + * Creates a temp file whose name is derived from the destination. + * {@link Files#createTempFile} rejects prefixes shorter than 3 characters, so + * short names are padded. + */ + private static File createTempFileFor(File destination) throws IOException { + String prefix = getFilePrefix(destination); + while (prefix.length() < 3) { + prefix = prefix + "_"; + } + return Files.createTempFile(prefix, "." + getFileExtension(destination)).toFile(); + } + + private static void moveIntoPlace(File tempFile, File destination) throws IOException { + try { + Files.move(tempFile.toPath(), destination.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + // The temp directory is often on a different filesystem than the cache. + Files.copy(tempFile.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void deleteQuietly(File file) { + if (file == null) { + return; + } + try { + Files.deleteIfExists(file.toPath()); + } catch (IOException e) { + logger.debug("Could not delete temporary file [{}]: {}", file, e.getMessage()); + } + } + /** * Converts path to Unix convention and adds a terminating slash if it was * omitted. diff --git a/biojava-core/src/main/java/org/biojava/nbio/core/util/HttpStatusException.java b/biojava-core/src/main/java/org/biojava/nbio/core/util/HttpStatusException.java new file mode 100644 index 0000000000..c74c6ffb05 --- /dev/null +++ b/biojava-core/src/main/java/org/biojava/nbio/core/util/HttpStatusException.java @@ -0,0 +1,84 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.core.util; + +import java.io.IOException; + +/** + * Signals that an HTTP request completed but returned a status code outside the + * 2xx range. + *

+ * This exists so that callers can tell apart the two very different reasons a + * download can fail: + *

    + *
  • The resource does not exist ({@link #isNotFound()}, i.e. HTTP 404 or + * 410). For a caller that tries several mirrors or several alternative data + * sources in turn, this simply means "not here, try the next one".
  • + *
  • Anything else — a server error, a redirect that was not + * followed, an authentication failure. These usually mean the whole operation + * should be abandoned rather than silently treated as "no data available".
  • + *
+ * Without a distinct exception type the only way to tell these apart is by + * parsing the message of a plain {@link IOException}, which is brittle. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class HttpStatusException extends IOException { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + private final String url; + + /** + * @param statusCode the HTTP status code returned by the server + * @param url the URL that was requested + * @param responseMessage the HTTP reason phrase, may be null + */ + public HttpStatusException(int statusCode, String url, String responseMessage) { + super(String.format("HTTP %d%s for %s", statusCode, + responseMessage == null || responseMessage.isEmpty() ? "" : " " + responseMessage, url)); + this.statusCode = statusCode; + this.url = url; + } + + /** + * @return the HTTP status code returned by the server + */ + public int getStatusCode() { + return statusCode; + } + + /** + * @return the URL that was requested + */ + public String getUrl() { + return url; + } + + /** + * Whether the status indicates that the resource is simply not there, as + * opposed to a transport or server problem. + * + * @return true for HTTP 404 (Not Found) and 410 (Gone) + */ + public boolean isNotFound() { + return statusCode == 404 || statusCode == 410; + } +} diff --git a/biojava-core/src/main/java/org/biojava/nbio/core/util/PrettyXMLWriter.java b/biojava-core/src/main/java/org/biojava/nbio/core/util/PrettyXMLWriter.java index 437085866f..6e4a7db77c 100644 --- a/biojava-core/src/main/java/org/biojava/nbio/core/util/PrettyXMLWriter.java +++ b/biojava-core/src/main/java/org/biojava/nbio/core/util/PrettyXMLWriter.java @@ -72,7 +72,7 @@ public void declareNamespace(String nsURI, String prefixHint) private void handleDeclaredNamespaces() throws IOException { - if (namespacesDeclared.size() == 0) { + if (namespacesDeclared.isEmpty()) { for (Iterator nsi = namespacesDeclared.iterator(); nsi.hasNext(); ) { String nsURI = nsi.next(); if (!namespacePrefixes.containsKey(nsURI)) { diff --git a/biojava-core/src/test/java/org/biojava/nbio/core/sequence/loader/GenbankProxySequenceReaderTest.java b/biojava-core/src/test/java/org/biojava/nbio/core/sequence/loader/GenbankProxySequenceReaderTest.java index 6883637a49..0e8f7f41f5 100644 --- a/biojava-core/src/test/java/org/biojava/nbio/core/sequence/loader/GenbankProxySequenceReaderTest.java +++ b/biojava-core/src/test/java/org/biojava/nbio/core/sequence/loader/GenbankProxySequenceReaderTest.java @@ -162,7 +162,7 @@ so it should be done here (manualy). logger.info("taxonomy name '{}'", taxonName); Assert.assertNotNull(taxonName); - if (seq.getFeaturesByType("CDS").size() > 0) { + if (!seq.getFeaturesByType("CDS").isEmpty()) { FeatureInterface, AminoAcidCompound> CDS = seq.getFeaturesByType("CDS").get(0); logger.info("CDS: {}", CDS); String codedBy = CDS.getQualifiers().get("coded_by").get(0).getValue(); diff --git a/biojava-core/src/test/java/org/biojava/nbio/core/util/FileDownloadRedirectTest.java b/biojava-core/src/test/java/org/biojava/nbio/core/util/FileDownloadRedirectTest.java new file mode 100644 index 0000000000..2c46127ffc --- /dev/null +++ b/biojava-core/src/test/java/org/biojava/nbio/core/util/FileDownloadRedirectTest.java @@ -0,0 +1,226 @@ +/* + * BioJava development code + * + * This code may be freely distributed and modified under the + * terms of the GNU Lesser General Public Licence. This should + * be distributed with the code. If you do not have a copy, + * see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual + * authors. These should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, + * or to join the biojava-l mailing list, visit the home page + * at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.core.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpServer; + +/** + * Checks that downloads follow the redirects {@link java.net.HttpURLConnection} does + * not follow by itself. + *

+ * The JDK handles 301, 302 and 303 within a protocol, but never 307 or 308, and never + * a redirect that changes http to https. Both gaps have broken this project's builds: + * CATH began answering http with a 301 to https, and ECOD now answers with a 308 to a + * rewritten path. A browser follows either without comment. + *

+ * The rule tests need no network and no server. The end-to-end tests use a local + * {@link HttpServer} rather than a real service, so that they cannot fail because a + * third party is having a bad day. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +class FileDownloadRedirectTest { + + private static final String PAYLOAD = "the file you were looking for\n"; + + @Nested + class RedirectRules { + + private final URL from = url("http://example.org/ecod/distributions/ecod.latest.domains.txt"); + + @Test + void aRelativeLocationIsResolvedAgainstTheRequest() { + // exactly what ECOD sends: same host, same protocol, relative path + assertEquals(url("http://example.org/ecod-legacy/distributions/ecod.latest.domains.txt"), + FileDownloadUtils.redirectTargetFor(308, + "/ecod-legacy/distributions/ecod.latest.domains.txt", from)); + } + + @Test + void anAbsoluteLocationIsUsedAsGiven() { + assertEquals(url("https://example.org/elsewhere.txt"), + FileDownloadUtils.redirectTargetFor(301, "https://example.org/elsewhere.txt", from)); + } + + @Test + void everyRedirectStatusWeHandleIsRecognised() { + for (int code : new int[] { 301, 302, 303, 307, 308 }) { + assertEquals(url("http://example.org/x"), + FileDownloadUtils.redirectTargetFor(code, "/x", from), + "status " + code + " should be followed"); + } + } + + @Test + void aSuccessIsNotARedirect() { + assertNull(FileDownloadUtils.redirectTargetFor(200, null, from)); + assertNull(FileDownloadUtils.redirectTargetFor(404, "/x", from)); + } + + /** + * A redirect must never quietly move us onto an unencrypted transport. + */ + @Test + void httpsIsNeverDowngradedToHttp() { + URL secure = url("https://example.org/file.txt"); + assertNull(FileDownloadUtils.redirectTargetFor(301, "http://example.org/file.txt", secure)); + assertNull(FileDownloadUtils.redirectTargetFor(308, "http://elsewhere.org/file.txt", secure)); + } + + @Test + void httpToHttpsIsFollowed() { + // the CATH case + assertEquals(url("https://example.org/file.txt"), + FileDownloadUtils.redirectTargetFor(301, "https://example.org/file.txt", + url("http://example.org/file.txt"))); + } + + @Test + void anUnusableLocationIsNotFollowed() { + assertNull(FileDownloadUtils.redirectTargetFor(308, null, from)); + assertNull(FileDownloadUtils.redirectTargetFor(308, " ", from)); + assertNull(FileDownloadUtils.redirectTargetFor(308, "gopher://example.org/x", from)); + } + } + + @Nested + class EndToEnd { + + private HttpServer server; + private String base; + private File dir; + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + base = "http://127.0.0.1:" + server.getAddress().getPort(); + dir = Files.createTempDirectory("redirectTest").toFile(); + + serve("/final", 200, null); + // the ECOD shape: 308 with a relative Location + serve("/moved", 308, "/final"); + serve("/temp", 307, "/final"); + // a chain that returns to its start + serve("/loop-a", 308, "/loop-b"); + serve("/loop-b", 308, "/loop-a"); + // longer than the hop limit + for (int i = 0; i < 9; i++) { + serve("/hop" + i, 308, "/hop" + (i + 1)); + } + serve("/hop9", 200, null); + serve("/nowhere", 308, null); + server.start(); + } + + private void serve(String path, int status, String location) { + server.createContext(path, exchange -> { + byte[] body = PAYLOAD.getBytes(StandardCharsets.UTF_8); + if (location != null) { + exchange.getResponseHeaders().add("Location", location); + } + exchange.sendResponseHeaders(status, status == 200 ? body.length : -1); + if (status == 200) { + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } + exchange.close(); + }); + } + + @AfterEach + void stop() throws IOException { + server.stop(0); + FileDownloadUtils.deleteDirectory(dir.getAbsolutePath()); + } + + @Test + void a308IsFollowed() throws IOException { + File got = new File(dir, "moved.txt"); + FileDownloadUtils.downloadFile(new URL(base + "/moved"), got); + assertEquals(PAYLOAD, new String(Files.readAllBytes(got.toPath()), StandardCharsets.UTF_8)); + } + + @Test + void a307IsFollowed() throws IOException { + File got = new File(dir, "temp.txt"); + FileDownloadUtils.downloadFile(new URL(base + "/temp"), got); + assertEquals(PAYLOAD, new String(Files.readAllBytes(got.toPath()), StandardCharsets.UTF_8)); + } + + @Test + void theRedirectBodyIsNeverWhatWeStore() throws IOException { + File got = new File(dir, "validated.txt"); + FileDownloadUtils.downloadFileWithValidation(new URL(base + "/moved"), got, null, + FileDownloadUtils.Hash.UNKNOWN, FileDownloadUtils.ETagPolicy.IGNORE); + assertEquals(PAYLOAD, new String(Files.readAllBytes(got.toPath()), StandardCharsets.UTF_8)); + assertTrue(FileDownloadUtils.validateFile(got), "the recorded size must describe the real file"); + } + + @Test + void aLoopIsReportedRatherThanChasedForever() { + File got = new File(dir, "loop.txt"); + HttpStatusException e = assertThrows(HttpStatusException.class, + () -> FileDownloadUtils.downloadFile(new URL(base + "/loop-a"), got)); + assertTrue(e.getMessage().contains("loop"), e.getMessage()); + } + + @Test + void tooManyHopsGivesUp() { + File got = new File(dir, "hops.txt"); + assertThrows(HttpStatusException.class, + () -> FileDownloadUtils.downloadFile(new URL(base + "/hop0"), got)); + } + + @Test + void aRedirectWithNoDestinationIsAnError() { + File got = new File(dir, "nowhere.txt"); + assertThrows(HttpStatusException.class, + () -> FileDownloadUtils.downloadFile(new URL(base + "/nowhere"), got)); + } + } + + private static URL url(String spec) { + try { + return new URL(spec); + } catch (IOException e) { + throw new IllegalArgumentException(spec, e); + } + } +} diff --git a/biojava-core/src/test/java/org/biojava/nbio/core/util/FileDownloadUtilsTest.java b/biojava-core/src/test/java/org/biojava/nbio/core/util/FileDownloadUtilsTest.java index 201ad88e48..bec6def90f 100644 --- a/biojava-core/src/test/java/org/biojava/nbio/core/util/FileDownloadUtilsTest.java +++ b/biojava-core/src/test/java/org/biojava/nbio/core/util/FileDownloadUtilsTest.java @@ -4,6 +4,7 @@ import static org.biojava.nbio.core.util.FileDownloadUtils.getFilePrefix; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -11,6 +12,7 @@ import java.io.IOException; import java.io.PrintStream; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import org.junit.jupiter.api.Nested; @@ -187,17 +189,268 @@ void testValidationFiles() throws IOException{ assertTrue(sizeFile.exists(), "couldn't create size file"); assertTrue(FileDownloadUtils.validateFile(destFile), "file not detected to be invalid although there is correct size validation file"); + // files.wwpdb.org returns the content MD5 as the ETag, so the default + // ETag policy records a real checksum without a separate hash URL. + assertTrue(hashFile.exists(), "no hash file was derived from the ETag"); + assertTrue(FileDownloadUtils.validateFile(destFile), "correctly downloaded file failed hash validation"); + PrintStream temp2 = new PrintStream(hashFile); - temp2.print("ABCD"); // some wrong hash value + temp2.print("ABCD"); // not a digest of any supported length temp2.close(); - //This is not yet implemented. I am using this test for documentation purpose. - assertThrows(UnsupportedOperationException.class, - () -> FileDownloadUtils.validateFile(destFile), + // An unreadable sidecar must not condemn an otherwise good download. + assertTrue(FileDownloadUtils.validateFile(destFile), + "a malformed hash file should be ignored, not treated as a mismatch"); + + PrintStream temp3 = new PrintStream(hashFile); + temp3.print("00000000000000000000000000000000"); // well-formed but wrong MD5 + temp3.close(); + assertFalse(FileDownloadUtils.validateFile(destFile), "file not detected to be invalid although hash value is wrong."); - + System.out.println("Just ignore the previous warning. It is expected."); + destFile.delete(); sizeFile.delete(); hashFile.delete(); } } + + @Nested + class HttpStatus { + + /** + * Which status an absent file comes back with is the server's business, and it + * changes: files.wwpdb.org moved behind Amazon S3 in September 2026, and S3 + * answers a missing key with 403 rather than 404 when the caller cannot list + * the bucket. Pinning the code made this test fail on an upstream hosting + * change that broke nothing. + *

+ * What must hold is the contract: an error status throws, and nothing is left + * on disk for it. {@link HttpStatusException#isNotFound()} is pinned separately + * below, without a network. + */ + @Test + void anAbsentFileThrowsAndLeavesNothingBehind() throws IOException { + URL missing = new URL("https://files.wwpdb.org/pub/pdb/data/structures/divided/mmCIF/zz/zzzz.cif.gz"); + File dest = new File(System.getProperty("java.io.tmpdir"), "bj-missing.cif.gz"); + File sizeFile = new File(dest.getParentFile(), dest.getName() + ".size"); + dest.delete(); + sizeFile.delete(); + + HttpStatusException e = assertThrows(HttpStatusException.class, + () -> FileDownloadUtils.downloadFile(missing, dest)); + assertTrue(e.getStatusCode() >= 400, + "an absent file must report an error status, got " + e.getStatusCode()); + assertFalse(dest.exists(), "an error body must never be written to the destination"); + + // ... and no validation metadata may be recorded for it either, or the + // cached error page would later pass validation. + FileDownloadUtils.createValidationFiles(missing, dest, null, FileDownloadUtils.Hash.UNKNOWN); + assertFalse(sizeFile.exists(), "no size file should be written for an error response"); + } + + @Test + void isNotFoundCoversTheAbsentStatusesOnly() { + assertTrue(new HttpStatusException(404, "http://example.org/x", "Not Found").isNotFound()); + assertTrue(new HttpStatusException(410, "http://example.org/x", "Gone").isNotFound()); + // 403 is what an S3-backed archive returns for a missing key, but it is not a + // statement that the file does not exist, so it must not claim to be one + assertFalse(new HttpStatusException(403, "http://example.org/x", "Forbidden").isNotFound()); + assertFalse(new HttpStatusException(500, "http://example.org/x", "Server Error").isNotFound()); + } + } + + @Nested + class Hashing { + + private File writeTemp(String name, byte[] content) throws IOException { + File f = new File(System.getProperty("java.io.tmpdir"), name); + Files.write(f.toPath(), content); + f.deleteOnExit(); + return f; + } + + @Test + void digestsOfEmptyFileMatchKnownValues() throws IOException { + File empty = writeTemp("bj-empty.bin", new byte[0]); + assertEquals("d41d8cd98f00b204e9800998ecf8427e", + FileDownloadUtils.computeHash(empty, FileDownloadUtils.Hash.MD5)); + assertEquals("da39a3ee5e6b4b0d3255bfef95601890afd80709", + FileDownloadUtils.computeHash(empty, FileDownloadUtils.Hash.SHA1)); + assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + FileDownloadUtils.computeHash(empty, FileDownloadUtils.Hash.SHA256)); + } + + @Test + void digestOfKnownContent() throws IOException { + File abc = writeTemp("bj-abc.bin", "abc".getBytes(StandardCharsets.UTF_8)); + assertEquals("900150983cd24fb0d6963f7d28e17f72", + FileDownloadUtils.computeHash(abc, FileDownloadUtils.Hash.MD5)); + assertTrue(FileDownloadUtils.verifyHash(abc, FileDownloadUtils.Hash.MD5, + "900150983CD24FB0D6963F7D28E17F72"), "comparison should be case-insensitive"); + assertFalse(FileDownloadUtils.verifyHash(abc, FileDownloadUtils.Hash.MD5, + "00000000000000000000000000000000")); + } + + @Test + void algorithmNames() { + assertEquals("MD5", FileDownloadUtils.getAlgorithmName(FileDownloadUtils.Hash.MD5)); + assertEquals("SHA-1", FileDownloadUtils.getAlgorithmName(FileDownloadUtils.Hash.SHA1)); + assertEquals("SHA-256", FileDownloadUtils.getAlgorithmName(FileDownloadUtils.Hash.SHA256)); + assertThrows(IllegalArgumentException.class, + () -> FileDownloadUtils.getAlgorithmName(FileDownloadUtils.Hash.UNKNOWN)); + } + } + + @Nested + class HashFileParsing { + + private static final String MD5 = "900150983cd24fb0d6963f7d28e17f72"; + + private String parse(String content) throws IOException { + File f = new File(System.getProperty("java.io.tmpdir"), "bj-hashfile.txt"); + Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); + f.deleteOnExit(); + return FileDownloadUtils.parseHashFile(f); + } + + @Test + void bareHex() throws IOException { + assertEquals(MD5, parse(MD5)); + assertEquals(MD5, parse(MD5 + "\n")); + } + + @Test + void uppercaseHexIsKeptVerbatim() throws IOException { + assertEquals(MD5.toUpperCase(), parse(MD5.toUpperCase())); + } + + @Test + void coreutilsLayouts() throws IOException { + assertEquals(MD5, parse(MD5 + " somefile.cif.gz\n")); + assertEquals(MD5, parse(MD5 + " *somefile.cif.gz\n")); + } + + @Test + void bsdLayout() throws IOException { + assertEquals(MD5, parse("MD5 (somefile.cif.gz) = " + MD5 + "\n")); + } + + @Test + void blankLeadingLinesAreSkipped() throws IOException { + assertEquals(MD5, parse("\n \n" + MD5 + "\n")); + } + + @Test + void garbageYieldsNull() throws IOException { + assertNull(parse("not a hash at all\n")); + assertNull(parse("ABCD\n")); + assertNull(parse("")); + } + } + + @Nested + class ETagParsing { + + @Test + void wwpdbStyleMd5IsRecognised() { + assertEquals(FileDownloadUtils.Hash.MD5, + FileDownloadUtils.hashFromETag("\"f99fb9d964e1e1c22f2ea559ac5745cf\"")); + assertEquals("f99fb9d964e1e1c22f2ea559ac5745cf", + FileDownloadUtils.normalizeETag("\"f99fb9d964e1e1c22f2ea559ac5745cf\"")); + } + + @Test + void sha1AndSha256LengthsAreRecognised() { + assertEquals(FileDownloadUtils.Hash.SHA1, + FileDownloadUtils.hashFromETag("da39a3ee5e6b4b0d3255bfef95601890afd80709")); + assertEquals(FileDownloadUtils.Hash.SHA256, + FileDownloadUtils.hashFromETag( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")); + } + + @Test + void ebiStyleTimeSizeETagIsNotMistakenForADigest() { + // nginx and Apache emit -; the dash keeps it out of + // the hex-only pattern, which is what stops us recording a bogus checksum. + assertEquals(FileDownloadUtils.Hash.UNKNOWN, FileDownloadUtils.hashFromETag("\"67910788-1009e0\"")); + assertEquals(FileDownloadUtils.Hash.UNKNOWN, FileDownloadUtils.hashFromETag("\"14c95dd-51eedb9922b40\"")); + } + + @Test + void weakAndMissingETags() { + assertEquals(FileDownloadUtils.Hash.UNKNOWN, FileDownloadUtils.hashFromETag("W/\"abc\"")); + assertEquals(FileDownloadUtils.Hash.UNKNOWN, FileDownloadUtils.hashFromETag(null)); + assertEquals(FileDownloadUtils.Hash.UNKNOWN, FileDownloadUtils.hashFromETag(" ")); + assertNull(FileDownloadUtils.normalizeETag(null)); + assertNull(FileDownloadUtils.normalizeETag("\"\"")); + } + } + + @Nested + class ValidateFile { + + @Test + void bareRelativeNameDoesNotThrow() { + // getParentFile() is null here; this used to be a NullPointerException. + assertTrue(FileDownloadUtils.validateFile(new File("no-such-file-in-cwd.cif"))); + } + + @Test + void emptySizeFileIsIgnoredRatherThanThrowing() throws IOException { + File dir = Files.createTempDirectory("bj-validate").toFile(); + try { + File data = new File(dir, "data.bin"); + Files.write(data.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(dir, "data.bin.size").toPath(), new byte[0]); + assertTrue(FileDownloadUtils.validateFile(data)); + } finally { + FileDownloadUtils.deleteDirectory(dir.toPath()); + } + } + + @Test + void sizeMismatchIsDetected() throws IOException { + File dir = Files.createTempDirectory("bj-validate").toFile(); + try { + File data = new File(dir, "data.bin"); + Files.write(data.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(dir, "data.bin.size").toPath(), "99".getBytes(StandardCharsets.UTF_8)); + assertFalse(FileDownloadUtils.validateFile(data)); + } finally { + FileDownloadUtils.deleteDirectory(dir.toPath()); + } + } + + @Test + void everyHashSidecarIsChecked() throws IOException { + File dir = Files.createTempDirectory("bj-validate").toFile(); + try { + File data = new File(dir, "data.bin"); + Files.write(data.toPath(), "abc".getBytes(StandardCharsets.UTF_8)); + // correct MD5, wrong SHA1: the second sidecar must still be caught + Files.write(new File(dir, "data.bin.hash_MD5").toPath(), + "900150983cd24fb0d6963f7d28e17f72".getBytes(StandardCharsets.UTF_8)); + Files.write(new File(dir, "data.bin.hash_SHA1").toPath(), + "0000000000000000000000000000000000000000".getBytes(StandardCharsets.UTF_8)); + assertFalse(FileDownloadUtils.validateFile(data)); + } finally { + FileDownloadUtils.deleteDirectory(dir.toPath()); + } + } + + @Test + void writeHashFileRoundTrip() throws IOException { + File dir = Files.createTempDirectory("bj-validate").toFile(); + try { + File data = new File(dir, "data.bin"); + Files.write(data.toPath(), "abc".getBytes(StandardCharsets.UTF_8)); + FileDownloadUtils.writeHashFile(data, FileDownloadUtils.Hash.MD5, + FileDownloadUtils.computeHash(data, FileDownloadUtils.Hash.MD5)); + assertTrue(new File(dir, "data.bin.hash_MD5").exists()); + assertTrue(FileDownloadUtils.validateFile(data)); + } finally { + FileDownloadUtils.deleteDirectory(dir.toPath()); + } + } + } } diff --git a/biojava-genome/pom.xml b/biojava-genome/pom.xml index 79608afaf4..956cfaa2dd 100644 --- a/biojava-genome/pom.xml +++ b/biojava-genome/pom.xml @@ -3,7 +3,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT 4.0.0 biojava-genome @@ -63,38 +63,25 @@ compile - junit - junit - test + org.junit.jupiter + junit-jupiter-engine + + + org.junit.jupiter + junit-jupiter-params org.biojava biojava-core - 7.2.3 + 7.3.0-SNAPSHOT compile org.biojava biojava-alignment - 7.2.3 + 7.3.0-SNAPSHOT compile - - junit-addons - junit-addons - 1.4 - test - - - xerces - xmlParserAPIs - - - xerces - xercesImpl - - - org.slf4j diff --git a/biojava-genome/src/main/java/org/biojava/nbio/genome/GeneFeatureHelper.java b/biojava-genome/src/main/java/org/biojava/nbio/genome/GeneFeatureHelper.java index c9786b3ad0..b417c4608d 100644 --- a/biojava-genome/src/main/java/org/biojava/nbio/genome/GeneFeatureHelper.java +++ b/biojava-genome/src/main/java/org/biojava/nbio/genome/GeneFeatureHelper.java @@ -36,7 +36,7 @@ /** * - * @author Scooter Willis + * @author Scooter Willis */ public class GeneFeatureHelper { @@ -418,7 +418,7 @@ static public void addGmodGFF3GeneFeatures(Map chrom String startCodonName = ""; String stopCodonName = ""; FeatureList startCodonList = mRNAChildren.selectByType("five_prime_UTR"); - if (startCodonList != null && startCodonList.size() > 0) { + if (startCodonList != null && !startCodonList.isEmpty()) { startCodon = startCodonList.get(0); if (strand == Strand.NEGATIVE) { startCodonBegin = startCodon.location().bioEnd(); @@ -430,7 +430,7 @@ static public void addGmodGFF3GeneFeatures(Map chrom FeatureList stopCodonList = mRNAChildren.selectByType("three_prime_UTR"); - if (stopCodonList != null && stopCodonList.size() > 0) { + if (stopCodonList != null && !stopCodonList.isEmpty()) { stopCodon = stopCodonList.get(0); if (strand == Strand.NEGATIVE) { stopCodonEnd = stopCodon.location().bioStart(); @@ -577,7 +577,7 @@ static public void addGlimmerGFF3GeneFeatures(Map ch String startCodonName = ""; String stopCodonName = ""; FeatureList startCodonList = gene.selectByAttribute("Note", "initial-exon"); - if (startCodonList != null && startCodonList.size() > 0) { + if (startCodonList != null && !startCodonList.isEmpty()) { startCodon = startCodonList.get(0); if (strand == Strand.NEGATIVE) { startCodonBegin = startCodon.location().bioEnd(); @@ -589,7 +589,7 @@ static public void addGlimmerGFF3GeneFeatures(Map ch FeatureList stopCodonList = gene.selectByAttribute("Note", "final-exon"); - if (stopCodonList != null && stopCodonList.size() > 0) { + if (stopCodonList != null && !stopCodonList.isEmpty()) { stopCodon = stopCodonList.get(0); if (strand == Strand.NEGATIVE) { stopCodonEnd = stopCodon.location().bioStart(); @@ -723,7 +723,7 @@ static public void addGeneMarkGTFGeneFeatures(Map ch String startCodonName = ""; String stopCodonName = ""; FeatureList startCodonList = transcriptFeature.selectByType("start_codon"); - if (startCodonList != null && startCodonList.size() > 0) { + if (startCodonList != null && !startCodonList.isEmpty()) { startCodon = startCodonList.get(0); if (strand == Strand.POSITIVE) { startCodonBegin = startCodon.location().bioStart(); @@ -735,7 +735,7 @@ static public void addGeneMarkGTFGeneFeatures(Map ch FeatureList stopCodonList = transcriptFeature.selectByType("stop_codon"); - if (stopCodonList != null && stopCodonList.size() > 0) { + if (stopCodonList != null && !stopCodonList.isEmpty()) { stopCodon = stopCodonList.get(0); if (strand == Strand.POSITIVE) { stopCodonEnd = stopCodon.location().bioEnd(); diff --git a/biojava-genome/src/main/java/org/biojava/nbio/genome/homology/GFF3FromUniprotBlastHits.java b/biojava-genome/src/main/java/org/biojava/nbio/genome/homology/GFF3FromUniprotBlastHits.java index 67e22bd992..1547aba2d9 100644 --- a/biojava-genome/src/main/java/org/biojava/nbio/genome/homology/GFF3FromUniprotBlastHits.java +++ b/biojava-genome/src/main/java/org/biojava/nbio/genome/homology/GFF3FromUniprotBlastHits.java @@ -46,7 +46,7 @@ /** * - * @author Scooter Willis + * @author Scooter Willis * @author Mark Chapman */ public class GFF3FromUniprotBlastHits { @@ -163,7 +163,7 @@ PairwiseSequenceAlignerType.LOCAL, new SimpleGapPenalty(), String notes = ""; if (featureKeyWords != null) { List keyWords = featureKeyWords.getKeyWords(); - if (keyWords.size() > 0) { + if (!keyWords.isEmpty()) { notes = ";Note="; for (String note : keyWords) { if ("Complete proteome".equals(note)) { @@ -187,7 +187,7 @@ PairwiseSequenceAlignerType.LOCAL, new SimpleGapPenalty(), List cazyList = databaseReferenceHashMap.get("CAZy"); List goList = databaseReferenceHashMap.get("GO"); List eccList = databaseReferenceHashMap.get("BRENDA"); - if (pfamList != null && pfamList.size() > 0) { + if (pfamList != null && !pfamList.isEmpty()) { if (notes.length() == 0) { notes = ";Note="; } @@ -197,7 +197,7 @@ PairwiseSequenceAlignerType.LOCAL, new SimpleGapPenalty(), } } - if (cazyList != null && cazyList.size() > 0) { + if (cazyList != null && !cazyList.isEmpty()) { if (notes.length() == 0) { notes = ";Note="; } @@ -208,7 +208,7 @@ PairwiseSequenceAlignerType.LOCAL, new SimpleGapPenalty(), } } - if (eccList != null && eccList.size() > 0) { + if (eccList != null && !eccList.isEmpty()) { if (notes.length() == 0) { notes = ";Note="; } @@ -221,8 +221,8 @@ PairwiseSequenceAlignerType.LOCAL, new SimpleGapPenalty(), } } - if (goList != null && goList.size() > 0) { - if (notes.length() == 0) { + if (goList != null && !goList.isEmpty()) { + if (notes.isEmpty()) { notes = ";Note="; } for (DBReferenceInfo note : goList) { diff --git a/biojava-genome/src/main/java/org/biojava/nbio/genome/parsers/gff/GFF3Writer.java b/biojava-genome/src/main/java/org/biojava/nbio/genome/parsers/gff/GFF3Writer.java index 88af970928..0f8bd97884 100644 --- a/biojava-genome/src/main/java/org/biojava/nbio/genome/parsers/gff/GFF3Writer.java +++ b/biojava-genome/src/main/java/org/biojava/nbio/genome/parsers/gff/GFF3Writer.java @@ -31,7 +31,7 @@ /** * - * @author Scooter Willis + * @author Scooter Willis */ public class GFF3Writer { @@ -122,7 +122,7 @@ public void write(OutputStream outputStream, Map chr private String getGFF3Note(List notesList) { String notes = ""; - if (notesList.size() > 0) { + if (!notesList.isEmpty()) { notes = ";Note="; int noteindex = 1; for (String note : notesList) { diff --git a/biojava-genome/src/main/java/org/biojava/nbio/genome/parsers/gff/Location.java b/biojava-genome/src/main/java/org/biojava/nbio/genome/parsers/gff/Location.java index 4163be2b56..a28f1019fb 100644 --- a/biojava-genome/src/main/java/org/biojava/nbio/genome/parsers/gff/Location.java +++ b/biojava-genome/src/main/java/org/biojava/nbio/genome/parsers/gff/Location.java @@ -135,7 +135,7 @@ public static Location fromBio( int start, int end, char strand ) int s= start - 1; int e= end; - if( !( strand == '-' || strand == '+' || strand == '.' )) + if( strand != '-' && strand != '+' && strand != '.' ) { throw new IllegalArgumentException( "Strand must be '+', '-', or '.'" ); } @@ -166,7 +166,7 @@ public static Location fromBioExt( int start, int length, char strand, int total int s= start; int e= s + length; - if( !( strand == '-' || strand == '+' || strand == '.' )) + if( strand != '-' && strand != '+' && strand != '.' ) { throw new IllegalArgumentException( "Strand must be '+', '-', or '.'" ); } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/FeatureListTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/FeatureListTest.java index 6e1cae5d8b..8c50e19bc2 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/FeatureListTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/FeatureListTest.java @@ -26,26 +26,26 @@ import org.biojava.nbio.genome.parsers.gff.Feature; import org.biojava.nbio.genome.parsers.gff.FeatureList; import org.biojava.nbio.genome.parsers.gff.Location; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; /** * @author mckeee1 * */ -public class FeatureListTest { +class FeatureListTest { @Test - public void testAddIndex() throws Exception + void testAddIndex() throws Exception { FeatureList fl = new FeatureList(); fl.add(new Feature("seqname", "source", "type", new Location(1, 2), (double)0, 0, "gene_id \"gene_id_1\"; transcript_id \"transcript_id_1\";")); fl.addIndex("transcript_id"); - Assert.assertEquals(1, fl.selectByAttribute("transcript_id").size()); + Assertions.assertEquals(1, fl.selectByAttribute("transcript_id").size()); FeatureList f2 = new FeatureList(); f2.addIndex("transcript_id"); f2.add(new Feature("seqname", "source", "type", new Location(1, 2), (double)0, 0, "gene_id \"gene_id_1\"; transcript_id \"transcript_id_1\";")); - Assert.assertEquals(1, f2.selectByAttribute("transcript_id").size()); + Assertions.assertEquals(1, f2.selectByAttribute("transcript_id").size()); } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/GeneFeatureHelperTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/GeneFeatureHelperTest.java index 3c81c50916..dac677800a 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/GeneFeatureHelperTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/GeneFeatureHelperTest.java @@ -20,7 +20,6 @@ */ package org.biojava.nbio.genome; -import junitx.framework.FileAssert; import org.biojava.nbio.genome.parsers.gff.FeatureList; import org.biojava.nbio.genome.parsers.gff.GFF3Reader; import org.biojava.nbio.genome.parsers.gff.GFF3Writer; @@ -28,9 +27,10 @@ import org.biojava.nbio.core.sequence.GeneSequence; import org.biojava.nbio.core.sequence.ProteinSequence; import org.biojava.nbio.core.sequence.io.FastaWriterHelper; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,20 +45,20 @@ * * @author Scooter Willis */ -public class GeneFeatureHelperTest { +class GeneFeatureHelperTest { private static final Logger logger = LoggerFactory.getLogger(GeneFeatureHelperTest.class); - @Before + @BeforeEach public void setUp() throws Exception { } - @After + @AfterEach public void tearDown() throws Exception { } @Test - public void testZeroLocation() throws Exception { + void testZeroLocation() throws Exception { @SuppressWarnings("unused") FeatureList listGenes = GFF3Reader.read("src/test/resources/amphimedon.gff3"); @@ -71,7 +71,7 @@ public void testZeroLocation() throws Exception { */ @Test - public void testLoadFastaAddGeneFeaturesFromUpperCaseExonFastaFile() throws Exception { + void testLoadFastaAddGeneFeaturesFromUpperCaseExonFastaFile() throws Exception { // logger.info("loadFastaAddGeneFeaturesFromUpperCaseExonFastaFile"); File fastaSequenceFile = new File("src/test/resources/volvox_all.fna"); File uppercaseFastaFile = new File("src/test/resources/volvox_all_genes_exon_uppercase.fna"); @@ -93,15 +93,17 @@ public void testLoadFastaAddGeneFeaturesFromUpperCaseExonFastaFile() throws Exce * Test of outputFastaSequenceLengthGFF3 method, of class GeneFeatureHelper. */ @Test - public void testOutputFastaSequenceLengthGFF3() throws Exception { + void testOutputFastaSequenceLengthGFF3() throws Exception { // logger.info("outputFastaSequenceLengthGFF3"); File fastaSequenceFile = new File("src/test/resources/volvox_all.fna"); File gffFile = Files.createTempFile("volvox_length","gff3").toFile(); gffFile.deleteOnExit(); GeneFeatureHelper.outputFastaSequenceLengthGFF3(fastaSequenceFile, gffFile); - FileAssert.assertEquals("volvox_length.gff3 and volvox_length_output.gff3 are not equal", gffFile, - new File("src/test/resources/volvox_length_reference.gff3")); + Assertions.assertEquals( + Files.readAllLines(new File("src/test/resources/volvox_length_reference.gff3").toPath()), + Files.readAllLines(gffFile.toPath()), + "volvox_length.gff3 and volvox_length_output.gff3 are not equal"); } @@ -112,7 +114,7 @@ public void testOutputFastaSequenceLengthGFF3() throws Exception { */ @Test - public void testAddGFF3Note() throws Exception { + void testAddGFF3Note() throws Exception { Map chromosomeSequenceList = GeneFeatureHelper .loadFastaAddGeneFeaturesFromGmodGFF3(new File("src/test/resources/volvox_all.fna"), new File( "src/test/resources/volvox.gff3"), false); @@ -128,7 +130,7 @@ public void testAddGFF3Note() throws Exception { * output. */ @Test - public void testGetProteinSequences() throws Exception { + void testGetProteinSequences() throws Exception { Map chromosomeSequenceList = GeneFeatureHelper .loadFastaAddGeneFeaturesFromGmodGFF3(new File("src/test/resources/volvox_all.fna"), new File( "src/test/resources/volvox.gff3"), false); @@ -140,15 +142,17 @@ public void testGetProteinSequences() throws Exception { File tmp = Files.createTempFile("volvox_all","faa").toFile(); tmp.deleteOnExit(); FastaWriterHelper.writeProteinSequence(tmp, proteinSequenceList.values()); - FileAssert.assertEquals("volvox_all_reference.faa and volvox_all.faa are not equal", new File( - "src/test/resources/volvox_all_reference.faa"), tmp); + Assertions.assertEquals( + Files.readAllLines(new File("src/test/resources/volvox_all_reference.faa").toPath()), + Files.readAllLines(tmp.toPath()), + "volvox_all_reference.faa and volvox_all.faa are not equal"); } /** * Test of getGeneSequences method, of class GeneFeatureHelper. */ @Test - public void testGetGeneSequences() throws Exception { + void testGetGeneSequences() throws Exception { // logger.info("getGeneSequences"); Map chromosomeSequenceList = GeneFeatureHelper .loadFastaAddGeneFeaturesFromGmodGFF3(new File("src/test/resources/volvox_all.fna"), new File( diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/TestChromosomeMappingTools.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/TestChromosomeMappingTools.java index 9ddd43357a..b17539a0ac 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/TestChromosomeMappingTools.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/TestChromosomeMappingTools.java @@ -21,21 +21,20 @@ package org.biojava.nbio.genome; import org.biojava.nbio.genome.util.ChromosomeMappingTools; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import static org.junit.Assert.assertEquals; - /** * Created by Yana Valasatava on 8/14/17. */ -public class TestChromosomeMappingTools { +class TestChromosomeMappingTools { @Test - public void testGetCDSLengthForward() { + void testGetCDSLengthForward() { List exonStarts = new ArrayList<>(Arrays.asList(10, 30, 50, 70)); List exonEnds = new ArrayList<>(Arrays.asList(20, 40, 60, 80)); @@ -46,11 +45,11 @@ public void testGetCDSLengthForward() { ChromosomeMappingTools.setCoordinateSystem(0); int cdsTest = ChromosomeMappingTools.getCDSLengthForward(exonStarts, exonEnds, cdsStart, cdsEnd); - assertEquals(cdsDesired, cdsTest); + Assertions.assertEquals(cdsDesired, cdsTest); } @Test - public void testGetCDSLengthReverseAsc() { + void testGetCDSLengthReverseAsc() { List exonStarts = new ArrayList<>(Arrays.asList(10, 50, 70)); List exonEnds = new ArrayList<>(Arrays.asList(20, 60, 80)); @@ -61,11 +60,11 @@ public void testGetCDSLengthReverseAsc() { ChromosomeMappingTools.setCoordinateSystem(0); int cdsTest = ChromosomeMappingTools.getCDSLengthReverse(exonStarts, exonEnds, cdsStart, cdsEnd); - assertEquals(cdsDesired, cdsTest); + Assertions.assertEquals(cdsDesired, cdsTest); } @Test - public void testGetCDSLengthReverseDesc() { + void testGetCDSLengthReverseDesc() { List exonStarts = new ArrayList<>(Arrays.asList(70, 50, 10)); List exonEnds = new ArrayList<>(Arrays.asList(80, 60, 20)); @@ -76,6 +75,6 @@ public void testGetCDSLengthReverseDesc() { ChromosomeMappingTools.setCoordinateSystem(0); int cdsTest = ChromosomeMappingTools.getCDSLengthReverse(exonStarts, exonEnds, cdsStart, cdsEnd); - assertEquals(cdsDesired, cdsTest); + Assertions.assertEquals(cdsDesired, cdsTest); } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/TestGenomeMapping.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/TestGenomeMapping.java index 4999cfa6fb..257b88e3e1 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/TestGenomeMapping.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/TestGenomeMapping.java @@ -22,8 +22,8 @@ import com.google.common.collect.Range; import org.biojava.nbio.genome.util.ChromosomeMappingTools; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.Arrays; @@ -32,10 +32,10 @@ /** * Created by andreas on 7/19/16. */ -public class TestGenomeMapping { +class TestGenomeMapping { @Test - public void testGenomeMappingToolGetCDSRanges(){ + void testGenomeMappingToolGetCDSRanges(){ List lst1 = new ArrayList<>(Arrays.asList( 86346823, 86352858, 86354529)); List lst2 = new ArrayList<>(Arrays.asList(86348878, 86352984, 86354692)); @@ -45,21 +45,21 @@ public void testGenomeMappingToolGetCDSRanges(){ List> result = ChromosomeMappingTools.getCDSRegions(lst1,lst2,cdsStart,cdsEnd); // makes sure the first list does not get changed; - Assert.assertEquals(86346823, (int) lst1.get(0)); + Assertions.assertEquals(86346823, (int) lst1.get(0)); - Assert.assertEquals(86348749, (int) result.get(0).lowerEndpoint()); - Assert.assertEquals(86352858, (int) result.get(1).lowerEndpoint()); - Assert.assertEquals(86354529, (int) result.get(2).lowerEndpoint()); + Assertions.assertEquals(86348749, (int) result.get(0).lowerEndpoint()); + Assertions.assertEquals(86352858, (int) result.get(1).lowerEndpoint()); + Assertions.assertEquals(86354529, (int) result.get(2).lowerEndpoint()); - Assert.assertEquals(86348878, (int) result.get(0).upperEndpoint()); - Assert.assertEquals(86352984, (int) result.get(1).upperEndpoint()); - Assert.assertEquals(86387027, (int) result.get(2).upperEndpoint()); + Assertions.assertEquals(86348878, (int) result.get(0).upperEndpoint()); + Assertions.assertEquals(86352984, (int) result.get(1).upperEndpoint()); + Assertions.assertEquals(86387027, (int) result.get(2).upperEndpoint()); } @Test - public void testGenomeMappingToolGetCDSRangesSERINC2(){ + void testGenomeMappingToolGetCDSRangesSERINC2(){ List lst1 = new ArrayList<>(Arrays.asList(31413812, 31415872, 31423692)); List lst2 = new ArrayList<>(Arrays.asList(31414777, 31415907, 31423854)); @@ -69,7 +69,7 @@ public void testGenomeMappingToolGetCDSRangesSERINC2(){ List> result = ChromosomeMappingTools.getCDSRegions(lst1,lst2,cdsStart,cdsEnd); // makes sure the first list does not get changed; - Assert.assertEquals(31423818, (int) result.get(0).lowerEndpoint()); + Assertions.assertEquals(31423818, (int) result.get(0).lowerEndpoint()); } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/TestIssue355.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/TestIssue355.java index 5543682f17..4c98225b04 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/TestIssue355.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/TestIssue355.java @@ -20,29 +20,28 @@ */ package org.biojava.nbio.genome; -import static org.junit.Assert.*; - import org.biojava.nbio.genome.parsers.gff.Location; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; -public class TestIssue355 { +class TestIssue355 { @Test - public void testIssue1() { + void testIssue1() { Location l1 = Location.fromBio(51227320, 51227381, '+'); Location l2 = Location.fromBio(51227323, 51227382, '+'); Location union = l1.union(l2); - assertEquals(51227320,union.bioStart()); - assertEquals(51227382,union.bioEnd()); + Assertions.assertEquals(51227320, union.bioStart()); + Assertions.assertEquals(51227382, union.bioEnd()); } @Test - public void testIssue2() { + void testIssue2() { Location l1 = Location.fromBio(100, 200, '+'); Location l2 = Location.fromBio(1, 99, '+'); Location intersection = l1.intersection(l2); - assertNull(intersection); + Assertions.assertNull(intersection); } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/TestLocation.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/TestLocation.java index 1289cb757e..42893cf22b 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/TestLocation.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/TestLocation.java @@ -20,15 +20,14 @@ */ package org.biojava.nbio.genome; -import static org.junit.Assert.*; - import org.biojava.nbio.genome.parsers.gff.Location; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; -public class TestLocation { +class TestLocation { @Test - public void testLocation() { + void testLocation() { // tests taken from Location.main() //Location p3_7= new Location( 3, 7 ); @@ -49,70 +48,70 @@ public void testLocation() { Location r5_8= new Location( 5, 8 ); //distance - assertEquals(7, L(14,14).distance( L(3,7) )); - assertEquals(7, L(3,7).distance( L(14,14) )); - assertEquals(3, L(1,4).distance( L(7, 10) )); + Assertions.assertEquals(7, L(14,14).distance( L(3,7) )); + Assertions.assertEquals(7, L(3,7).distance( L(14,14) )); + Assertions.assertEquals(3, L(1,4).distance( L(7, 10) )); //union - assertEquals(p10_17, p10_12.union( p14_17 )); - assertEquals(p10_17, p14_17.union( p10_12 )); - assertEquals(p15_19, p15_19.union( p15_16 )); + Assertions.assertEquals(p10_17, p10_12.union( p14_17 )); + Assertions.assertEquals(p10_17, p14_17.union( p10_12 )); + Assertions.assertEquals(p15_19, p15_19.union( p15_16 )); //intersection - assertEquals(new Location( 21, 25 ), r13_17.union( r21_25 ).intersection( r21_25 )); + Assertions.assertEquals(new Location( 21, 25 ), r13_17.union( r21_25 ).intersection( r21_25 )); //isBefore - assertTrue( r2_5.isBefore( r5_8 )); - assertTrue( !r2_5.isBefore( r4_7 )); + Assertions.assertTrue(r2_5.isBefore( r5_8 )); + Assertions.assertTrue(!r2_5.isBefore( r4_7 )); //isAfter - assertTrue(r5_8.isAfter( r2_5 )); - assertTrue(!r5_8.isAfter( r4_7 )); + Assertions.assertTrue(r5_8.isAfter( r2_5 )); + Assertions.assertTrue(!r5_8.isAfter( r4_7 )); //contains - assertTrue(p15_19.contains( p16_19 )); + Assertions.assertTrue(p15_19.contains( p16_19 )); //overlaps - assertTrue(r2_5.overlaps( r4_7 )); - assertTrue(r2_5.overlaps( r0_3 )); - assertTrue(!r5_8.overlaps( r2_5 )); - assertTrue(!r2_5.overlaps( r5_8 )); + Assertions.assertTrue(r2_5.overlaps( r4_7 )); + Assertions.assertTrue(r2_5.overlaps( r0_3 )); + Assertions.assertTrue(!r5_8.overlaps( r2_5 )); + Assertions.assertTrue(!r2_5.overlaps( r5_8 )); //prefix - assertEquals(L(2,3), L(2,20).prefix(1)); - assertEquals(L(2,19), L(2,20).prefix(-1)); - assertEquals( L(2,10), L(2,20).prefix( L(10,12))); + Assertions.assertEquals(L(2,3), L(2,20).prefix(1)); + Assertions.assertEquals(L(2,19), L(2,20).prefix(-1)); + Assertions.assertEquals(L(2,10), L(2,20).prefix( L(10,12))); //suffix - assertEquals(L(3,20), L(2,20).suffix(1)); - assertEquals(L(19,20), L(2,20).suffix(-1)); - assertEquals(L(12,20), L(2,20).suffix( L(10,12))); + Assertions.assertEquals(L(3,20), L(2,20).suffix(1)); + Assertions.assertEquals(L(19,20), L(2,20).suffix(-1)); + Assertions.assertEquals(L(12,20), L(2,20).suffix( L(10,12))); } @Test - public void testLocationIntersections() { + void testLocationIntersections() { // One inside another Location r21_25 = new Location( 21, 25 ); Location r1_100 = new Location(1, 100 ); - assertEquals(r21_25, r21_25.intersection( r1_100)); - assertEquals(r21_25, r1_100.intersection( r21_25)); + Assertions.assertEquals(r21_25, r21_25.intersection( r1_100)); + Assertions.assertEquals(r21_25, r1_100.intersection( r21_25)); // Non overlapping Location r10_100 = new Location(10, 100 ); Location r1_9 = new Location( 1, 9 ); - assertNull(r10_100.intersection( r1_9)); - assertNull(r1_9.intersection( new Location( 9, 10 ))); + Assertions.assertNull(r10_100.intersection( r1_9)); + Assertions.assertNull(r1_9.intersection( new Location( 9, 10 ))); // Partially overlappping Location r1_25 = new Location( 1, 25 ); Location r21_100 = new Location(21, 100 ); - assertEquals(r21_25, r1_25.intersection( r21_100)); - assertEquals(r21_25, r21_100.intersection( r1_25)); + Assertions.assertEquals(r21_25, r1_25.intersection( r21_100)); + Assertions.assertEquals(r21_25, r21_100.intersection( r1_25)); } //shorthand for testing diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/AbstractFastqReaderTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/AbstractFastqReaderTest.java index 7cde3d3ec9..3e1385f3a2 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/AbstractFastqReaderTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/AbstractFastqReaderTest.java @@ -20,8 +20,8 @@ */ package org.biojava.nbio.genome.io.fastq; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.io.File; import java.io.IOException; @@ -34,7 +34,7 @@ /** * Abstract unit test for implementations of FastqReader. */ -public abstract class AbstractFastqReaderTest { +abstract class AbstractFastqReaderTest { /** Array of example files that should throw IOExceptions. */ static final String[] ERROR_EXAMPLES = new String[] { @@ -87,21 +87,21 @@ public abstract class AbstractFastqReaderTest { public void testCreateFastq() { Fastq fastq = createFastq(); - Assert.assertNotNull(fastq); + Assertions.assertNotNull(fastq); } @Test public void testCreateFastqReader() { FastqReader reader = createFastqReader(); - Assert.assertNotNull(reader); + Assertions.assertNotNull(reader); } @Test public void testCreateFastqWriter() { FastqWriter writer = createFastqWriter(); - Assert.assertNotNull(writer); + Assertions.assertNotNull(writer); } @Test @@ -111,7 +111,7 @@ public void testReadFile() throws Exception try { reader.read((File) null); - Assert.fail("read((File) null) expected IllegalArgumentException"); + Assertions.fail("read((File) null) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -121,7 +121,7 @@ public void testReadFile() throws Exception { File noSuchFile = new File("no such file"); reader.read(noSuchFile); - Assert.fail("read(no such file) expected IOException"); + Assertions.fail("read(no such file) expected IOException"); } catch (IOException e) { @@ -135,14 +135,14 @@ public void testReadEmptyFile() throws Exception FastqReader reader = createFastqReader(); File empty = Files.createTempFile("abstractFastqReaderTest",null).toFile(); Iterable iterable = reader.read(empty); - Assert.assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - Assert.assertNotNull(f); + Assertions.assertNotNull(f); count++; } - Assert.assertEquals(0, count); + Assertions.assertEquals(0, count); } @Test @@ -154,14 +154,14 @@ public void testReadRoundTripSingleFile() throws Exception FastqWriter writer = createFastqWriter(); writer.write(single, fastq); Iterable iterable = reader.read(single); - Assert.assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - Assert.assertNotNull(f); + Assertions.assertNotNull(f); count++; } - Assert.assertEquals(1, count); + Assertions.assertEquals(1, count); } @Test @@ -175,14 +175,14 @@ public void testReadRoundTripMultipleFile() throws Exception FastqWriter writer = createFastqWriter(); writer.write(multiple, fastq0, fastq1, fastq2); Iterable iterable = reader.read(multiple); - Assert.assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - Assert.assertNotNull(f); + Assertions.assertNotNull(f); count++; } - Assert.assertEquals(3, count); + Assertions.assertEquals(3, count); } @Test @@ -192,7 +192,7 @@ public void testReadURL() throws Exception try { reader.read((URL) null); - Assert.fail("read((URL) null) expected IllegalArgumentException"); + Assertions.fail("read((URL) null) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -202,7 +202,7 @@ public void testReadURL() throws Exception { URL noSuchURL = new URL("file:///no such url"); reader.read(noSuchURL); - Assert.fail("read(no such URL) expected IOException"); + Assertions.fail("read(no such URL) expected IOException"); } catch (IOException e) { @@ -216,14 +216,14 @@ public void testReadEmptyURL() throws Exception FastqReader reader = createFastqReader(); URL empty = getClass().getResource("empty.fastq"); Iterable iterable = reader.read(empty); - Assert.assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - Assert.assertNotNull(f); + Assertions.assertNotNull(f); count++; } - Assert.assertEquals(0, count); + Assertions.assertEquals(0, count); } @Test @@ -233,7 +233,7 @@ public void testReadInputStream() throws Exception try { reader.read((InputStream) null); - Assert.fail("read((InputStream) null) expected IllegalArgumentException"); + Assertions.fail("read((InputStream) null) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -247,14 +247,14 @@ public void testReadEmptyInputStream() throws Exception FastqReader reader = createFastqReader(); InputStream empty = getClass().getResourceAsStream("empty.fastq"); Iterable iterable = reader.read(empty); - Assert.assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - Assert.assertNotNull(f); + Assertions.assertNotNull(f); count++; } - Assert.assertEquals(0, count); + Assertions.assertEquals(0, count); empty.close(); } @@ -264,15 +264,15 @@ public void testWrappedSequence() throws Exception FastqReader reader = createFastqReader(); InputStream wrappedSequence = getClass().getResourceAsStream("wrapped-sequence.fastq"); Iterable iterable = reader.read(wrappedSequence); - Assert.assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - Assert.assertNotNull(f); - Assert.assertEquals("ACTG", f.getSequence()); + Assertions.assertNotNull(f); + Assertions.assertEquals("ACTG", f.getSequence()); count++; } - Assert.assertEquals(1, count); + Assertions.assertEquals(1, count); wrappedSequence.close(); } @@ -282,15 +282,15 @@ public void testWrappedQuality() throws Exception FastqReader reader = createFastqReader(); InputStream wrappedQuality = getClass().getResourceAsStream("wrapped-quality.fastq"); Iterable iterable = reader.read(wrappedQuality); - Assert.assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - Assert.assertNotNull(f); - Assert.assertEquals("ZZZZ", f.getQuality()); + Assertions.assertNotNull(f); + Assertions.assertEquals("ZZZZ", f.getQuality()); count++; } - Assert.assertEquals(1, count); + Assertions.assertEquals(1, count); wrappedQuality.close(); } @@ -300,15 +300,15 @@ public void testMultipleWrappedQuality() throws Exception FastqReader reader = createFastqReader(); InputStream wrappedQuality = getClass().getResourceAsStream("multiple-wrapped-quality.fastq"); Iterable iterable = reader.read(wrappedQuality); - Assert.assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - Assert.assertNotNull(f); - Assert.assertEquals("ZZZZ", f.getQuality()); + Assertions.assertNotNull(f); + Assertions.assertEquals("ZZZZ", f.getQuality()); count++; } - Assert.assertEquals(4, count); + Assertions.assertEquals(4, count); wrappedQuality.close(); } @@ -322,7 +322,7 @@ public void testErrorExamples() throws Exception try { reader.read(inputStream); - Assert.fail("error example " + errorExample + " expected IOException"); + Assertions.fail("error example " + errorExample + " expected IOException"); } catch (IOException e) { @@ -430,7 +430,7 @@ public void complete() throws IOException { // empty } }); - Assert.fail("parse(null, ) expected IllegalArgumentException"); + Assertions.fail("parse(null, ) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -446,7 +446,7 @@ public void testParseNullParseListener() throws Exception try { reader.parse(new StringReader(input), null); - Assert.fail("parse(, null) expected IllegalArgumentException"); + Assertions.fail("parse(, null) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/AbstractFastqWriterTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/AbstractFastqWriterTest.java index cf2b695968..f2000b5096 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/AbstractFastqWriterTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/AbstractFastqWriterTest.java @@ -20,8 +20,8 @@ */ package org.biojava.nbio.genome.io.fastq; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.io.ByteArrayOutputStream; import java.io.File; @@ -34,7 +34,7 @@ /** * Abstract unit test for implementations of FastqWriter. */ -public abstract class AbstractFastqWriterTest { +abstract class AbstractFastqWriterTest { /** * Create and return a new FASTQ formatted sequence suitable for testing. @@ -54,14 +54,14 @@ public abstract class AbstractFastqWriterTest { public void testCreateFastq() { Fastq fastq = createFastq(); - Assert.assertNotNull(fastq); + Assertions.assertNotNull(fastq); } @Test public void testCreateFastqWriter() { FastqWriter writer = createFastqWriter(); - Assert.assertNotNull(writer); + Assertions.assertNotNull(writer); } @Test @@ -72,16 +72,16 @@ public void testAppendVararg() throws Exception Fastq fastq0 = createFastq(); Fastq fastq1 = createFastq(); Fastq fastq2 = createFastq(); - Assert.assertSame(appendable, writer.append(appendable, fastq0)); - Assert.assertSame(appendable, writer.append(appendable, fastq0, fastq1)); - Assert.assertSame(appendable, writer.append(appendable, fastq0, fastq1, fastq2)); - Assert.assertSame(appendable, writer.append(appendable, fastq0, fastq1, fastq2, null)); - Assert.assertSame(appendable, writer.append(appendable, (Fastq) null)); + Assertions.assertSame(appendable, writer.append(appendable, fastq0)); + Assertions.assertSame(appendable, writer.append(appendable, fastq0, fastq1)); + Assertions.assertSame(appendable, writer.append(appendable, fastq0, fastq1, fastq2)); + Assertions.assertSame(appendable, writer.append(appendable, fastq0, fastq1, fastq2, null)); + Assertions.assertSame(appendable, writer.append(appendable, (Fastq) null)); try { writer.append((Appendable) null, fastq0); - Assert.fail("append(null,) expected IllegalArgumentException"); + Assertions.fail("append(null,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -98,20 +98,20 @@ public void testAppendIterable() throws Exception Fastq fastq1 = createFastq(); Fastq fastq2 = createFastq(); List list = new ArrayList(); - Assert.assertSame(appendable, writer.append(appendable, list)); + Assertions.assertSame(appendable, writer.append(appendable, list)); list.add(fastq0); - Assert.assertSame(appendable, writer.append(appendable, list)); + Assertions.assertSame(appendable, writer.append(appendable, list)); list.add(fastq1); - Assert.assertSame(appendable, writer.append(appendable, list)); + Assertions.assertSame(appendable, writer.append(appendable, list)); list.add(fastq2); - Assert.assertSame(appendable, writer.append(appendable, list)); + Assertions.assertSame(appendable, writer.append(appendable, list)); list.add(null); - Assert.assertSame(appendable, writer.append(appendable, list)); + Assertions.assertSame(appendable, writer.append(appendable, list)); try { writer.append((Appendable) null, list); - Assert.fail("append(null,) expected IllegalArgumentException"); + Assertions.fail("append(null,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -120,7 +120,7 @@ public void testAppendIterable() throws Exception try { writer.append(appendable, (Iterable) null); - Assert.fail("append(,null) expected IllegalArgumentException"); + Assertions.fail("append(,null) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -149,7 +149,7 @@ public void testWriteFileVararg() throws Exception try { writer.write((File) null, fastq0); - Assert.fail("append(null,) expected IllegalArgumentException"); + Assertions.fail("append(null,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -189,7 +189,7 @@ public void testWriteFileIterable() throws Exception try { writer.write((File) null, fastq0); - Assert.fail("append(null,) expected IllegalArgumentException"); + Assertions.fail("append(null,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -198,7 +198,7 @@ public void testWriteFileIterable() throws Exception try { writer.write(file5, (Iterable) null); - Assert.fail("append(,null) expected IllegalArgumentException"); + Assertions.fail("append(,null) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -223,7 +223,7 @@ public void testWriteOutputStreamVararg() throws Exception try { writer.write((OutputStream) null, fastq0); - Assert.fail("append(null,) expected IllegalArgumentException"); + Assertions.fail("append(null,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -253,7 +253,7 @@ public void testWriteOutputStreamIterable() throws Exception try { writer.write((OutputStream) null, fastq0); - Assert.fail("append(null,) expected IllegalArgumentException"); + Assertions.fail("append(null,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -262,7 +262,7 @@ public void testWriteOutputStreamIterable() throws Exception try { writer.write(outputStream, (Iterable) null); - Assert.fail("append(,null) expected IllegalArgumentException"); + Assertions.fail("append(,null) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/ConvertTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/ConvertTest.java index b07e237ef2..c6789e622f 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/ConvertTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/ConvertTest.java @@ -26,19 +26,18 @@ import java.util.List; import java.util.Map; -import org.junit.Test; -import static org.junit.Assert.*; - import com.google.common.collect.Lists; import com.google.common.collect.Maps; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; /** * Round trip conversion functional tests. */ -public final class ConvertTest { +final class ConvertTest { @Test - public void testConvert() throws Exception + void testConvert() throws Exception { Map readers = Maps.newHashMap(); readers.put(FastqVariant.FASTQ_SANGER, new SangerFastqReader()); @@ -88,13 +87,13 @@ public void testConvert() throws Exception List observed = Lists.newArrayList(resultReader.read(tmp)); List expected = Lists.newArrayList(resultReader.read(getClass().getResource(expectedFileName))); - assertEquals(expected.size(), observed.size()); + Assertions.assertEquals(expected.size(), observed.size()); for (int i = 0; i < expected.size(); i++) { - assertEquals(expected.get(i).getDescription(), observed.get(i).getDescription()); - assertEquals(expected.get(i).getSequence(), observed.get(i).getSequence()); - assertEquals(expected.get(i).getQuality(), observed.get(i).getQuality()); - assertEquals(expected.get(i).getVariant(), observed.get(i).getVariant()); + Assertions.assertEquals(expected.get(i).getDescription(), observed.get(i).getDescription()); + Assertions.assertEquals(expected.get(i).getSequence(), observed.get(i).getSequence()); + Assertions.assertEquals(expected.get(i).getQuality(), observed.get(i).getQuality()); + Assertions.assertEquals(expected.get(i).getVariant(), observed.get(i).getVariant()); } } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqBuilderTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqBuilderTest.java index 5803068276..46957aeb26 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqBuilderTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqBuilderTest.java @@ -20,25 +20,23 @@ */ package org.biojava.nbio.genome.io.fastq; -import org.junit.Assert; -import org.junit.Test; - -import org.junit.function.ThrowingRunnable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; /** * Unit test for FastqBuilder. */ -public final class FastqBuilderTest { +final class FastqBuilderTest { @Test - public void testConstructor() + void testConstructor() { FastqBuilder fastqBuilder = new FastqBuilder(); - Assert.assertNotNull(fastqBuilder); + Assertions.assertNotNull(fastqBuilder); } @Test - public void testConstructorFastq() + void testConstructorFastq() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") @@ -49,34 +47,29 @@ public void testConstructorFastq() Fastq fastq = fastqBuilder.build(); FastqBuilder fastqBuilder2 = new FastqBuilder(fastq); - Assert.assertNotNull(fastqBuilder2); + Assertions.assertNotNull(fastqBuilder2); Fastq fastq2 = fastqBuilder2.build(); - Assert.assertEquals("description", fastq2.getDescription()); - Assert.assertEquals("sequence", fastq2.getSequence()); - Assert.assertEquals("quality_", fastq2.getQuality()); - Assert.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq2.getVariant()); + Assertions.assertEquals("description", fastq2.getDescription()); + Assertions.assertEquals("sequence", fastq2.getSequence()); + Assertions.assertEquals("quality_", fastq2.getQuality()); + Assertions.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq2.getVariant()); } @Test - public void testConstructorNullFastq() + void testConstructorNullFastq() { - Assert.assertThrows(IllegalArgumentException.class, new ThrowingRunnable() { - @Override - public void run() { - new FastqBuilder(null); - } - }); + Assertions.assertThrows(IllegalArgumentException.class, () -> new FastqBuilder(null)); } @Test - public void testBuildDefault() + void testBuildDefault() { try { FastqBuilder fastqBuilder = new FastqBuilder(); fastqBuilder.build(); - Assert.fail("build default expected IllegalStateException"); + Assertions.fail("build default expected IllegalStateException"); } catch (IllegalStateException e) { @@ -85,7 +78,7 @@ public void testBuildDefault() } @Test - public void testBuildNullDescription() + void testBuildNullDescription() { try { @@ -96,7 +89,7 @@ public void testBuildNullDescription() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build null description expected IllegalArgumentException"); + Assertions.fail("build null description expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -105,7 +98,7 @@ public void testBuildNullDescription() } @Test - public void testBuildNullSequence() + void testBuildNullSequence() { try { @@ -116,7 +109,7 @@ public void testBuildNullSequence() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build null sequence expected IllegalArgumentException"); + Assertions.fail("build null sequence expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -125,7 +118,7 @@ public void testBuildNullSequence() } @Test - public void testBuildNullAppendSequence() + void testBuildNullAppendSequence() { try { @@ -136,7 +129,7 @@ public void testBuildNullAppendSequence() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build null append sequence expected IllegalArgumentException"); + Assertions.fail("build null append sequence expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -145,7 +138,7 @@ public void testBuildNullAppendSequence() } @Test - public void testBuildNullQuality() + void testBuildNullQuality() { try { @@ -156,7 +149,7 @@ public void testBuildNullQuality() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build null quality expected IllegalArgumentException"); + Assertions.fail("build null quality expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -165,7 +158,7 @@ public void testBuildNullQuality() } @Test - public void testBuildNullAppendQuality() + void testBuildNullAppendQuality() { try { @@ -176,7 +169,7 @@ public void testBuildNullAppendQuality() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build null append quality expected IllegalArgumentException"); + Assertions.fail("build null append quality expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -185,7 +178,7 @@ public void testBuildNullAppendQuality() } @Test - public void testBuildNullVariant() + void testBuildNullVariant() { try { @@ -196,7 +189,7 @@ public void testBuildNullVariant() .withVariant(null); fastqBuilder.build(); - Assert.fail("build null variant expected IllegalArgumentException"); + Assertions.fail("build null variant expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -205,7 +198,7 @@ public void testBuildNullVariant() } @Test - public void testBuildMissingDescription() + void testBuildMissingDescription() { try { @@ -215,7 +208,7 @@ public void testBuildMissingDescription() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build missing description expected IllegalStateException"); + Assertions.fail("build missing description expected IllegalStateException"); } catch (IllegalStateException e) { @@ -224,7 +217,7 @@ public void testBuildMissingDescription() } @Test - public void testBuildMissingSequence() + void testBuildMissingSequence() { try { @@ -234,7 +227,7 @@ public void testBuildMissingSequence() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build missing sequence expected IllegalStateException"); + Assertions.fail("build missing sequence expected IllegalStateException"); } catch (IllegalStateException e) { @@ -243,7 +236,7 @@ public void testBuildMissingSequence() } @Test - public void testBuildMissingQuality() + void testBuildMissingQuality() { try { @@ -253,7 +246,7 @@ public void testBuildMissingQuality() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build missing quality expected IllegalStateException"); + Assertions.fail("build missing quality expected IllegalStateException"); } catch (IllegalStateException e) { @@ -262,7 +255,7 @@ public void testBuildMissingQuality() } @Test - public void testBuildDefaultVariant() + void testBuildDefaultVariant() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") @@ -270,16 +263,16 @@ public void testBuildDefaultVariant() .withQuality("quality_"); Fastq fastq = fastqBuilder.build(); - Assert.assertEquals("description", fastqBuilder.getDescription()); - Assert.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); - Assert.assertEquals("description", fastq.getDescription()); - Assert.assertEquals("sequence", fastq.getSequence()); - Assert.assertEquals("quality_", fastq.getQuality()); - Assert.assertEquals(FastqBuilder.DEFAULT_VARIANT, fastq.getVariant()); + Assertions.assertEquals("description", fastqBuilder.getDescription()); + Assertions.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); + Assertions.assertEquals("description", fastq.getDescription()); + Assertions.assertEquals("sequence", fastq.getSequence()); + Assertions.assertEquals("quality_", fastq.getQuality()); + Assertions.assertEquals(FastqBuilder.DEFAULT_VARIANT, fastq.getVariant()); } @Test - public void testBuild() + void testBuild() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") @@ -287,16 +280,16 @@ public void testBuild() .withQuality("quality_") .withVariant(FastqVariant.FASTQ_SOLEXA); Fastq fastq = fastqBuilder.build(); - Assert.assertEquals("description", fastqBuilder.getDescription()); - Assert.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); - Assert.assertEquals("description", fastq.getDescription()); - Assert.assertEquals("sequence", fastq.getSequence()); - Assert.assertEquals("quality_", fastq.getQuality()); - Assert.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq.getVariant()); + Assertions.assertEquals("description", fastqBuilder.getDescription()); + Assertions.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); + Assertions.assertEquals("description", fastq.getDescription()); + Assertions.assertEquals("sequence", fastq.getSequence()); + Assertions.assertEquals("quality_", fastq.getQuality()); + Assertions.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq.getVariant()); } @Test - public void testBuildAppendSequence() + void testBuildAppendSequence() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") @@ -305,16 +298,16 @@ public void testBuildAppendSequence() .withQuality("quality_") .withVariant(FastqVariant.FASTQ_SOLEXA); Fastq fastq = fastqBuilder.build(); - Assert.assertEquals("description", fastqBuilder.getDescription()); - Assert.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); - Assert.assertEquals("description", fastq.getDescription()); - Assert.assertEquals("sequence", fastq.getSequence()); - Assert.assertEquals("quality_", fastq.getQuality()); - Assert.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq.getVariant()); + Assertions.assertEquals("description", fastqBuilder.getDescription()); + Assertions.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); + Assertions.assertEquals("description", fastq.getDescription()); + Assertions.assertEquals("sequence", fastq.getSequence()); + Assertions.assertEquals("quality_", fastq.getQuality()); + Assertions.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq.getVariant()); } @Test - public void testBuildAppendQuality() + void testBuildAppendQuality() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") @@ -323,48 +316,48 @@ public void testBuildAppendQuality() .appendQuality("ity_") .withVariant(FastqVariant.FASTQ_SOLEXA); Fastq fastq = fastqBuilder.build(); - Assert.assertEquals("description", fastqBuilder.getDescription()); - Assert.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); - Assert.assertEquals("description", fastq.getDescription()); - Assert.assertEquals("sequence", fastq.getSequence()); - Assert.assertEquals("quality_", fastq.getQuality()); - Assert.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq.getVariant()); + Assertions.assertEquals("description", fastqBuilder.getDescription()); + Assertions.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); + Assertions.assertEquals("description", fastq.getDescription()); + Assertions.assertEquals("sequence", fastq.getSequence()); + Assertions.assertEquals("quality_", fastq.getQuality()); + Assertions.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq.getVariant()); } @Test - public void testBuildNonMatchingSequenceQualityScoreLengthsBothNull() + void testBuildNonMatchingSequenceQualityScoreLengthsBothNull() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") .withVariant(FastqVariant.FASTQ_SOLEXA); - Assert.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); + Assertions.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); } @Test - public void testBuildNonMatchingSequenceQualityScoreLengthsSequenceNull() + void testBuildNonMatchingSequenceQualityScoreLengthsSequenceNull() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") .withQuality("0123") .withVariant(FastqVariant.FASTQ_SOLEXA); - Assert.assertEquals(false, fastqBuilder.sequenceAndQualityLengthsMatch()); + Assertions.assertEquals(false, fastqBuilder.sequenceAndQualityLengthsMatch()); } @Test - public void testBuildNonMatchingSequenceQualityScoreLengthsQualityNull() + void testBuildNonMatchingSequenceQualityScoreLengthsQualityNull() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") .withSequence("ACTG") .withVariant(FastqVariant.FASTQ_SOLEXA); - Assert.assertEquals(false, fastqBuilder.sequenceAndQualityLengthsMatch()); + Assertions.assertEquals(false, fastqBuilder.sequenceAndQualityLengthsMatch()); } @Test - public void testBuildNonMatchingSequenceQualityScoreLengths0() + void testBuildNonMatchingSequenceQualityScoreLengths0() { try { @@ -375,7 +368,7 @@ public void testBuildNonMatchingSequenceQualityScoreLengths0() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build sequence length > quality length expected IllegalStateException"); + Assertions.fail("build sequence length > quality length expected IllegalStateException"); } catch (IllegalStateException e) { @@ -384,7 +377,7 @@ public void testBuildNonMatchingSequenceQualityScoreLengths0() } @Test - public void testBuildNonMatchingSequenceQualityScoreLengths1() + void testBuildNonMatchingSequenceQualityScoreLengths1() { try { @@ -395,7 +388,7 @@ public void testBuildNonMatchingSequenceQualityScoreLengths1() .withVariant(FastqVariant.FASTQ_SOLEXA); fastqBuilder.build(); - Assert.fail("build sequence length < quality length expected IllegalStateException"); + Assertions.fail("build sequence length < quality length expected IllegalStateException"); } catch (IllegalStateException e) { @@ -404,7 +397,7 @@ public void testBuildNonMatchingSequenceQualityScoreLengths1() } @Test - public void testBuildMultiple() + void testBuildMultiple() { FastqBuilder fastqBuilder = new FastqBuilder() .withDescription("description") @@ -414,12 +407,12 @@ public void testBuildMultiple() for (int i = 0; i < 10; i++) { Fastq fastq = fastqBuilder.withSequence("sequence" + i).build(); - Assert.assertEquals("description", fastqBuilder.getDescription()); - Assert.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); - Assert.assertEquals("description", fastq.getDescription()); - Assert.assertEquals("sequence" + i, fastq.getSequence()); - Assert.assertEquals("quality__", fastq.getQuality()); - Assert.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq.getVariant()); + Assertions.assertEquals("description", fastqBuilder.getDescription()); + Assertions.assertTrue(fastqBuilder.sequenceAndQualityLengthsMatch()); + Assertions.assertEquals("description", fastq.getDescription()); + Assertions.assertEquals("sequence" + i, fastq.getSequence()); + Assertions.assertEquals("quality__", fastq.getQuality()); + Assertions.assertEquals(FastqVariant.FASTQ_SOLEXA, fastq.getVariant()); } } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqTest.java index 62d7ee9368..5bcbb43ee9 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqTest.java @@ -20,26 +20,24 @@ */ package org.biojava.nbio.genome.io.fastq; -import org.junit.Assert; -import org.junit.Test; - -import org.junit.function.ThrowingRunnable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; /** * Unit test for Fastq. */ -public final class FastqTest { +final class FastqTest { @Test - public void testConstructor() + void testConstructor() { Fastq fastq = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); - Assert.assertNotNull(fastq); + Assertions.assertNotNull(fastq); try { new Fastq(null, "sequence", "quality_", FastqVariant.FASTQ_SANGER); - Assert.fail("ctr(null description) expected IllegalArgumentException"); + Assertions.fail("ctr(null description) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -48,7 +46,7 @@ public void testConstructor() try { new Fastq("description", null, "quality_", FastqVariant.FASTQ_SANGER); - Assert.fail("ctr(null sequence) expected IllegalArgumentException"); + Assertions.fail("ctr(null sequence) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -57,7 +55,7 @@ public void testConstructor() try { new Fastq("description", "sequence", null, FastqVariant.FASTQ_SANGER); - Assert.fail("ctr(null quality) expected IllegalArgumentException"); + Assertions.fail("ctr(null quality) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -66,7 +64,7 @@ public void testConstructor() try { new Fastq("description", "sequence", "quality_", null); - Assert.fail("ctr(null variant) expected IllegalArgumentException"); + Assertions.fail("ctr(null variant) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -75,88 +73,83 @@ public void testConstructor() } @Test - public void testDescription() + void testDescription() { Fastq fastq = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); - Assert.assertTrue(fastq.getDescription() != null); - Assert.assertEquals("description", fastq.getDescription()); + Assertions.assertTrue(fastq.getDescription() != null); + Assertions.assertEquals("description", fastq.getDescription()); } @Test - public void testSequence() + void testSequence() { Fastq fastq = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); - Assert.assertTrue(fastq.getSequence() != null); - Assert.assertEquals("sequence", fastq.getSequence()); + Assertions.assertTrue(fastq.getSequence() != null); + Assertions.assertEquals("sequence", fastq.getSequence()); } @Test - public void testQuality() + void testQuality() { Fastq fastq = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); - Assert.assertTrue(fastq.getQuality() != null); - Assert.assertEquals("quality_", fastq.getQuality()); + Assertions.assertTrue(fastq.getQuality() != null); + Assertions.assertEquals("quality_", fastq.getQuality()); } @Test - public void testVariant() + void testVariant() { Fastq fastq = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); - Assert.assertTrue(fastq.getVariant() != null); - Assert.assertEquals(FastqVariant.FASTQ_SANGER, fastq.getVariant()); + Assertions.assertTrue(fastq.getVariant() != null); + Assertions.assertEquals(FastqVariant.FASTQ_SANGER, fastq.getVariant()); } @Test - public void testBuilder() + void testBuilder() { - Assert.assertNotNull(Fastq.builder()); + Assertions.assertNotNull(Fastq.builder()); } @Test - public void testBuilderNullFastq() + void testBuilderNullFastq() { - Assert.assertThrows(IllegalArgumentException.class, new ThrowingRunnable() { - @Override - public void run() { - Fastq.builder(null); - } - }); + Assertions.assertThrows(IllegalArgumentException.class, () -> Fastq.builder(null)); } @Test - public void testEquals() + void testEquals() { Fastq fastq0 = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); Fastq fastq1 = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); - Assert.assertFalse(fastq0.equals(null)); - Assert.assertFalse(fastq1.equals(null)); - Assert.assertFalse(fastq0.equals(new Object())); - Assert.assertFalse(fastq1.equals(new Object())); - Assert.assertTrue(fastq0.equals(fastq0)); - Assert.assertTrue(fastq1.equals(fastq1)); - Assert.assertFalse(fastq0 == fastq1); - Assert.assertFalse(fastq0.equals(fastq1)); - Assert.assertFalse(fastq1.equals(fastq0)); + Assertions.assertFalse(fastq0.equals(null)); + Assertions.assertFalse(fastq1.equals(null)); + Assertions.assertFalse(fastq0.equals(new Object())); + Assertions.assertFalse(fastq1.equals(new Object())); + Assertions.assertTrue(fastq0.equals(fastq0)); + Assertions.assertTrue(fastq1.equals(fastq1)); + Assertions.assertFalse(fastq0 == fastq1); + Assertions.assertFalse(fastq0.equals(fastq1)); + Assertions.assertFalse(fastq1.equals(fastq0)); } @Test - public void testHashCode() + void testHashCode() { Fastq fastq0 = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); Fastq fastq1 = new Fastq("description", "sequence", "quality_", FastqVariant.FASTQ_SANGER); - Assert.assertEquals(fastq0.hashCode(), fastq0.hashCode()); - Assert.assertEquals(fastq1.hashCode(), fastq1.hashCode()); + Assertions.assertEquals(fastq0.hashCode(), fastq0.hashCode()); + Assertions.assertEquals(fastq1.hashCode(), fastq1.hashCode()); if (fastq0.equals(fastq1)) { - Assert.assertEquals(fastq0.hashCode(), fastq1.hashCode()); - Assert.assertEquals(fastq1.hashCode(), fastq0.hashCode()); + Assertions.assertEquals(fastq0.hashCode(), fastq1.hashCode()); + Assertions.assertEquals(fastq1.hashCode(), fastq0.hashCode()); } if (fastq1.equals(fastq0)) { - Assert.assertEquals(fastq0.hashCode(), fastq1.hashCode()); - Assert.assertEquals(fastq1.hashCode(), fastq0.hashCode()); + Assertions.assertEquals(fastq0.hashCode(), fastq1.hashCode()); + Assertions.assertEquals(fastq1.hashCode(), fastq0.hashCode()); } } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqToolsTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqToolsTest.java index 469601e624..43672b517b 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqToolsTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqToolsTest.java @@ -27,493 +27,340 @@ import org.biojava.nbio.core.sequence.features.QualityFeature; import org.biojava.nbio.core.sequence.features.QuantityFeature; import org.biojava.nbio.core.sequence.template.AbstractSequence; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; /** * Unit test for FastqTools. */ -public final class FastqToolsTest { +final class FastqToolsTest { private final FastqBuilder builder = new FastqBuilder().withDescription("foo").withSequence("ACTG").withQuality("ZZZZ"); @Test - public void testCreateDNASequence() throws CompoundNotFoundException + void testCreateDNASequence() throws CompoundNotFoundException { DNASequence sequence = FastqTools.createDNASequence(builder.build()); - Assert.assertNotNull(sequence); + Assertions.assertNotNull(sequence); } @Test - public void testCreateDNASequenceNullFastq() throws CompoundNotFoundException + void testCreateDNASequenceNullFastq() { - try - { - FastqTools.createDNASequence(null); - Assert.fail("createDNASequence(null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.createDNASequence(null)); } @Test - public void testCreateDNASequenceWithQualityScores() throws CompoundNotFoundException + void testCreateDNASequenceWithQualityScores() throws CompoundNotFoundException { DNASequence sequence = FastqTools.createDNASequenceWithQualityScores(builder.build()); - Assert.assertNotNull(sequence); + Assertions.assertNotNull(sequence); List, NucleotideCompound>> features = sequence.getFeaturesByType("qualityScores"); - Assert.assertNotNull(features); - Assert.assertEquals(1, features.size()); + Assertions.assertNotNull(features); + Assertions.assertEquals(1, features.size()); QualityFeature, NucleotideCompound> qualityScores = (QualityFeature, NucleotideCompound>) features.get(0); - Assert.assertEquals(sequence.getLength(), qualityScores.getQualities().size()); - Assert.assertEquals(sequence.getLength(), qualityScores.getLocations().getLength()); + Assertions.assertEquals(sequence.getLength(), qualityScores.getQualities().size()); + Assertions.assertEquals(sequence.getLength(), qualityScores.getLocations().getLength()); } @Test - public void testCreateDNASequenceWithQualityScoresNullFastq() throws CompoundNotFoundException + void testCreateDNASequenceWithQualityScoresNullFastq() { - try - { - FastqTools.createDNASequenceWithQualityScores(null); - Assert.fail("createDNASequenceWithQualityScores(null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.createDNASequenceWithQualityScores(null)); } @Test - public void testCreateDNASequenceWithErrorProbabilies() throws CompoundNotFoundException + void testCreateDNASequenceWithErrorProbabilies() throws CompoundNotFoundException { DNASequence sequence = FastqTools.createDNASequenceWithErrorProbabilities(builder.build()); - Assert.assertNotNull(sequence); + Assertions.assertNotNull(sequence); List, NucleotideCompound>> features = sequence.getFeaturesByType("errorProbabilities"); - Assert.assertNotNull(features); - Assert.assertEquals(1, features.size()); + Assertions.assertNotNull(features); + Assertions.assertEquals(1, features.size()); QuantityFeature, NucleotideCompound> errorProbabilities = (QuantityFeature, NucleotideCompound>) features.get(0); - Assert.assertEquals(sequence.getLength(), errorProbabilities.getQuantities().size()); - Assert.assertEquals(sequence.getLength(), errorProbabilities.getLocations().getLength()); + Assertions.assertEquals(sequence.getLength(), errorProbabilities.getQuantities().size()); + Assertions.assertEquals(sequence.getLength(), errorProbabilities.getLocations().getLength()); } @Test - public void testCreateDNASequenceWithErrorProbabilitiesNullFastq() throws CompoundNotFoundException + void testCreateDNASequenceWithErrorProbabilitiesNullFastq() { - try - { - FastqTools.createDNASequenceWithErrorProbabilities(null); - Assert.fail("createDNASequenceWithErrorProbabilities(null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.createDNASequenceWithErrorProbabilities(null)); } @Test - public void testCreateDNASequenceWithQualityScoresAndErrorProbabilities() throws CompoundNotFoundException + void testCreateDNASequenceWithQualityScoresAndErrorProbabilities() throws CompoundNotFoundException { DNASequence sequence = FastqTools.createDNASequenceWithQualityScoresAndErrorProbabilities(builder.build()); - Assert.assertNotNull(sequence); + Assertions.assertNotNull(sequence); List, NucleotideCompound>> qualityScoresFeatures = sequence.getFeaturesByType("qualityScores"); - Assert.assertNotNull(qualityScoresFeatures); - Assert.assertEquals(1, qualityScoresFeatures.size()); + Assertions.assertNotNull(qualityScoresFeatures); + Assertions.assertEquals(1, qualityScoresFeatures.size()); QualityFeature, NucleotideCompound> qualityScores = (QualityFeature, NucleotideCompound>) qualityScoresFeatures.get(0); - Assert.assertEquals(sequence.getLength(), qualityScores.getQualities().size()); - Assert.assertEquals(sequence.getLength(), qualityScores.getLocations().getLength()); + Assertions.assertEquals(sequence.getLength(), qualityScores.getQualities().size()); + Assertions.assertEquals(sequence.getLength(), qualityScores.getLocations().getLength()); List, NucleotideCompound>> errorProbabilitiesFeatures = sequence.getFeaturesByType("errorProbabilities"); - Assert.assertNotNull(errorProbabilitiesFeatures); - Assert.assertEquals(1, errorProbabilitiesFeatures.size()); + Assertions.assertNotNull(errorProbabilitiesFeatures); + Assertions.assertEquals(1, errorProbabilitiesFeatures.size()); QuantityFeature, NucleotideCompound> errorProbabilities = (QuantityFeature, NucleotideCompound>) errorProbabilitiesFeatures.get(0); - Assert.assertEquals(sequence.getLength(), errorProbabilities.getQuantities().size()); - Assert.assertEquals(sequence.getLength(), errorProbabilities.getLocations().getLength()); + Assertions.assertEquals(sequence.getLength(), errorProbabilities.getQuantities().size()); + Assertions.assertEquals(sequence.getLength(), errorProbabilities.getLocations().getLength()); } @Test - public void testCreateDNASequenceWithQualityScoresAndErrorProbabilitiesNullFastq() throws CompoundNotFoundException + void testCreateDNASequenceWithQualityScoresAndErrorProbabilitiesNullFastq() { - try - { - FastqTools.createDNASequenceWithQualityScoresAndErrorProbabilities(null); - Assert.fail("createDNASequenceWithQualityScoresAndErrorProbabilities(null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.createDNASequenceWithQualityScoresAndErrorProbabilities(null)); } @Test - public void testCreateQualityScores() + void testCreateQualityScores() { Fastq fastq = builder.build(); QualityFeature, NucleotideCompound> qualityScores = FastqTools.createQualityScores(fastq); - Assert.assertNotNull(qualityScores); - Assert.assertEquals(fastq.getSequence().length(), qualityScores.getQualities().size()); + Assertions.assertNotNull(qualityScores); + Assertions.assertEquals(fastq.getSequence().length(), qualityScores.getQualities().size()); } @Test - public void testCreateQualityScoresNullFastq() + void testCreateQualityScoresNullFastq() { - try - { - FastqTools.createQualityScores(null); - Assert.fail("createQualityScores(null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.createQualityScores(null)); } @Test - public void testCreateErrorProbabilities() + void testCreateErrorProbabilities() { Fastq fastq = builder.build(); QuantityFeature, NucleotideCompound> errorProbabilities = FastqTools.createErrorProbabilities(fastq); - Assert.assertNotNull(errorProbabilities); - Assert.assertEquals(fastq.getSequence().length(), errorProbabilities.getQuantities().size()); + Assertions.assertNotNull(errorProbabilities); + Assertions.assertEquals(fastq.getSequence().length(), errorProbabilities.getQuantities().size()); } @Test - public void testCreateErrorProbabilitiesNullFastq() + void testCreateErrorProbabilitiesNullFastq() { - try - { - FastqTools.createErrorProbabilities(null); - Assert.fail("createErrorProbabilities(null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.createErrorProbabilities(null)); } @Test - public void testQualityScores() + void testQualityScores() { Iterable qualityScores = FastqTools.qualityScores(builder.build()); - Assert.assertNotNull(qualityScores); - int count = 0; - for (Number qualityScore : qualityScores) - { - Assert.assertNotNull(qualityScore); - count++; - } - Assert.assertEquals(4, count); + List scoresList = StreamSupport.stream(qualityScores.spliterator(), false) + .collect(Collectors.toList()); + Assertions.assertAll( + () -> Assertions.assertEquals(4, scoresList.size()), + () -> Assertions.assertFalse(scoresList.contains(null)) + ); } @Test - public void testQualityScoresNullFastq() + void testQualityScoresNullFastq() { - try - { - FastqTools.qualityScores(null); - Assert.fail("qualityScores(null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.qualityScores(null)); } @Test - public void testQualityScoresIntArray() + void testQualityScoresIntArray() { int[] qualityScores = new int[4]; FastqTools.qualityScores(builder.build(), qualityScores); - for (int i = 0; i < 4; i++) - { - Assert.assertTrue(qualityScores[i] != 0); - } + + Assertions.assertTrue(Arrays.stream(qualityScores).allMatch(score -> score != 0), () -> + "Array contains zero at some position: " + Arrays.toString(qualityScores)); } @Test - public void testQualityScoresIntArrayNullFastq() + void testQualityScoresIntArrayNullFastq() { - try - { - FastqTools.qualityScores(null, new int[0]); - Assert.fail("qualityScores(null, int[]) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.qualityScores(null, new int[0])); } @Test - public void testQualityScoresNullIntArray() + void testQualityScoresNullIntArray() { - try - { - FastqTools.qualityScores(builder.build(), null); - Assert.fail("qualityScores(fastq, null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Fastq fastq = builder.build(); + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.qualityScores(fastq, null)); } @Test - public void testQualityScoresQualityScoresTooSmall() + void testQualityScoresQualityScoresTooSmall() { - try - { - FastqTools.qualityScores(builder.build(), new int[3]); - Assert.fail("expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Fastq fastq = builder.build(); + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.qualityScores(fastq, new int[3])); } @Test - public void testQualityScoresQualityScoresTooLarge() + void testQualityScoresQualityScoresTooLarge() { - try - { - FastqTools.qualityScores(builder.build(), new int[5]); - Assert.fail("expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Fastq fastq = builder.build(); + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.qualityScores(fastq, new int[5])); } @Test - public void testErrorProbabilities() + void testErrorProbabilities() { Iterable errorProbabilities = FastqTools.errorProbabilities(builder.build()); - Assert.assertNotNull(errorProbabilities); - int count = 0; - for (Number errorProbability : errorProbabilities) - { - Assert.assertNotNull(errorProbability); - count++; - } - Assert.assertEquals(4, count); + List scores = StreamSupport.stream(errorProbabilities.spliterator(), false) + .collect(Collectors.toList()); + + Assertions.assertNotNull(scores); + Assertions.assertEquals(4, scores.size()); + Assertions.assertTrue(scores.stream().allMatch(Objects::nonNull)); } @Test - public void testErrorProbabilitiesNullFastq() + void testErrorProbabilitiesNullFastq() { - try - { - FastqTools.errorProbabilities(null); - Assert.fail("errorProbabilities(null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.errorProbabilities(null)); } @Test - public void testErrorProbabilitiesDoubleArray() + void testErrorProbabilitiesDoubleArray() { double[] errorProbabilities = new double[4]; FastqTools.errorProbabilities(builder.build(), errorProbabilities); - for (int i = 0; i < 0; i++) - { - Assert.assertTrue(errorProbabilities[i] > 0.0d); - } + Assertions.assertTrue( + Arrays.stream(errorProbabilities).allMatch(p -> p > 0.0), + () -> "Expected all probabilities to be > 0.0, but got: " + Arrays.toString(errorProbabilities) + ); } @Test - public void testErrorProbabilitiesDoubleArrayNullFastq() + void testErrorProbabilitiesDoubleArrayNullFastq() { - try - { - FastqTools.errorProbabilities(null, new double[0]); - Assert.fail("errorProbabilities(null, double[]) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.errorProbabilities(null, new double[0])); } @Test - public void testErrorProbabilitiesNullErrorProbabilities() + void testErrorProbabilitiesNullErrorProbabilities() { - try - { - FastqTools.errorProbabilities(builder.build(), null); - Assert.fail("errorProbabilities(fastq, null) expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Fastq fastq = builder.build(); + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.errorProbabilities(fastq, null)); } @Test - public void testErrorProbabilitiesErrorProbabilitiesTooSmall() + void testErrorProbabilitiesErrorProbabilitiesTooSmall() { - try - { - FastqTools.errorProbabilities(builder.build(), new double[3]); - Assert.fail("expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Fastq fastq = builder.build(); + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.errorProbabilities(fastq, new double[3])); } @Test - public void testErrorProbabilitiesErrorProbabilitiesTooLarge() + void testErrorProbabilitiesErrorProbabilitiesTooLarge() { - try - { - FastqTools.errorProbabilities(builder.build(), new double[5]); - Assert.fail("expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Fastq fastq = builder.build(); + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.errorProbabilities(fastq, new double[5])); } @Test - public void testConvertNullFastq() + void testConvertNullFastq() { - try - { - FastqTools.convert(null, FastqVariant.FASTQ_SANGER); - Assert.fail("expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.convert(null, FastqVariant.FASTQ_SANGER)); } @Test - public void testConvertNullVariant() + void testConvertNullVariant() { - try - { - FastqTools.convert(builder.build(), null); - Assert.fail("expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Fastq fastq = builder.build(); + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.convert(fastq, null)); } @Test - public void testConvertSameVariant() + void testConvertSameVariant() { Fastq fastq = builder.build(); - Assert.assertEquals(fastq, FastqTools.convert(fastq, fastq.getVariant())); + Assertions.assertEquals(fastq, FastqTools.convert(fastq, fastq.getVariant())); } @Test - public void testConvertQualitiesNullFastq() + void testConvertQualitiesNullFastq() { - try - { - FastqTools.convertQualities(null, FastqVariant.FASTQ_SANGER); - Assert.fail("expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.convertQualities(null, FastqVariant.FASTQ_SANGER)); } @Test - public void testConvertQualitiesNullVariant() + void testConvertQualitiesNullVariant() { - try - { - FastqTools.convertQualities(builder.build(), null); - Assert.fail("expected IllegalArgumentException"); - } - catch (IllegalArgumentException e) - { - // expected - } + Fastq fastq = builder.build(); + Assertions.assertThrows(IllegalArgumentException.class, () -> FastqTools.convertQualities(fastq, null)); } @Test - public void testConvertQualitiesSameVariant() + void testConvertQualitiesSameVariant() { Fastq fastq = builder.build(); - Assert.assertEquals(fastq.getQuality(), FastqTools.convertQualities(fastq, fastq.getVariant())); + Assertions.assertEquals(fastq.getQuality(), FastqTools.convertQualities(fastq, fastq.getVariant())); } @Test - public void testConvertQualitiesSangerToSolexa() + void testConvertQualitiesSangerToSolexa() { Fastq fastq = builder.build(); - Assert.assertEquals("yyyy", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_SOLEXA)); + Assertions.assertEquals("yyyy", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_SOLEXA)); } @Test - public void testConvertQualitiesSangerToIllumina() + void testConvertQualitiesSangerToIllumina() { Fastq fastq = builder.build(); - Assert.assertEquals("yyyy", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_ILLUMINA)); + Assertions.assertEquals("yyyy", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_ILLUMINA)); } @Test - public void testConvertQualitiesSolexaToSanger() + void testConvertQualitiesSolexaToSanger() { Fastq fastq = builder.withVariant(FastqVariant.FASTQ_SOLEXA).build(); - Assert.assertEquals(";;;;", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_SANGER)); + Assertions.assertEquals(";;;;", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_SANGER)); } @Test - public void testConvertQualitiesIlluminaToSanger() + void testConvertQualitiesIlluminaToSanger() { Fastq fastq = builder.withVariant(FastqVariant.FASTQ_ILLUMINA).build(); - Assert.assertEquals(";;;;", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_SANGER)); + Assertions.assertEquals(";;;;", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_SANGER)); } @Test - public void testConvertQualitiesSolexaToIllumina() + void testConvertQualitiesSolexaToIllumina() { Fastq fastq = builder.withVariant(FastqVariant.FASTQ_SOLEXA).build(); - Assert.assertEquals("ZZZZ", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_ILLUMINA)); + Assertions.assertEquals("ZZZZ", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_ILLUMINA)); } @Test - public void testConvertQualitiesIlluminaToSolexa() + void testConvertQualitiesIlluminaToSolexa() { Fastq fastq = builder.withVariant(FastqVariant.FASTQ_ILLUMINA).build(); - Assert.assertEquals("ZZZZ", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_SOLEXA)); + Assertions.assertEquals("ZZZZ", FastqTools.convertQualities(fastq, FastqVariant.FASTQ_SOLEXA)); } @Test - public void testToList() + void testToList() { - List list = new ArrayList(); - Assert.assertSame(list, FastqTools.toList(list)); + List list = new ArrayList<>(); + Assertions.assertSame(list, FastqTools.toList(list)); } @Test - public void testToListNotAList() + void testToListNotAList() { - Collection collection = new HashSet(); - Assert.assertTrue(FastqTools.toList(collection) instanceof List); - Assert.assertNotSame(collection, FastqTools.toList(collection)); + Collection collection = new HashSet<>(); + Assertions.assertTrue(FastqTools.toList(collection) instanceof List); + Assertions.assertNotSame(collection, FastqTools.toList(collection)); } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqVariantTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqVariantTest.java index f8b0855a8e..a47896b714 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqVariantTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/FastqVariantTest.java @@ -22,66 +22,66 @@ import static org.biojava.nbio.genome.io.fastq.FastqVariant.*; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; /** * Unit test for FastqVariant. */ -public final class FastqVariantTest { +final class FastqVariantTest { @Test - public void testDescription() + void testDescription() { for (FastqVariant variant : values()) { - Assert.assertNotNull(variant.getDescription()); + Assertions.assertNotNull(variant.getDescription()); } } @Test - public void testIsSanger() + void testIsSanger() { - Assert.assertTrue(FASTQ_SANGER.isSanger()); - Assert.assertFalse(FASTQ_SOLEXA.isSanger()); - Assert.assertFalse(FASTQ_ILLUMINA.isSanger()); + Assertions.assertTrue(FASTQ_SANGER.isSanger()); + Assertions.assertFalse(FASTQ_SOLEXA.isSanger()); + Assertions.assertFalse(FASTQ_ILLUMINA.isSanger()); } @Test - public void testIsSolexa() + void testIsSolexa() { - Assert.assertFalse(FASTQ_SANGER.isSolexa()); - Assert.assertTrue(FASTQ_SOLEXA.isSolexa()); - Assert.assertFalse(FASTQ_ILLUMINA.isSolexa()); + Assertions.assertFalse(FASTQ_SANGER.isSolexa()); + Assertions.assertTrue(FASTQ_SOLEXA.isSolexa()); + Assertions.assertFalse(FASTQ_ILLUMINA.isSolexa()); } @Test - public void testIsIllumina() + void testIsIllumina() { - Assert.assertFalse(FASTQ_SANGER.isIllumina()); - Assert.assertFalse(FASTQ_SOLEXA.isIllumina()); - Assert.assertTrue(FASTQ_ILLUMINA.isIllumina()); + Assertions.assertFalse(FASTQ_SANGER.isIllumina()); + Assertions.assertFalse(FASTQ_SOLEXA.isIllumina()); + Assertions.assertTrue(FASTQ_ILLUMINA.isIllumina()); } @Test - public void testParseFastqVariant() + void testParseFastqVariant() { - Assert.assertEquals(null, parseFastqVariant(null)); - Assert.assertEquals(null, parseFastqVariant("")); - Assert.assertEquals(null, parseFastqVariant("not a valid FASTQ variant")); - Assert.assertEquals(FASTQ_SANGER, parseFastqVariant("FASTQ_SANGER")); - Assert.assertEquals(FASTQ_SANGER, parseFastqVariant("fastq-sanger")); + Assertions.assertEquals(null, parseFastqVariant(null)); + Assertions.assertEquals(null, parseFastqVariant("")); + Assertions.assertEquals(null, parseFastqVariant("not a valid FASTQ variant")); + Assertions.assertEquals(FASTQ_SANGER, parseFastqVariant("FASTQ_SANGER")); + Assertions.assertEquals(FASTQ_SANGER, parseFastqVariant("fastq-sanger")); } @Test - public void testQualityLessThanMinimumQualityScore() + void testQualityLessThanMinimumQualityScore() { for (FastqVariant variant : values()) { try { variant.quality(variant.minimumQualityScore() - 1); - Assert.fail("expected IllegalArgumentException"); + Assertions.fail("expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -91,14 +91,14 @@ public void testQualityLessThanMinimumQualityScore() } @Test - public void testQualityMoreThanMaximumQualityScore() + void testQualityMoreThanMaximumQualityScore() { for (FastqVariant variant : values()) { try { variant.quality(variant.maximumQualityScore() + 1); - Assert.fail("expected IllegalArgumentException"); + Assertions.fail("expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -108,13 +108,13 @@ public void testQualityMoreThanMaximumQualityScore() } @Test - public void testQualityQualityScoreRoundTrip() + void testQualityQualityScoreRoundTrip() { for (FastqVariant variant : values()) { for (int i = variant.minimumQualityScore(); i < (variant.maximumQualityScore() + 1); i++) { - Assert.assertEquals(i, variant.qualityScore(variant.quality(i))); + Assertions.assertEquals(i, variant.qualityScore(variant.quality(i))); } } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/IlluminaFastqReaderTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/IlluminaFastqReaderTest.java index d7b0a8b9d2..1d2adf76b6 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/IlluminaFastqReaderTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/IlluminaFastqReaderTest.java @@ -20,8 +20,8 @@ */ package org.biojava.nbio.genome.io.fastq; -import org.junit.Test; -import static org.junit.Assert.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.io.IOException; import java.io.InputStream; @@ -31,7 +31,7 @@ /** * Unit test for IlluminaFastqReader. */ -public final class IlluminaFastqReaderTest +final class IlluminaFastqReaderTest extends AbstractFastqReaderTest { @@ -59,119 +59,119 @@ public FastqWriter createFastqWriter() } @Test - public void testValidateDescription() throws Exception + void testValidateDescription() throws Exception { IlluminaFastqReader reader = new IlluminaFastqReader(); URL invalidDescription = getClass().getResource("illumina-invalid-description.fastq"); try { reader.read(invalidDescription); - fail("read(invalidDescription) expected IOException"); + Assertions.fail("read(invalidDescription) expected IOException"); } catch (IOException e) { - assertTrue(e.getMessage().contains("description must begin with a '@' character")); + Assertions.assertTrue(e.getMessage().contains("description must begin with a '@' character")); } } @Test - public void testValidateRepeatDescription() throws Exception + void testValidateRepeatDescription() throws Exception { IlluminaFastqReader reader = new IlluminaFastqReader(); URL invalidRepeatDescription = getClass().getResource("illumina-invalid-repeat-description.fastq"); try { reader.read(invalidRepeatDescription); - fail("read(invalidRepeatDescription) expected IOException"); + Assertions.fail("read(invalidRepeatDescription) expected IOException"); } catch (IOException e) { - assertTrue(e.getMessage().contains("repeat description must match description")); + Assertions.assertTrue(e.getMessage().contains("repeat description must match description")); } } @Test - public void testWrappingAsIllumina() throws Exception + void testWrappingAsIllumina() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("wrapping_as_illumina.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(3, count); + Assertions.assertEquals(3, count); inputStream.close(); } @Test - public void testFullRangeAsIllumina() throws Exception + void testFullRangeAsIllumina() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("illumina_full_range_as_illumina.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(2, count); + Assertions.assertEquals(2, count); inputStream.close(); } @Test - public void testMiscDnaAsIllumina() throws Exception + void testMiscDnaAsIllumina() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("misc_dna_as_illumina.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(4, count); + Assertions.assertEquals(4, count); inputStream.close(); } @Test - public void testMiscRnaAsIllumina() throws Exception + void testMiscRnaAsIllumina() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("misc_rna_as_illumina.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(4, count); + Assertions.assertEquals(4, count); inputStream.close(); } @Test - public void testLongReadsAsIllumina() throws Exception + void testLongReadsAsIllumina() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("longreads_as_illumina.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(10, count); + Assertions.assertEquals(10, count); inputStream.close(); } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/IlluminaFastqWriterTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/IlluminaFastqWriterTest.java index c9701595fa..384e204ff0 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/IlluminaFastqWriterTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/IlluminaFastqWriterTest.java @@ -21,12 +21,12 @@ package org.biojava.nbio.genome.io.fastq; -import org.junit.Test; +import org.junit.jupiter.api.Test; /** * Unit test for IlluminaFastqWriter. */ -public final class IlluminaFastqWriterTest +final class IlluminaFastqWriterTest extends AbstractFastqWriterTest { @@ -48,7 +48,7 @@ public Fastq createFastq() } @Test - public void testConvertNotIlluminaVariant() throws Exception + void testConvertNotIlluminaVariant() throws Exception { IlluminaFastqWriter writer = new IlluminaFastqWriter(); Appendable appendable = new StringBuilder(); diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SangerFastqReaderTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SangerFastqReaderTest.java index af6f67319f..3e99a4cdc8 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SangerFastqReaderTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SangerFastqReaderTest.java @@ -20,18 +20,17 @@ */ package org.biojava.nbio.genome.io.fastq; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.io.IOException; import java.io.InputStream; import java.net.URL; -import static org.junit.Assert.*; - /** * Unit test for SangerFastqReader. */ -public final class SangerFastqReaderTest +final class SangerFastqReaderTest extends AbstractFastqReaderTest { @@ -65,197 +64,197 @@ public void testValidateDescription() throws Exception try { reader.read(invalidDescription); - fail("read(invalidDescription) expected IOException"); + Assertions.fail("read(invalidDescription) expected IOException"); } catch (IOException e) { - assertTrue(e.getMessage().contains("description must begin with a '@' character")); + Assertions.assertTrue(e.getMessage().contains("description must begin with a '@' character")); } } @Test - public void testValidateRepeatDescription() throws Exception + void testValidateRepeatDescription() throws Exception { SangerFastqReader reader = new SangerFastqReader(); URL invalidRepeatDescription = getClass().getResource("sanger-invalid-repeat-description.fastq"); try { reader.read(invalidRepeatDescription); - fail("read(invalidRepeatDescription) expected IOException"); + Assertions.fail("read(invalidRepeatDescription) expected IOException"); } catch (IOException e) { - assertTrue(e.getMessage().contains("repeat description must match description")); + Assertions.assertTrue(e.getMessage().contains("repeat description must match description")); } } @Test - public void testWrappingOriginal() throws Exception + void testWrappingOriginal() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("wrapping_original_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(3, count); + Assertions.assertEquals(3, count); inputStream.close(); } @Test - public void testWrappingAsSanger() throws Exception + void testWrappingAsSanger() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("wrapping_as_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(3, count); + Assertions.assertEquals(3, count); inputStream.close(); } @Test - public void testFullRangeOriginal() throws Exception + void testFullRangeOriginal() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("sanger_full_range_original_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(2, count); + Assertions.assertEquals(2, count); inputStream.close(); } @Test - public void testFullRangeAsSanger() throws Exception + void testFullRangeAsSanger() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("sanger_full_range_as_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(2, count); + Assertions.assertEquals(2, count); inputStream.close(); } @Test - public void testMiscDnaOriginal() throws Exception + void testMiscDnaOriginal() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("misc_dna_original_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(4, count); + Assertions.assertEquals(4, count); inputStream.close(); } @Test - public void testMiscDnaAsSanger() throws Exception + void testMiscDnaAsSanger() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("misc_dna_as_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(4, count); + Assertions.assertEquals(4, count); inputStream.close(); } @Test - public void testMiscRnaOriginal() throws Exception + void testMiscRnaOriginal() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("misc_rna_original_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(4, count); + Assertions.assertEquals(4, count); inputStream.close(); } @Test - public void testMiscRnaAsSanger() throws Exception + void testMiscRnaAsSanger() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("misc_rna_as_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(4, count); + Assertions.assertEquals(4, count); inputStream.close(); } @Test - public void testLongReadsOriginal() throws Exception + void testLongReadsOriginal() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("longreads_original_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(10, count); + Assertions.assertEquals(10, count); inputStream.close(); } @Test - public void testLongReadsAsSanger() throws Exception + void testLongReadsAsSanger() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("longreads_as_sanger.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(10, count); + Assertions.assertEquals(10, count); inputStream.close(); } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SangerFastqWriterTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SangerFastqWriterTest.java index f94db84e0a..fcb1638c71 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SangerFastqWriterTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SangerFastqWriterTest.java @@ -21,12 +21,12 @@ package org.biojava.nbio.genome.io.fastq; -import org.junit.Test; +import org.junit.jupiter.api.Test; /** * Unit test for SangerFastqWriter. */ -public final class SangerFastqWriterTest +final class SangerFastqWriterTest extends AbstractFastqWriterTest { @@ -48,7 +48,7 @@ public Fastq createFastq() } @Test - public void testConvertNotSangerVariant() throws Exception + void testConvertNotSangerVariant() throws Exception { SangerFastqWriter writer = new SangerFastqWriter(); Appendable appendable = new StringBuilder(); diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SolexaFastqReaderTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SolexaFastqReaderTest.java index 5f6f041c84..cd87c53dc6 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SolexaFastqReaderTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SolexaFastqReaderTest.java @@ -20,19 +20,18 @@ */ package org.biojava.nbio.genome.io.fastq; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.io.IOException; import java.io.InputStream; import java.net.URL; -import static org.junit.Assert.*; - /** * Unit test for SolexaFastqReader. */ -public final class SolexaFastqReaderTest +final class SolexaFastqReaderTest extends AbstractFastqReaderTest { @@ -60,119 +59,119 @@ public FastqWriter createFastqWriter() } @Test - public void testValidateDescription() throws Exception + void testValidateDescription() throws Exception { SolexaFastqReader reader = new SolexaFastqReader(); URL invalidDescription = getClass().getResource("solexa-invalid-description.fastq"); try { reader.read(invalidDescription); - fail("read(invalidDescription) expected IOException"); + Assertions.fail("read(invalidDescription) expected IOException"); } catch (IOException e) { - assertTrue(e.getMessage().contains("description must begin with a '@' character")); + Assertions.assertTrue(e.getMessage().contains("description must begin with a '@' character")); } } @Test - public void testValidateRepeatDescription() throws Exception + void testValidateRepeatDescription() throws Exception { SolexaFastqReader reader = new SolexaFastqReader(); URL invalidRepeatDescription = getClass().getResource("solexa-invalid-repeat-description.fastq"); try { reader.read(invalidRepeatDescription); - fail("read(invalidRepeatDescription) expected IOException"); + Assertions.fail("read(invalidRepeatDescription) expected IOException"); } catch (IOException e) { - assertTrue(e.getMessage().contains("repeat description must match description")); + Assertions.assertTrue(e.getMessage().contains("repeat description must match description")); } } @Test - public void testWrappingAsSolexa() throws Exception + void testWrappingAsSolexa() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("wrapping_as_solexa.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(3, count); + Assertions.assertEquals(3, count); inputStream.close(); } @Test - public void testFullRangeAsSolexa() throws Exception + void testFullRangeAsSolexa() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("solexa_full_range_as_solexa.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(2, count); + Assertions.assertEquals(2, count); inputStream.close(); } @Test - public void testMiscDnaAsSolexa() throws Exception + void testMiscDnaAsSolexa() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("misc_dna_as_solexa.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(4, count); + Assertions.assertEquals(4, count); inputStream.close(); } @Test - public void testMiscRnaAsSolexa() throws Exception + void testMiscRnaAsSolexa() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("misc_rna_as_solexa.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(4, count); + Assertions.assertEquals(4, count); inputStream.close(); } @Test - public void testLongReadsAsSolexa() throws Exception + void testLongReadsAsSolexa() throws Exception { FastqReader reader = createFastqReader(); InputStream inputStream = getClass().getResourceAsStream("longreads_as_solexa.fastq"); Iterable iterable = reader.read(inputStream); - assertNotNull(iterable); + Assertions.assertNotNull(iterable); int count = 0; for (Fastq f : iterable) { - assertNotNull(f); + Assertions.assertNotNull(f); count++; } - assertEquals(10, count); + Assertions.assertEquals(10, count); inputStream.close(); } } diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SolexaFastqWriterTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SolexaFastqWriterTest.java index 2f2011e849..0927bf0cff 100755 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SolexaFastqWriterTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/SolexaFastqWriterTest.java @@ -21,12 +21,12 @@ package org.biojava.nbio.genome.io.fastq; -import org.junit.Test; +import org.junit.jupiter.api.Test; /** * Unit test for SolexaFastqWriter. */ -public final class SolexaFastqWriterTest +final class SolexaFastqWriterTest extends AbstractFastqWriterTest { @@ -48,7 +48,7 @@ public Fastq createFastq() } @Test - public void testConvertNotSolexaVariant() throws Exception + void testConvertNotSolexaVariant() throws Exception { SolexaFastqWriter writer = new SolexaFastqWriter(); Appendable appendable = new StringBuilder(); diff --git a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/StreamingFastqParserTest.java b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/StreamingFastqParserTest.java index a80f44a43d..02d49d3177 100644 --- a/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/StreamingFastqParserTest.java +++ b/biojava-genome/src/test/java/org/biojava/nbio/genome/io/fastq/StreamingFastqParserTest.java @@ -20,8 +20,8 @@ */ package org.biojava.nbio.genome.io.fastq; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import java.io.StringReader; @@ -29,10 +29,10 @@ /** * Unit test for StreamingFastqParser. */ -public class StreamingFastqParserTest { +class StreamingFastqParserTest { @Test - public void testStreamNullReadable() throws Exception + void testStreamNullReadable() throws Exception { try { @@ -42,7 +42,7 @@ public void fastq(final Fastq fastq) { // empty } }); - Assert.fail("stream(null,,) expected IllegalArgumentException"); + Assertions.fail("stream(null,,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -51,7 +51,7 @@ public void fastq(final Fastq fastq) { } @Test - public void testStreamNullVariant() throws Exception + void testStreamNullVariant() throws Exception { try { @@ -62,7 +62,7 @@ public void fastq(final Fastq fastq) { // empty } }); - Assert.fail("stream(null,,) expected IllegalArgumentException"); + Assertions.fail("stream(null,,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { @@ -71,13 +71,13 @@ public void fastq(final Fastq fastq) { } @Test - public void testStreamNullListener() throws Exception + void testStreamNullListener() throws Exception { try { final String input = ""; StreamingFastqParser.stream(new StringReader(input), FastqVariant.FASTQ_SANGER, null); - Assert.fail("stream(null,,) expected IllegalArgumentException"); + Assertions.fail("stream(null,,) expected IllegalArgumentException"); } catch (IllegalArgumentException e) { diff --git a/biojava-integrationtest/pom.xml b/biojava-integrationtest/pom.xml index 41d9be230c..81ec390cc7 100644 --- a/biojava-integrationtest/pom.xml +++ b/biojava-integrationtest/pom.xml @@ -4,7 +4,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-integrationtest jar @@ -40,7 +40,7 @@ org.biojava biojava-structure - 7.2.3 + 7.3.0-SNAPSHOT diff --git a/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/ecod/EcodInstallationTest.java b/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/ecod/EcodInstallationTest.java index 8ab3f29fb3..f384f2ba65 100644 --- a/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/ecod/EcodInstallationTest.java +++ b/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/ecod/EcodInstallationTest.java @@ -22,8 +22,12 @@ import static org.junit.Assert.*; +import java.io.BufferedReader; import java.io.File; +import java.io.FileReader; import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -47,6 +51,7 @@ import org.biojava.nbio.structure.ecod.EcodDomain; import org.biojava.nbio.structure.ecod.EcodFactory; import org.biojava.nbio.structure.ecod.EcodInstallation; +import org.biojava.nbio.structure.ecod.EcodInstallation.EcodParser; import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; @@ -277,12 +282,47 @@ public void testFilterByHierarchy() throws IOException { assertEquals(expected,actual); } + /** + * Checks that the current release can still be read. + *

+ * The version is read from the file's header without parsing the domains, so this + * additionally parses the first few thousand lines of the same file. That is enough to + * notice a column change — which is what ECOD did at v294.1, unnoticed for months — + * without building the three million domains the whole file now holds. + */ @Test public void testVersion() throws IOException { EcodDatabase ecod3 = EcodFactory.getEcodDatabase("latest"); String version = ecod3.getVersion(); assertNotNull(version); assertNotEquals("latest", version); + System.out.println("latest version of ECOD is "+version); + + File domainsFile = new File(((EcodInstallation) ecod3).getCacheLocation(), + "ecod.latest.domains.txt"); + assertTrue("No local copy of the domains file at "+domainsFile, domainsFile.exists()); + + EcodParser parser = new EcodParser(firstLines(domainsFile, 5000)); + assertEquals(version, parser.getVersion()); + assertFalse("No domains parsed from ECOD "+version + + "; the distribution format has probably changed", + parser.getDomains().isEmpty()); + } + + /** + * @return a reader over the first {@code maxLines} lines of the file + */ + private static Reader firstLines(File f, int maxLines) throws IOException { + StringBuilder head = new StringBuilder(); + try (BufferedReader in = new BufferedReader(new FileReader(f))) { + String line; + int n = 0; + while (n < maxLines && (line = in.readLine()) != null) { + head.append(line).append('\n'); + n++; + } + } + return new StringReader(head.toString()); } /** diff --git a/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/io/TestSeqResParsing.java b/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/io/TestSeqResParsing.java index de6c072719..6ea23aef8a 100644 --- a/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/io/TestSeqResParsing.java +++ b/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/io/TestSeqResParsing.java @@ -53,7 +53,7 @@ public void test11GS() throws IOException, StructureException{ s = StructureIO.getStructure(pdbID); assertNotNull(s); - assertTrue(s.getChains().size() > 0); + assertFalse(s.getChains().isEmpty()); Chain c = s.getChainByIndex(0); assertTrue(c.getSeqResGroups().size() > 2); diff --git a/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/io/density/DensityMapIntegrationTest.java b/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/io/density/DensityMapIntegrationTest.java new file mode 100644 index 0000000000..97bf76a294 --- /dev/null +++ b/biojava-integrationtest/src/test/java/org/biojava/nbio/structure/test/io/density/DensityMapIntegrationTest.java @@ -0,0 +1,215 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.test.io.density; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; + +import org.biojava.nbio.core.util.FileDownloadUtils; +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.io.density.Ccp4Header; +import org.biojava.nbio.structure.io.density.DensityFileFormat; +import org.biojava.nbio.structure.io.density.DensityMapCache; +import org.biojava.nbio.structure.io.density.DensityMapKind; +import org.biojava.nbio.structure.io.density.DensityMapRequest; +import org.biojava.nbio.structure.io.density.DensityMapResult; +import org.biojava.nbio.structure.io.density.DensityMapSource; +import org.biojava.nbio.structure.io.density.NoDensityMapException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Density fetching against the real services. + *

+ * Deliberately frugal: the entries chosen keep a full run to a couple of + * megabytes plus a few small metadata calls. In particular the cryo-EM path is + * exercised with the size limit set so low that the 116 MB map is declined + * before any of its body is transferred, which tests the whole resolution and + * guard sequence without the download. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class DensityMapIntegrationTest { + + private File cacheRoot; + private DensityMapCache cache; + + @BeforeEach + public void setUp() throws IOException { + cacheRoot = Files.createTempDirectory("bj-density-it").toFile(); + cache = new DensityMapCache(cacheRoot.getAbsolutePath()); + } + + @AfterEach + public void tearDown() throws IOException { + FileDownloadUtils.deleteDirectory(cacheRoot.toPath()); + } + + /** The default path for an X-ray entry: the smallest source answers first. */ + @Test + public void fetchesAnXrayMapFromTheFirstSourceTried() throws IOException { + DensityMapResult result = cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + + assertEquals(DensityMapSource.RCSB_VOLUME_SERVER, result.getSource()); + assertEquals(DensityMapKind.TWO_FO_FC, result.getKind()); + assertTrue(result.isRenderable()); + assertFalse(result.isFromCache()); + assertTrue(result.getFileSizeBytes() > 1024); + assertTrue(DensityMapResult.metaFileFor(result.getFile()).isFile(), + "a .meta sidecar makes the result reconstructible offline"); + + // second call must come from the cache without another download + DensityMapResult again = cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + assertTrue(again.isFromCache()); + assertEquals(result.getFile(), again.getFile()); + } + + /** + * Both map kinds come out of one download, and the difference map is presented + * under the companion name that makes Jmol read the other data block. + */ + @Test + public void bothKindsShareASingleDownload() throws IOException { + DensityMapResult twoFoFc = cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + DensityMapResult foFc = cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.FO_FC); + + assertEquals(DensityMapKind.FO_FC, foFc.getKind()); + assertFalse(twoFoFc.getFile().equals(foFc.getFile()), "the difference map needs its own file name"); + assertTrue(foFc.getFile().getName().contains("&diff=1"), + "the marker has to be in the name for Jmol to select the FO-FC block"); + assertEquals(twoFoFc.getFileSizeBytes(), foFc.getFileSizeBytes(), + "both names must address the same bytes"); + } + + /** PDBe serves real CCP4 files, which the header check should recognise. */ + @Test + public void pdbeServesAGenuineCcp4Map() throws IOException { + cache.setSourceChain(DensityMapKind.TWO_FO_FC, Arrays.asList(DensityMapSource.PDBE_CCP4)); + DensityMapResult result = cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + + assertEquals(DensityMapSource.PDBE_CCP4, result.getSource()); + assertEquals(DensityFileFormat.CCP4, result.getFormat()); + assertTrue(Ccp4Header.isCcp4(result.getFile()), "the CCP4 stamp should be present at byte 208"); + assertTrue(FileDownloadUtils.validateFile(result.getFile())); + } + + /** + * The whole cryo-EM route: resolve the EMDB entry, pick up the author contour + * level, and decline the full map on size without transferring it. + */ + @Test + public void resolvesCryoEmEntriesAndHonoursTheSizeLimit() throws IOException { + List emdbIds = cache.getEmdbResolver().getEmdbIds(new PdbId("6hu9")); + assertEquals(Arrays.asList("EMD-0262"), emdbIds); + + DensityMapResult result = cache.getDensityMap(new PdbId("6hu9"), DensityMapKind.AUTO); + assertEquals(DensityMapKind.EM, result.getKind()); + assertEquals("EMD-0262", result.getEmdbId()); + assertNotNull(result.getRecommendedContourLevel(), + "EM maps need the author contour level to be displayed properly"); + assertEquals(0.0263, result.getRecommendedContourLevel(), 1e-6); + assertNotNull(result.getContourInSigma()); + + // With only the full archive enabled and a tiny ceiling, the guard must fire + // rather than pulling down 116 MB. + DensityMapCache strict = new DensityMapCache(cacheRoot.getAbsolutePath()); + strict.setSourceChain(DensityMapKind.EM, Arrays.asList(DensityMapSource.EMDB_MAP)); + strict.setMaxDownloadBytes(1024); + try { + strict.getDensityMap(DensityMapRequest.builder(new PdbId("6hu9")).kind(DensityMapKind.EM).build()); + fail("the size guard should have declined the full EMDB map"); + } catch (NoDensityMapException e) { + assertTrue(e.getAttempts().get(DensityMapSource.EMDB_MAP).contains("too large")); + } + } + + /** 4HHB was deposited in 1984 without structure factors, so nothing has a map for it. */ + @Test + public void reportsWhyAnEntryHasNoDensity() throws IOException { + cache.setSourceEnabled(DensityMapSource.WWPDB_MAP_COEFFICIENTS, true); + try { + cache.getDensityMap(new PdbId("4hhb"), DensityMapKind.AUTO); + fail("4hhb has no deposited structure factors"); + } catch (NoDensityMapException e) { + assertFalse(e.getAttempts().isEmpty()); + assertTrue(e.getAttempts().values().stream().anyMatch(r -> r.contains("404"))); + } + } + + /** + * Coefficients must arrive intact and be verifiable afterwards. Whether that + * verification includes a cryptographic digest depends on the server. + *

+ * The divided archive paths on files.wwpdb.org and files.rcsb.org return the content + * MD5 as the ETag. The flat /validation/download/ endpoint this provider now uses + * returns neither an ETag nor a Content-Length, and neither does the beta archive on + * those two hosts, so no digest can be recorded there. The size sidecar is written + * from the bytes actually read, so it exists either way. + *

+ * The digest is therefore asserted when the server offered one and skipped when it + * did not, rather than being required: requiring it would fail against the endpoint + * we use, and hard-coding the divided path would only work until the archive + * transition in July 2027. + */ + @Test + public void mapCoefficientsArriveIntactAndVerifiable() throws IOException { + cache.setSourceEnabled(DensityMapSource.WWPDB_MAP_COEFFICIENTS, true); + cache.setSourceChain(DensityMapKind.TWO_FO_FC, Arrays.asList(DensityMapSource.WWPDB_MAP_COEFFICIENTS)); + + DensityMapResult result = cache.getDensityMap(DensityMapRequest.builder(new PdbId("1cbs")) + .kind(DensityMapKind.TWO_FO_FC) + .allowNonRenderableFormats(true) + .build()); + + assertEquals(DensityMapSource.WWPDB_MAP_COEFFICIENTS, result.getSource()); + assertFalse(result.isRenderable(), + "structure factors are not a map and must not claim to be renderable"); + + // written from the observed byte count, so it is present whether or not the + // server declared a length + assertTrue(FileDownloadUtils.validateFile(result.getFile()), + "a freshly downloaded file must validate against its own sidecars"); + + File hashFile = new File(result.getFile().getParentFile(), result.getFile().getName() + ".hash_MD5"); + if (hashFile.isFile()) { + String recorded = new String(Files.readAllBytes(hashFile.toPath()), StandardCharsets.UTF_8).trim(); + assertTrue(FileDownloadUtils.verifyHash(result.getFile(), FileDownloadUtils.Hash.MD5, recorded), + "the recorded MD5 must match the file it describes"); + } else { + System.out.println("No MD5 recorded for " + result.getSourceUrl() + + " - the server offered no usable ETag. Size validation still applies."); + } + + // corrupt it and confirm validation actually catches it + Files.write(result.getFile().toPath(), new byte[] {0, 1, 2, 3}); + assertFalse(FileDownloadUtils.validateFile(result.getFile()), + "a truncated file must not validate"); + } +} diff --git a/biojava-modfinder/pom.xml b/biojava-modfinder/pom.xml index 876203f207..343aa74f6c 100644 --- a/biojava-modfinder/pom.xml +++ b/biojava-modfinder/pom.xml @@ -4,7 +4,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-modfinder biojava-modfinder @@ -31,7 +31,7 @@ org.biojava biojava-structure - 7.2.3 + 7.3.0-SNAPSHOT jar compile diff --git a/biojava-modfinder/src/main/java/org/biojava/nbio/protmod/io/ModifiedCompoundXMLConverter.java b/biojava-modfinder/src/main/java/org/biojava/nbio/protmod/io/ModifiedCompoundXMLConverter.java index 187e113924..e038f57cd3 100644 --- a/biojava-modfinder/src/main/java/org/biojava/nbio/protmod/io/ModifiedCompoundXMLConverter.java +++ b/biojava-modfinder/src/main/java/org/biojava/nbio/protmod/io/ModifiedCompoundXMLConverter.java @@ -69,7 +69,7 @@ public static String toXML(ModifiedCompound mc) throws IOException{ Set linkages = mc.getAtomLinkages(); - if ( linkages.size() > 0 ) { + if (!linkages.isEmpty()) { int pos = -1; for ( StructureAtomLinkage link: linkages){ pos ++; diff --git a/biojava-modfinder/src/main/java/org/biojava/nbio/protmod/structure/ProteinModificationIdentifier.java b/biojava-modfinder/src/main/java/org/biojava/nbio/protmod/structure/ProteinModificationIdentifier.java index c9575a5444..0d94d36e6e 100644 --- a/biojava-modfinder/src/main/java/org/biojava/nbio/protmod/structure/ProteinModificationIdentifier.java +++ b/biojava-modfinder/src/main/java/org/biojava/nbio/protmod/structure/ProteinModificationIdentifier.java @@ -285,7 +285,7 @@ public void identify(final List chains, if (residues.isEmpty()) { String pdbId = "?"; - if ( chains.size() > 0) { + if (!chains.isEmpty()) { Structure struc = chains.get(0).getStructure(); if ( struc != null) pdbId = struc.getPDBCode(); diff --git a/biojava-modfinder/src/test/java/org/biojava/nbio/protmod/phosphosite/TestAcetylation.java b/biojava-modfinder/src/test/java/org/biojava/nbio/protmod/phosphosite/TestAcetylation.java index 34376307a0..ba8e6d2a3d 100644 --- a/biojava-modfinder/src/test/java/org/biojava/nbio/protmod/phosphosite/TestAcetylation.java +++ b/biojava-modfinder/src/test/java/org/biojava/nbio/protmod/phosphosite/TestAcetylation.java @@ -32,7 +32,8 @@ import java.net.URL; import java.util.List; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; @@ -100,11 +101,11 @@ public void testAcetylation() throws IOException { List sites = Site.parseSites(localFile); - assertTrue(sites.size() > 0); + assertFalse(sites.isEmpty()); for (Site s : sites) { - assertTrue(s.getResidue() != null); + assertNotNull(s.getResidue()); } diff --git a/biojava-ontology/pom.xml b/biojava-ontology/pom.xml index 02e4a894d2..efc24f1290 100644 --- a/biojava-ontology/pom.xml +++ b/biojava-ontology/pom.xml @@ -4,7 +4,7 @@ org.biojava biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-ontology diff --git a/biojava-ontology/src/main/java/org/biojava/nbio/ontology/utils/WeakValueHashMap.java b/biojava-ontology/src/main/java/org/biojava/nbio/ontology/utils/WeakValueHashMap.java index 7762642c76..c508cdc206 100644 --- a/biojava-ontology/src/main/java/org/biojava/nbio/ontology/utils/WeakValueHashMap.java +++ b/biojava-ontology/src/main/java/org/biojava/nbio/ontology/utils/WeakValueHashMap.java @@ -58,12 +58,12 @@ public WeakValueHashMap() { private void diddleReferenceQueue() { // Avoid making behind-the-scenes modifications while iterators exist. - if (iteratorRefs.size() > 0) { + if (!iteratorRefs.isEmpty()) { Reference ref; while ((ref = iteratorRefQueue.poll()) != null) { iteratorRefs.remove(ref); } - if (iteratorRefs.size() > 0) { + if (!iteratorRefs.isEmpty()) { return; } } diff --git a/biojava-protein-comparison-tool/pom.xml b/biojava-protein-comparison-tool/pom.xml index b4de1b1e8a..8f6fb7f420 100644 --- a/biojava-protein-comparison-tool/pom.xml +++ b/biojava-protein-comparison-tool/pom.xml @@ -4,7 +4,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-protein-comparison-tool @@ -36,23 +36,23 @@ org.biojava biojava-alignment - 7.2.3 + 7.3.0-SNAPSHOT org.biojava biojava-core - 7.2.3 + 7.3.0-SNAPSHOT org.biojava biojava-structure - 7.2.3 + 7.3.0-SNAPSHOT org.biojava biojava-structure-gui - 7.2.3 + 7.3.0-SNAPSHOT net.sourceforge.jmol diff --git a/biojava-protein-disorder/pom.xml b/biojava-protein-disorder/pom.xml index d5e216b60d..434f6d3f18 100644 --- a/biojava-protein-disorder/pom.xml +++ b/biojava-protein-disorder/pom.xml @@ -3,7 +3,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-protein-disorder jar @@ -63,7 +63,7 @@ org.biojava biojava-core - 7.2.3 + 7.3.0-SNAPSHOT diff --git a/biojava-structure-gui/pom.xml b/biojava-structure-gui/pom.xml index 62db686d60..14a38eb37f 100644 --- a/biojava-structure-gui/pom.xml +++ b/biojava-structure-gui/pom.xml @@ -3,7 +3,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT 4.0.0 biojava-structure-gui @@ -27,13 +27,13 @@ org.biojava biojava-structure - 7.2.3 + 7.3.0-SNAPSHOT compile org.biojava biojava-core - 7.2.3 + 7.3.0-SNAPSHOT compile diff --git a/biojava-structure-gui/src/main/java/demo/DemoShowElectronDensity.java b/biojava-structure-gui/src/main/java/demo/DemoShowElectronDensity.java new file mode 100644 index 0000000000..3948605744 --- /dev/null +++ b/biojava-structure-gui/src/main/java/demo/DemoShowElectronDensity.java @@ -0,0 +1,80 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package demo; + +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.Structure; +import org.biojava.nbio.structure.StructureIO; +import org.biojava.nbio.structure.align.gui.jmol.StructureAlignmentJmol; +import org.biojava.nbio.structure.io.density.DensityMapCache; +import org.biojava.nbio.structure.io.density.DensityMapKind; +import org.biojava.nbio.structure.io.density.DensityMapResult; + +/** + * Shows 1CBS with its electron density drawn around the bound retinoic acid. + *

+ * Both maps are displayed: the 2mFo-DFc map in blue at 1 sigma, which should hug + * the ligand closely, and the mFo-DFc difference map as a red and green pair at + * 3 sigma, which for a well refined structure should show very little. + *

+ * Once the window is up, the map can be manipulated from the Rasmol command box + * at the bottom, for instance + *

+ * isosurface ID "bj_density_2fofc" delete
+ * 
+ * to remove just the blue surface. Pressing Reset Display keeps the maps, since + * they are folded into the saved state when they are drawn. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class DemoShowElectronDensity { + + /** + * @param args an optional PDB ID to display instead of the default + * @throws Exception if the structure or the map could not be fetched + */ + public static void main(String[] args) throws Exception { + String id = args.length > 0 ? args[0] : "1cbs"; + + Structure structure = StructureIO.getStructure(id); + StructureAlignmentJmol viewer = new StructureAlignmentJmol(); + viewer.setStructure(structure); + viewer.evalString("select all; cartoon on; color chain; " + + "select ligand; wireframe 0.16; spacefill 0.4; color cpk;"); + + DensityMapCache cache = new DensityMapCache(); + System.out.println("Density cache: " + cache.getCachePath()); + + // Clipping to the ligand keeps the surface readable and, for a large map, + // keeps the contouring quick enough not to stall the interface. + for (DensityMapKind kind : new DensityMapKind[] {DensityMapKind.TWO_FO_FC, DensityMapKind.FO_FC}) { + cache.findDensityMap(new PdbId(id), kind).ifPresent(map -> { + System.out.printf("%-8s from %-20s %s (%,d bytes)%n", + map.getKind(), map.getSource(), map.getFile().getName(), map.getFileSizeBytes()); + viewer.getJmolPanel().loadDensityMap(map, "{ligand}", 5.0); + }); + } + + DensityMapResult any = cache.findDensityMap(new PdbId(id), DensityMapKind.AUTO).orElse(null); + if (any == null) { + System.out.println("No density is available for " + id + + " - try an entry with deposited structure factors, such as 1cbs."); + } + } +} diff --git a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/MenuCreator.java b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/MenuCreator.java index 6a0e3c6e1a..197802a2c5 100644 --- a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/MenuCreator.java +++ b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/MenuCreator.java @@ -66,6 +66,8 @@ public class MenuCreator { public static final String PAIRWISE_ALIGN = "New Pairwise Alignment"; public static final String MULTIPLE_ALIGN = "New Multiple Alignment"; public static final String PHYLOGENETIC_TREE = "Phylogenetic Tree"; + /** @since 7.3.0 */ + public static final String SHOW_DENSITY = "Show Electron Density"; protected static final int keyMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask(); @@ -169,6 +171,8 @@ public static JMenuBar initJmolMenu(JFrame frame, distMax.setMnemonic(KeyEvent.VK_D); distMax.addActionListener(new MyDistMaxListener(parent)); view.add(distMax); + //Electron density + view.add(getShowDensityMenuItem(parent)); //Dot Plot - only if the alignment was an afpChain if (afpChain != null){ JMenuItem dotplot = new JMenuItem(DOT_PLOT); @@ -229,6 +233,22 @@ public static JMenuItem getOpenPDBMenuItem() { return openI; } + /** + * Menu item that fetches and displays the electron density or cryo-EM map for + * the structure currently on screen. + * + * @param parent the viewer to draw the map into + * @return the menu item + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static JMenuItem getShowDensityMenuItem(AbstractAlignmentJmol parent) { + JMenuItem densityI = new JMenuItem(SHOW_DENSITY); + densityI.setMnemonic(KeyEvent.VK_E); + densityI.addActionListener(new MyShowDensityListener(parent)); + return densityI; + } + public static JMenuItem getLoadMenuItem() { diff --git a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/MyShowDensityListener.java b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/MyShowDensityListener.java new file mode 100644 index 0000000000..b1a9cf23ff --- /dev/null +++ b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/MyShowDensityListener.java @@ -0,0 +1,202 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.align.gui; + +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import javax.swing.JOptionPane; +import javax.swing.SwingWorker; + +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.Structure; +import org.biojava.nbio.structure.align.gui.jmol.AbstractAlignmentJmol; +import org.biojava.nbio.structure.io.density.DensityMapCache; +import org.biojava.nbio.structure.io.density.DensityMapKind; +import org.biojava.nbio.structure.io.density.DensityMapRequest; +import org.biojava.nbio.structure.io.density.DensityMapResult; +import org.biojava.nbio.structure.io.density.DensityMapSource; +import org.biojava.nbio.structure.io.density.NoDensityMapException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Fetches the density map for the structure on screen and draws it. + *

+ * The download happens on a {@link SwingWorker} rather than the event dispatch + * thread. Even the smallest source runs to a few hundred kilobytes and a + * full-resolution map can be far larger, so fetching inline would freeze the + * interface for as long as it took. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class MyShowDensityListener implements ActionListener { + + private static final Logger logger = LoggerFactory.getLogger(MyShowDensityListener.class); + + private static final String OPTION_2FOFC = "2Fo-Fc (electron density)"; + private static final String OPTION_FOFC = "Fo-Fc (difference)"; + private static final String OPTION_BOTH = "Both"; + private static final String OPTION_AUTO = "Whatever is available"; + + private final AbstractAlignmentJmol parent; + + /** + * @param parent the viewer to draw into + */ + public MyShowDensityListener(AbstractAlignmentJmol parent) { + this.parent = parent; + } + + @Override + public void actionPerformed(ActionEvent e) { + Structure structure = parent == null ? null : parent.getStructure(); + if (structure == null) { + JOptionPane.showMessageDialog(parent == null ? null : parent.getFrame(), + "There is no structure on screen to fetch a density map for.", + "Show Electron Density", JOptionPane.INFORMATION_MESSAGE); + return; + } + + PdbId pdbId = structure.getPdbId(); + if (pdbId == null) { + String typed = JOptionPane.showInputDialog(parent.getFrame(), + "This structure has no PDB ID. Enter one to look up its density map:", + "Show Electron Density", JOptionPane.QUESTION_MESSAGE); + if (typed == null || typed.trim().isEmpty()) { + return; + } + try { + pdbId = new PdbId(typed.trim()); + } catch (IllegalArgumentException ex) { + JOptionPane.showMessageDialog(parent.getFrame(), typed + " is not a valid PDB ID.", + "Show Electron Density", JOptionPane.ERROR_MESSAGE); + return; + } + } + + Object[] options = {OPTION_2FOFC, OPTION_FOFC, OPTION_BOTH, OPTION_AUTO}; + Object choice = JOptionPane.showInputDialog(parent.getFrame(), + "Which map would you like to see for " + pdbId.getId() + "?", + "Show Electron Density", JOptionPane.QUESTION_MESSAGE, null, options, OPTION_2FOFC); + if (choice == null) { + return; + } + + List kinds = new ArrayList<>(2); + if (OPTION_BOTH.equals(choice)) { + kinds.add(DensityMapKind.TWO_FO_FC); + kinds.add(DensityMapKind.FO_FC); + } else if (OPTION_FOFC.equals(choice)) { + kinds.add(DensityMapKind.FO_FC); + } else if (OPTION_AUTO.equals(choice)) { + kinds.add(DensityMapKind.AUTO); + } else { + kinds.add(DensityMapKind.TWO_FO_FC); + } + + fetchAndShow(pdbId, kinds); + } + + private void fetchAndShow(PdbId pdbId, List kinds) { + parent.setStatus("Fetching density map for " + pdbId.getId() + " ..."); + + new SwingWorker, Void>() { + + private NoDensityMapException missing; + + @Override + protected List doInBackground() throws Exception { + DensityMapCache cache = DensityMapCache.getInstance(); + List results = new ArrayList<>(kinds.size()); + for (DensityMapKind kind : kinds) { + try { + // Restricting to displayable formats also excludes the map + // coefficient source automatically. + results.add(cache.getDensityMap(DensityMapRequest.builder(pdbId) + .kind(kind) + .allowNonRenderableFormats(false) + .build())); + } catch (NoDensityMapException ex) { + missing = ex; + } + } + return results; + } + + @Override + protected void done() { + List results; + try { + results = get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return; + } catch (ExecutionException ex) { + logger.error("Could not fetch a density map for {}", pdbId.getId(), ex.getCause()); + parent.setStatus("Could not fetch density map"); + JOptionPane.showMessageDialog(parent.getFrame(), + "Could not fetch the density map:\n" + ex.getCause().getMessage(), + "Show Electron Density", JOptionPane.ERROR_MESSAGE); + return; + } + + for (DensityMapResult result : results) { + parent.getJmolPanel().loadDensityMap(result); + } + + if (results.isEmpty()) { + parent.setStatus("No density map available"); + JOptionPane.showMessageDialog(parent.getFrame(), explain(pdbId, missing), + "Show Electron Density", JOptionPane.INFORMATION_MESSAGE); + } else { + DensityMapResult first = results.get(0); + parent.setStatus(String.format("Density from %s (%,d kB)", + first.getSource(), first.getFileSizeBytes() / 1024)); + } + } + }.execute(); + } + + /** + * Turns the per-source reasons into something a user can act on, rather than a + * list of HTTP codes. + */ + private static String explain(PdbId pdbId, NoDensityMapException missing) { + StringBuilder sb = new StringBuilder("No density map is available for ") + .append(pdbId.getId()).append(".\n\n"); + if (missing == null) { + return sb.toString(); + } + Map attempts = missing.getAttempts(); + boolean allNotFound = !attempts.isEmpty() && attempts.values().stream() + .allMatch(reason -> reason.startsWith("HTTP 404") || reason.startsWith("no ")); + if (allNotFound) { + sb.append("The most likely reason is that no structure factors were deposited\n") + .append("for this entry, and that it has no associated EMDB map.\n\n"); + } + sb.append("Sources tried:\n"); + attempts.forEach((source, reason) -> sb.append(" ").append(source).append(": ").append(reason).append('\n')); + return sb.toString(); + } +} diff --git a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/autosuggest/JAutoSuggest.java b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/autosuggest/JAutoSuggest.java index 9e3c825910..c5542b4edc 100644 --- a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/autosuggest/JAutoSuggest.java +++ b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/autosuggest/JAutoSuggest.java @@ -229,7 +229,7 @@ public void keyReleased(KeyEvent e) { list.ensureIndexIsVisible(list.getSelectedIndex() - 1); return; } else if (e.getKeyCode() == KeyEvent.VK_ENTER - && list.getSelectedIndex() != -1 && suggestions.size() > 0) { + && list.getSelectedIndex() != -1 && !suggestions.isEmpty()) { setText((String) list.getSelectedValue()); @@ -365,7 +365,7 @@ public String doInBackground() { setFont(regular); - if (suggestions.size() > 0) { + if (!suggestions.isEmpty()) { list.setListData(suggestions); list.setSelectedIndex(0); list.ensureIndexIsVisible(0); diff --git a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/autosuggest/SCOPAutoSuggestProvider.java b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/autosuggest/SCOPAutoSuggestProvider.java index 136f184584..8e1a975205 100644 --- a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/autosuggest/SCOPAutoSuggestProvider.java +++ b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/autosuggest/SCOPAutoSuggestProvider.java @@ -111,7 +111,7 @@ private List getPossibleScopDomains(String userInput) { if ( stop.get()) return domains; - if ( domains == null || domains.size() < 1){ + if ( domains == null || domains.isEmpty()){ if ( userInput.length() > 5){ // e.g. d4hhba @@ -127,11 +127,11 @@ private List getPossibleScopDomains(String userInput) { if (DEBUG) System.out.println("domains: " + domains); - if ( domains == null || domains.size() < 1) { + if ( domains == null || domains.isEmpty()) { if ( userInput.length() > 0 ){ List descs = scop.filterByClassificationId(userInput); - if ( descs == null || descs.size() < 1){ + if ( descs == null || descs.isEmpty()){ descs = scop.filterByDescription(userInput); } diff --git a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/AbstractAlignmentJmol.java b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/AbstractAlignmentJmol.java index 535ad182fe..86be61d8fc 100644 --- a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/AbstractAlignmentJmol.java +++ b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/AbstractAlignmentJmol.java @@ -194,6 +194,29 @@ public Structure getStructure(){ */ public abstract List getDistanceMatrices(); + /** + * The window this viewer lives in, for use as the owner of dialogs raised from + * outside this package. + * + * @return the frame, which may be null before the window is built + * @since 7.3.0 + */ + public JFrame getFrame() { + return frame; + } + + /** + * Writes a short message into the viewer's status field. + * + * @param message the message; ignored if there is no status field yet + * @since 7.3.0 + */ + public void setStatus(String message) { + if (status != null) { + status.setText(message); + } + } + /** * Set the title of the AlignmentJmol window. * @param title diff --git a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/JmolPanel.java b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/JmolPanel.java index 23361e62ed..9108489d29 100644 --- a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/JmolPanel.java +++ b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/JmolPanel.java @@ -31,12 +31,14 @@ import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.io.BufferedInputStream; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; import java.text.DecimalFormat; import java.util.List; +import java.util.Locale; import javax.swing.JComboBox; @@ -50,6 +52,8 @@ import org.biojava.nbio.structure.domain.pdp.Domain; import org.biojava.nbio.structure.domain.pdp.Segment; import org.biojava.nbio.structure.gui.util.color.ColorUtils; +import org.biojava.nbio.structure.io.density.DensityMapKind; +import org.biojava.nbio.structure.io.density.DensityMapResult; import org.biojava.nbio.structure.io.mmtf.MmtfActions; import org.biojava.nbio.structure.jama.Matrix; import org.biojava.nbio.structure.scop.ScopDatabase; @@ -166,6 +170,157 @@ public void setStructure(final Structure s) { setStructure(s, false); } + /** Isosurface id used for the 2mFo-DFc map, so that it can be addressed on its own. */ + public static final String ISOSURFACE_ID_2FOFC = "bj_density_2fofc"; + + /** Isosurface id used for the mFo-DFc difference map, drawn as a signed pair of lobes. */ + public static final String ISOSURFACE_ID_FOFC = "bj_density_fofc"; + + /** Isosurface id used for a cryo-EM map. */ + public static final String ISOSURFACE_ID_EM = "bj_density_em"; + + /** Default clipping radius, in Angstroms, around the selected atoms. */ + public static final double DEFAULT_WITHIN_RADIUS = 5.0; + + /** + * Displays a density map fetched through + * {@link org.biojava.nbio.structure.io.density.DensityMapCache}, clipped to + * {@value #DEFAULT_WITHIN_RADIUS} Angstroms around the whole model. + * + * @param map the map to display + * @throws IllegalArgumentException if the map cannot be displayed without a + * Fourier transform first + * @since 7.3.0 + */ + public void loadDensityMap(DensityMapResult map) { + loadDensityMap(map, "{*}", DEFAULT_WITHIN_RADIUS); + } + + /** + * Displays a density map, clipped to a distance around a selection. + *

+ * Contouring follows the convention for each kind of map: the 2mFo-DFc map at + * 1 sigma in blue, the mFo-DFc difference map as a signed red/green pair at 3 + * sigma, and a cryo-EM map at the level its depositors recommend when the entry + * states one, falling back to 3 sigma when it does not. + *

+ * Clipping matters for more than tidiness: contouring a whole cryo-EM grid at + * mesh resolution can take long enough to make the interface appear frozen. + * + * @param map the map to display + * @param atomSelection a Jmol atom expression such as {*} or + * {ligand}, or null to contour the whole cell + * @param withinRadius the clipping radius in Angstroms, ignored when + * atomSelection is null + * @throws IllegalArgumentException if the map cannot be displayed without a + * Fourier transform first + * @since 7.3.0 + */ + public void loadDensityMap(DensityMapResult map, String atomSelection, double withinRadius) { + if (!map.isRenderable()) { + throw new IllegalArgumentException("A " + map.getFormat() + " file holds structure factors, not a " + + "sampled map, and cannot be displayed as it stands. Convert it first, for example with " + + "'gemmi sf2map', or fetch the map from a source that serves a grid."); + } + + Double level = map.getRecommendedContourLevel(); + if (map.getKind() == DensityMapKind.EM && level != null) { + // EM maps are conventionally contoured at the absolute level the + // depositors chose rather than at a multiple of sigma. + loadDensityMap(map.getFile(), map.getKind(), level, false, atomSelection, withinRadius); + } else { + double sigma = map.getKind() == DensityMapKind.TWO_FO_FC ? 1.0 : 3.0; + loadDensityMap(map.getFile(), map.getKind(), sigma, true, atomSelection, withinRadius); + } + } + + /** + * Displays a density map file directly. + * + * @param mapFile the map file, in a format Jmol can contour (CCP4/MRC, or a + * BinaryCIF volume) + * @param kind what the map values mean, which decides the colouring and whether + * a signed pair of surfaces is drawn + * @param level the contour level + * @param levelIsSigma whether level is a multiple of the map's RMS + * deviation rather than an absolute value + * @param atomSelection a Jmol atom expression, or null to contour + * the whole cell + * @param withinRadius the clipping radius in Angstroms + * @since 7.3.0 + */ + public void loadDensityMap(File mapFile, DensityMapKind kind, double level, boolean levelIsSigma, + String atomSelection, double withinRadius) { + + String url = toJmolFileUrl(mapFile); + String within = atomSelection == null ? "" + : String.format(Locale.US, " within %.1f %s", withinRadius, atomSelection); + + if (kind == DensityMapKind.FO_FC) { + // One signed surface carrying both lobes, red for negative and green for + // positive. Drawing the negative lobe as a separate surface at "sigma -3" + // does not work: Jmol gives a negative sigma its own internal meaning and + // silently contours at the default level instead. + evalString(isosurfaceCommand(ISOSURFACE_ID_FOFC, "sign red green", level, levelIsSigma, within, url)); + } else if (kind == DensityMapKind.EM) { + evalString(isosurfaceCommand(ISOSURFACE_ID_EM, "color grey", level, levelIsSigma, within, url)); + } else { + evalString(isosurfaceCommand(ISOSURFACE_ID_2FOFC, "color blue", level, levelIsSigma, within, url)); + } + + // resetDisplay() restores "state_1", which is saved when the structure is + // loaded. Without re-saving here, pressing Reset Display would silently + // discard the map the user just asked for. + evalString("save STATE state_1"); + } + + /** + * Builds an isosurface command. + *

+ * The option order is not a matter of taste: mesh and + * nofill have to follow the file name. Placed before it, Jmol + * accepts the command without complaint and draws nothing at all. + */ + private static String isosurfaceCommand(String id, String colouring, double level, boolean levelIsSigma, + String within, String url) { + return String.format(Locale.US, + "isosurface ID \"%s\" delete; isosurface ID \"%s\" %s %s %.4f%s \"%s\" mesh nofill;", + id, id, colouring, levelIsSigma ? "sigma" : "cutoff", level, within, url); + } + + /** + * Removes any density surfaces this panel has drawn, leaving other isosurfaces + * alone. + * + * @since 7.3.0 + */ + public void clearDensityMaps() { + for (String id : new String[] {ISOSURFACE_ID_2FOFC, ISOSURFACE_ID_FOFC, ISOSURFACE_ID_EM}) { + evalString("isosurface ID \"" + id + "\" delete;"); + } + evalString("save STATE state_1"); + } + + /** + * Converts a file to the URL form Jmol expects. + *

+ * Going through {@link File#toURI()} percent-encodes spaces and removes + * backslashes, which Jmol would otherwise read as escape characters in a script + * string. On Windows the result is a single-slash file:/C:/..., + * which is normalised here to the usual three-slash form. + * + * @param file the file + * @return a URL string safe to embed in a Jmol script + * @since 7.3.0 + */ + public static String toJmolFileUrl(File file) { + String url = file.getAbsoluteFile().toURI().toString(); + if (url.startsWith("file:/") && !url.startsWith("file://")) { + url = "file:///" + url.substring("file:/".length()); + } + return url; + } + /** assign a custom color to the Jmol chains command. * */ diff --git a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/RasmolCommandListener.java b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/RasmolCommandListener.java index d1ce5c0e30..feec8b0366 100644 --- a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/RasmolCommandListener.java +++ b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/align/gui/jmol/RasmolCommandListener.java @@ -74,7 +74,7 @@ public void actionPerformed(ActionEvent event) { // check last command in history // if equivalent, don't add, // otherwise add - if (history.size()>0){ + if (!history.isEmpty()){ String txt=history.get(history.size()-1); if (! txt.equals(cmd)) { history.add(cmd); diff --git a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/gui/util/SequenceScalePanel.java b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/gui/util/SequenceScalePanel.java index 06542e5271..75da6c8e28 100644 --- a/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/gui/util/SequenceScalePanel.java +++ b/biojava-structure-gui/src/main/java/org/biojava/nbio/structure/gui/util/SequenceScalePanel.java @@ -126,7 +126,7 @@ private void setPrefSize() { public void setAligMap(List apos){ this.apos = apos; - if ( apos.size() == 0) + if (apos.isEmpty()) return; AlignedPosition last = apos.get(apos.size()-1); diff --git a/biojava-structure/pom.xml b/biojava-structure/pom.xml index aeff7465c0..e3b5163341 100644 --- a/biojava-structure/pom.xml +++ b/biojava-structure/pom.xml @@ -4,7 +4,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-structure biojava-structure @@ -51,13 +51,13 @@ org.biojava biojava-alignment - 7.2.3 + 7.3.0-SNAPSHOT compile org.biojava biojava-core - 7.2.3 + 7.3.0-SNAPSHOT compile @@ -87,7 +87,7 @@ com.fasterxml.jackson.core jackson-databind - 2.13.4.2 + 2.18.9 diff --git a/biojava-structure/src/main/java/demo/DemoFetchElectronDensity.java b/biojava-structure/src/main/java/demo/DemoFetchElectronDensity.java new file mode 100644 index 0000000000..f987fd7cfe --- /dev/null +++ b/biojava-structure/src/main/java/demo/DemoFetchElectronDensity.java @@ -0,0 +1,90 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package demo; + +import java.io.IOException; + +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.io.density.DensityMapCache; +import org.biojava.nbio.structure.io.density.DensityMapKind; +import org.biojava.nbio.structure.io.density.DensityMapResult; +import org.biojava.nbio.structure.io.density.NoDensityMapException; + +/** + * Fetches electron density and cryo-EM maps, printing which source answered. + *

+ * The three entries chosen exercise the three outcomes the fallback chain has to + * handle: + *

    + *
  • 1cbs — an X-ray structure with deposited structure factors, + * served by the first source tried.
  • + *
  • 6hu9 — a cryo-EM structure. Every X-ray source has nothing for + * it, so the chain resolves the associated EMDB entry instead and picks up the + * author-recommended contour level along the way.
  • + *
  • 4hhb — deposited in 1984 without structure factors, so no + * source has anything. This is a normal outcome, not an error, and the exception + * says which sources were tried and why each declined.
  • + *
+ * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class DemoFetchElectronDensity { + + /** + * @param args ignored + * @throws IOException if a server could not be reached at all + */ + public static void main(String[] args) throws IOException { + DensityMapCache cache = new DensityMapCache(); + System.out.println("Caching under: " + cache.getCachePath()); + System.out.println(); + + show(cache, "1cbs", DensityMapKind.TWO_FO_FC); + show(cache, "1cbs", DensityMapKind.FO_FC); + show(cache, "6hu9", DensityMapKind.AUTO); + show(cache, "4hhb", DensityMapKind.AUTO); + } + + private static void show(DensityMapCache cache, String id, DensityMapKind kind) throws IOException { + System.out.printf("%s (%s)%n", id, kind); + try { + DensityMapResult result = cache.getDensityMap(new PdbId(id), kind); + System.out.printf(" source : %s%n", result.getSource()); + System.out.printf(" format : %s%s%n", result.getFormat(), + result.isRenderable() ? "" : " (needs an FFT before display)"); + System.out.printf(" kind : %s%n", result.getKind()); + System.out.printf(" file : %s (%,d bytes)%n", result.getFile(), result.getFileSizeBytes()); + System.out.printf(" cached : %s%n", result.isFromCache()); + if (result.getEmdbId() != null) { + System.out.printf(" EMDB : %s%n", result.getEmdbId()); + } + if (result.getRecommendedContourLevel() != null) { + System.out.printf(" contour : %s (author recommended)%n", result.getRecommendedContourLevel()); + } + if (result.getContourInSigma() != null) { + System.out.printf(" in sigma : %.2f%n", result.getContourInSigma()); + } + } catch (NoDensityMapException e) { + System.out.printf(" no map available%n"); + e.getAttempts().forEach((source, reason) -> + System.out.printf(" %-24s %s%n", source, reason)); + } + System.out.println(); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/Author.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/Author.java index b0d7253507..bd5a01b885 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/Author.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/Author.java @@ -62,7 +62,7 @@ public boolean equals(Object obj) { if ((this.surname == null) ? (other.surname != null) : !this.surname.equals(other.surname)) { return false; } - return !((this.initials == null) ? (other.initials != null) : !this.initials.equals(other.initials)); + return (this.initials == null) ? other.initials == null : this.initials.equals(other.initials); } @Override diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/Element.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/Element.java index 2f534b2828..4e2d3e340a 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/Element.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/Element.java @@ -424,7 +424,7 @@ public boolean isHeavyAtom() { * @return true if Element is not Hydrogen and not Carbon. */ public boolean isHeteroAtom() { - return !(this == C || this == H); + return this != C && this != H; } /** diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/Site.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/Site.java index 9158906d23..341483f31b 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/Site.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/Site.java @@ -83,7 +83,7 @@ public String toPDB() { @Override public void toPDB(StringBuffer buf) { - if (groups == null || groups.size() < 1) { + if (groups == null || groups.isEmpty()) { return; } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ClusterAltAligs.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ClusterAltAligs.java index 373bcf1611..0933198d7b 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ClusterAltAligs.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ClusterAltAligs.java @@ -102,7 +102,7 @@ public static void cluster(AlternativeAlignment[] aligs, int cutoff){ } clusters.add(currentCluster); - if ( remainList.size() == 0) { + if ( remainList.isEmpty()) { break; } } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ce/CECalculator.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ce/CECalculator.java index 6c045ba48e..83f16b7982 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ce/CECalculator.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ce/CECalculator.java @@ -1450,7 +1450,7 @@ private int optimizeSuperposition(AFPChain afpChain, int nse1, int nse2, int str //afpChain.setTotalRmsdOpt(rmsd); //System.out.println("rmsd: " + rmsd); - if(!(nAtom= strLen * 0.95 && !isRmsdLenAssigned) { rmsdLen=rmsd; isRmsdLenAssigned=true; } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ce/CeCalculatorEnhanced.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ce/CeCalculatorEnhanced.java index 4f57161268..cab98b0113 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ce/CeCalculatorEnhanced.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/ce/CeCalculatorEnhanced.java @@ -1455,7 +1455,7 @@ private int optimizeSuperposition(AFPChain afpChain, int nse1, int nse2, int str //afpChain.setTotalRmsdOpt(rmsd); //System.out.println("rmsd: " + rmsd); - if(!(nAtom= strLen * 0.95 && !isRmsdLenAssigned) { rmsdLen=rmsd; isRmsdLenAssigned=true; } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/BlockImpl.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/BlockImpl.java index e0423b6f8f..43da1d7c06 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/BlockImpl.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/BlockImpl.java @@ -127,7 +127,7 @@ public void setAlignRes(List> alignRes) { public int length() { if (alignRes == null) return 0; - if (alignRes.size() == 0) + if (alignRes.isEmpty()) return 0; return alignRes.get(0).size(); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/BlockSetImpl.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/BlockSetImpl.java index cbbb3ae895..344ee3c239 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/BlockSetImpl.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/BlockSetImpl.java @@ -179,7 +179,7 @@ public int size() { // Get the size from the variables that can contain the information if (parent != null) return parent.size(); - else if (getBlocks().size() == 0) { + else if (getBlocks().isEmpty()) { throw new IndexOutOfBoundsException( "Empty BlockSet: number of Blocks == 0."); } else @@ -194,7 +194,7 @@ public int getCoreLength() { } protected void updateLength() { - if (getBlocks().size() == 0) { + if (getBlocks().isEmpty()) { throw new IndexOutOfBoundsException( "Empty BlockSet: number of Blocks == 0."); } @@ -207,7 +207,7 @@ protected void updateLength() { } protected void updateCoreLength() { - if (getBlocks().size() == 0) { + if (getBlocks().isEmpty()) { throw new IndexOutOfBoundsException( "Empty BlockSet: number of Blocks == 0."); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/MultipleAlignmentImpl.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/MultipleAlignmentImpl.java index 738eee30c5..06c93a4403 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/MultipleAlignmentImpl.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/MultipleAlignmentImpl.java @@ -207,7 +207,7 @@ public int getCoreLength() { * lengths. */ protected void updateLength() { - if (getBlockSets().size() == 0) { + if (getBlockSets().isEmpty()) { throw new IndexOutOfBoundsException( "Empty MultipleAlignment: blockSets size == 0."); } // Otherwise try to calculate it from the BlockSet information @@ -223,7 +223,7 @@ protected void updateLength() { * BlockSet core lengths. */ protected void updateCoreLength() { - if (getBlockSets().size() == 0) { + if (getBlockSets().isEmpty()) { throw new IndexOutOfBoundsException( "Empty MultipleAlignment: blockSets size == 0."); } // Otherwise try to calculate it from the BlockSet information diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/mc/MultipleMcOptimizer.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/mc/MultipleMcOptimizer.java index 052f147fc6..29c7012801 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/mc/MultipleMcOptimizer.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/mc/MultipleMcOptimizer.java @@ -153,7 +153,7 @@ public MultipleMcOptimizer(MultipleAlignment seedAln, for (Block b : toDelete) { for (BlockSet bs : msa.getBlockSets()) { bs.getBlocks().remove(b); - if (bs.getBlocks().size() == 0) + if (bs.getBlocks().isEmpty()) emptyBs.add(bs); } } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/util/MultipleAlignmentWriter.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/util/MultipleAlignmentWriter.java index 771b8b5f68..5033576df0 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/util/MultipleAlignmentWriter.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/multiple/util/MultipleAlignmentWriter.java @@ -205,7 +205,7 @@ public static String toTransformMatrices(MultipleAlignment alignment) { List btransforms = alignment.getBlockSet(bs) .getTransformations(); - if (btransforms == null || btransforms.size() < 1) + if (btransforms == null || btransforms.isEmpty()) continue; if (alignment.getBlockSets().size() > 1) { diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/quaternary/QsAlignResult.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/quaternary/QsAlignResult.java index 7ac77a602e..fe1c9c411b 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/quaternary/QsAlignResult.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/quaternary/QsAlignResult.java @@ -117,7 +117,7 @@ public void setSubunitMap(Map subunitMap) { "Subunit Map index higher than Subunit List size."); // Update the relation enum - if (subunitMap.size() == 0) { + if (subunitMap.isEmpty()) { relation = QsRelation.DIFFERENT; } else if (subunitMap.keySet().size() == subunits1.size()) { if (subunitMap.values().size() == subunits2.size()) { diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/util/AlignmentTools.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/util/AlignmentTools.java index c6791f4ed2..e535a87508 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/util/AlignmentTools.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/util/AlignmentTools.java @@ -1313,7 +1313,7 @@ public static Group[] prepareGroupsForDisplay(AFPChain afpChain, Atom[] ca1, Ato if ( afpChain.getBlockNum() > 0){ // Superimpose ligands relative to the first block - if( hetatms2.size() > 0 ) { + if(!hetatms2.isEmpty()) { if ( afpChain.getBlockRotationMatrix().length > 0 ) { diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/util/AtomCache.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/util/AtomCache.java index 1435191c2c..71b8a3da22 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/util/AtomCache.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/util/AtomCache.java @@ -228,7 +228,7 @@ public Structure getBiologicalAssembly(String pdbId, int bioAssemblyId, boolean throws StructureException, IOException { return getBiologicalAssembly(new PdbId(pdbId), bioAssemblyId, multiModel); } - + /** * Returns the biological assembly for a given PDB ID and bioAssemblyId, by building the * assembly from the biounit annotations found in {@link Structure#getPDBHeader()} @@ -284,7 +284,7 @@ public Structure getBiologicalAssembly(PdbId pdbId, int bioAssemblyId, boolean m asymUnit.getPDBHeader().getBioAssemblies().get(bioAssemblyId).getTransforms(); - if (transformations == null || transformations.size() == 0) { + if (transformations == null || transformations.isEmpty()) { throw new StructureException("Could not load transformations to recreate biological assembly id " + bioAssemblyId + " of " + pdbId); } @@ -339,7 +339,7 @@ public Structure getBiologicalAssembly(String pdbId, boolean multiModel) throws asymUnit.getPDBHeader().getBioAssemblies().get(bioAssemblyId).getTransforms(); - if (transformations == null || transformations.size() == 0) { + if (transformations == null || transformations.isEmpty()) { throw new StructureException("Could not load transformations to recreate biological assembly id " + bioAssemblyId + " of " + pdbId); } @@ -385,7 +385,7 @@ public List getBiologicalAssemblies(String pdbId, boolean multiModel) List transformations = asymUnit.getPDBHeader().getBioAssemblies().get(bioAssemblyId).getTransforms(); - if (transformations == null || transformations.size() == 0) { + if (transformations == null || transformations.isEmpty()) { logger.info("Could not load transformations to recreate biological assembly id {} of {}. Assembly " + "id will be missing in biological assemblies.", bioAssemblyId, pdbId); continue; @@ -807,7 +807,7 @@ public Structure getStructureForPdbId(String id) throws IOException, StructureEx public Structure getStructureForPdbId(PdbId pdbId) throws IOException { if (pdbId == null) return null; - + while (checkLoading(pdbId)) { // waiting for loading to be finished... try { @@ -833,7 +833,7 @@ public Structure getStructureForPdbId(PdbId pdbId) throws IOException { protected Structure loadStructureFromCifByPdbId(String pdbId) throws IOException { return loadStructureFromCifByPdbId(new PdbId(pdbId)); } - + protected Structure loadStructureFromCifByPdbId(PdbId pdbId) throws IOException { logger.debug("Loading structure {} from mmCIF file {}.", pdbId, path); Structure s; diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/xml/MultipleAlignmentXMLParser.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/xml/MultipleAlignmentXMLParser.java index 759ee61931..e8d5434578 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/align/xml/MultipleAlignmentXMLParser.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/align/xml/MultipleAlignmentXMLParser.java @@ -169,7 +169,7 @@ else if ("ScoresCache".equals(child.getNodeName())){ } } //Because if it is 0 means that there were no transformations - if (transforms.size() != 0){ + if (!transforms.isEmpty()){ bs.setTransformations(transforms); } return bs; diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/asa/AsaCalculator.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/asa/AsaCalculator.java index 0f46b7f416..ed576b9abe 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/asa/AsaCalculator.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/asa/AsaCalculator.java @@ -27,7 +27,6 @@ import org.slf4j.LoggerFactory; import javax.vecmath.Point3d; -import javax.vecmath.Vector3d; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -101,12 +100,56 @@ public void run() { } } - static class IndexAndDistance { - final int index; - final double dist; - IndexAndDistance(int index, double dist) { - this.index = index; - this.dist = dist; + /** + * The neighbors of a single atom, as parallel primitive arrays of neighbor atom indices and their distances to + * the central atom, sorted by increasing distance. + *

+ * Parallel primitive arrays are used rather than an array of index-distance objects because there are ~30 + * neighbors per atom: for a large structure that would mean millions of small short-lived objects. + */ + static class Neighbors { + + /** The neighbor atom indices, ordered by increasing distance to the central atom */ + final int[] indices; + /** The distances to the central atom, in increasing order and parallel to {@link #indices} */ + final double[] dists; + + private Neighbors(int[] indices, double[] dists) { + this.indices = indices; + this.dists = dists; + } + + /** + * Creates a Neighbors from the first count elements of the given buffers, copying them to exact-size arrays + * and sorting them by increasing distance. + * @param indicesBuffer the neighbor indices, only the first count elements are used + * @param distsBuffer the neighbor distances, only the first count elements are used + * @param count the number of neighbors + * @return the sorted neighbors + */ + static Neighbors createSorted(int[] indicesBuffer, double[] distsBuffer, int count) { + int[] indices = Arrays.copyOf(indicesBuffer, count); + double[] dists = Arrays.copyOf(distsBuffer, count); + // Sorting by closest to farthest away neighbors achieves faster runtimes when checking for occluded + // sphere sample points in calcSingleAsa. This follows the ideas exposed in + // Eisenhaber et al, J Comp Chemistry 1994 (https://onlinelibrary.wiley.com/doi/epdf/10.1002/jcc.540160303) + // This is essential for performance: it brings down the number of occlusion checks to + // an average of n_sphere_points/10 per atom, producing ~ x4 performance gain overall. + // An insertion sort is used because the arrays are small (~30 elements on average) and because it avoids + // both the boxing of a comparator-based sort and the object allocation an index-distance array would need. + for (int i = 1; i < count; i++) { + double dist = dists[i]; + int index = indices[i]; + int j = i - 1; + while (j >= 0 && dists[j] > dist) { + dists[j + 1] = dists[j]; + indices[j + 1] = indices[j]; + j--; + } + dists[j + 1] = dist; + indices[j + 1] = index; + } + return new Neighbors(indices, dists); } } @@ -116,9 +159,16 @@ static class IndexAndDistance { private final double[] radii; private final double probe; private final int nThreads; - private Vector3d[] spherePoints; + /** + * The sphere points to sample, as a flat array of interleaved x,y,z coordinates (thus of size 3 x nSpherePoints). + * A flat array of primitives (rather than an array of Vector3d objects) is used for performance: it keeps the + * points contiguous in memory and avoids a pointer dereference per point in the innermost loop of + * {@link #calcSingleAsa(int)}. + */ + private double[] spherePoints; + private int nSpherePoints; private double cons; - private IndexAndDistance[][] neighborIndices; + private Neighbors[] neighbors; private boolean useSpatialHashingForNeighbors; @@ -239,7 +289,8 @@ private void initSpherePoints(int nSpherePoints) { logger.debug("Will use {} sphere points", nSpherePoints); // initialising the sphere points to sample - spherePoints = generateSpherePoints(nSpherePoints); + this.nSpherePoints = nSpherePoints; + this.spherePoints = generateSpherePoints(nSpherePoints); cons = 4.0 * Math.PI / nSpherePoints; } @@ -285,10 +336,10 @@ public double[] calculateAsas() { long start = System.currentTimeMillis(); if (useSpatialHashingForNeighbors) { logger.debug("Will use spatial hashing to find neighbors"); - neighborIndices = findNeighborIndicesSpatialHashing(); + neighbors = findNeighborIndicesSpatialHashing(); } else { logger.debug("Will not use spatial hashing to find neighbors"); - neighborIndices = findNeighborIndices(); + neighbors = findNeighborIndices(); } long end = System.currentTimeMillis(); logger.debug("Took {} s to find neighbors", (end-start)/1000.0); @@ -334,109 +385,126 @@ void setUseSpatialHashingForNeighbors(boolean useSpatialHashingForNeighbors) { * Returns list of 3d coordinates of points on a unit sphere using the * Golden Section Spiral algorithm. * @param nSpherePoints the number of points to be used in generating the spherical dot-density - * @return the array of points as Vector3d objects + * @return a flat array of interleaved x,y,z coordinates, of size 3 x nSpherePoints */ - private Vector3d[] generateSpherePoints(int nSpherePoints) { - Vector3d[] points = new Vector3d[nSpherePoints]; + private double[] generateSpherePoints(int nSpherePoints) { + double[] points = new double[3 * nSpherePoints]; double inc = Math.PI * (3.0 - Math.sqrt(5.0)); double offset = 2.0 / nSpherePoints; for (int k=0;k thisNbIndices = new ArrayList<>(initialCapacity); + int count = 0; for (int i = 0; i < atomCoords.length; i++) { if (i == k) continue; double dist = atomCoords[i].distance(atomCoords[k]); - if (dist < radius + radii[i]) { - thisNbIndices.add(new IndexAndDistance(i, dist)); + if (areNeighbors(k, i, dist)) { + if (count == indicesBuffer.length) { + indicesBuffer = Arrays.copyOf(indicesBuffer, count * 2); + distsBuffer = Arrays.copyOf(distsBuffer, count * 2); + } + indicesBuffer[count] = i; + distsBuffer[count] = dist; + count++; } } - IndexAndDistance[] indicesArray = thisNbIndices.toArray(new IndexAndDistance[0]); - nbsIndices[k] = indicesArray; + nbs[k] = Neighbors.createSorted(indicesBuffer, distsBuffer, count); } - return nbsIndices; + return nbs; } /** - * Returns the 2-dimensional array with neighbor indices for every atom, + * Returns the neighbors of every atom, sorted by increasing distance, * using spatial hashing to avoid all to all distance calculation. - * @return 2-dimensional array of size: n_atoms x n_neighbors_per_atom + * @return array of size n_atoms */ - IndexAndDistance[][] findNeighborIndicesSpatialHashing() { - - // looking at a typical protein case, number of neighbours are from ~10 to ~50, with an average of ~30 - int initialCapacity = 60; + Neighbors[] findNeighborIndicesSpatialHashing() { List contactList = calcContacts(); - Map> indices = new HashMap<>(atomCoords.length); + + // A first pass to count the neighbors per atom, so that exact-size arrays can be allocated in the second + // pass. Atom indices are dense, so plain arrays are used rather than a map: that avoids boxing the indices + // and the repeated hashing, which are significant given that there are ~30 contacts per atom. + int[] counts = new int[atomCoords.length]; for (Contact contact : contactList) { // note contacts are stored 1-way only, with j>i int i = contact.getI(); int j = contact.getJ(); - - List iIndices; - List jIndices; - if (!indices.containsKey(i)) { - iIndices = new ArrayList<>(initialCapacity); - indices.put(i, iIndices); - } else { - iIndices = indices.get(i); - } - if (!indices.containsKey(j)) { - jIndices = new ArrayList<>(initialCapacity); - indices.put(j, jIndices); - } else { - jIndices = indices.get(j); - } - - double radius = radii[i] + probe + probe; - double dist = contact.getDistance(); - if (dist < radius + radii[j]) { - iIndices.add(new IndexAndDistance(j, dist)); - jIndices.add(new IndexAndDistance(i, dist)); + if (areNeighbors(i, j, contact.getDistance())) { + counts[i]++; + counts[j]++; } } - // convert map to array for fast access - IndexAndDistance[][] nbsIndices = new IndexAndDistance[atomCoords.length][]; - for (Map.Entry> entry : indices.entrySet()) { - List list = entry.getValue(); - IndexAndDistance[] indexAndDistances = list.toArray(new IndexAndDistance[0]); - nbsIndices[entry.getKey()] = indexAndDistances; + int[][] indices = new int[atomCoords.length][]; + double[][] dists = new double[atomCoords.length][]; + for (int i = 0; i < atomCoords.length; i++) { + // note that some atoms might have no neighbors at all, in which case these are empty arrays + indices[i] = new int[counts[i]]; + dists[i] = new double[counts[i]]; } - // important: some atoms might have no neighbors at all: we need to initialise to empty arrays - for (int i=0; i calcContacts() { private double calcSingleAsa(int i) { Point3d atom_i = atomCoords[i]; - int n_neighbor = neighborIndices[i].length; - IndexAndDistance[] neighbor_indices = neighborIndices[i]; - // Sorting by closest to farthest away neighbors achieves faster runtimes when checking for occluded - // sphere sample points below. This follows the ideas exposed in - // Eisenhaber et al, J Comp Chemistry 1994 (https://onlinelibrary.wiley.com/doi/epdf/10.1002/jcc.540160303) - // This is essential for performance. In my tests this brings down the number of occlusion checks in loop below to - // an average of n_sphere_points/10 per atom i, producing ~ x4 performance gain overall - Arrays.sort(neighbor_indices, Comparator.comparingDouble(o -> o.dist)); + // note the neighbors are already sorted by increasing distance (see Neighbors#createSorted), which is + // essential for the performance of the occlusion checks in the loop below + Neighbors nbs = neighbors[i]; + int[] neighbor_indices = nbs.indices; + double[] neighbor_dists = nbs.dists; + int n_neighbor = neighbor_indices.length; double radius_i = probe + radii[i]; @@ -476,36 +542,47 @@ private double calcSingleAsa(int i) { int[] numDistsCalced = null; if (logger.isDebugEnabled()) numDistsCalced = new int[n_neighbor]; - // now we precalculate anything depending only on i,j in equation 3 in Eisenhaber 1994 - double[] sqRadii = new double[n_neighbor]; - Vector3d[] aj_minus_ais = new Vector3d[n_neighbor]; + // Now we precalculate anything depending only on i,j in equation 3 in Eisenhaber 1994. + // The per-neighbor data is laid out in a single flat array as quadruplets + // [aj_minus_ai.x, aj_minus_ai.y, aj_minus_ai.z, cutoff], so that the innermost loop below is a purely + // sequential scan over contiguous primitives, with no object dereferencing. That matches the access + // pattern of the early break and is significantly faster than an array of Vector3d objects. + double[] nbData = new double[4 * n_neighbor]; for (int nbArrayInd =0; nbArrayInd> 2]++; - if (dotProd > sqRadii[nbArrayInd]) { + if (dotProd > nbData[off + 3]) { is_accessible = false; break; } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/cath/CathInstallation.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/cath/CathInstallation.java index 3f0f44158b..4201f8d5ca 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/cath/CathInstallation.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/cath/CathInstallation.java @@ -31,8 +31,6 @@ import java.io.*; import java.net.URL; -import java.nio.file.Files; -import java.nio.file.StandardCopyOption; import java.text.DateFormat; import java.text.DecimalFormat; import java.text.ParseException; @@ -54,7 +52,7 @@ public class CathInstallation implements CathDatabase{ public static final String nodeListFileName = "cath-names-v%s.txt"; public static final String domallFileName = "cath-domain-boundaries-v%s.txt"; - public static final String CATH_DOWNLOAD_URL = "http://download.cathdb.info/cath/releases/"; + public static final String CATH_DOWNLOAD_URL = "https://download.cathdb.info/cath/releases/"; public static final String CATH_DOWNLOAD_ALL_RELEASES_DIR = "all-releases"; public static final String CATH_DOWNLOAD_CLASSIFICATION_DATA_DIR = "cath-classification-data"; @@ -352,13 +350,15 @@ private void parseCathDomainList() throws IOException { parseCathDomainList(buffer); } - private void parseCathDomainList(BufferedReader bufferedReader) throws IOException{ + protected void parseCathDomainList(BufferedReader bufferedReader) throws IOException{ String line; - // int counter = 0; + int counter = 0; while ( (line = bufferedReader.readLine()) != null ) { if ( line.startsWith("#") ) continue; + if ( line.trim().isEmpty() ) continue; CathDomain cathDomain = parseCathListFileLine(line); - // counter++; + if ( cathDomain == null ) continue; + counter++; String pdbId = cathDomain.getPdbIdAndChain().substring(0,4); // includes chain letter @@ -374,6 +374,9 @@ private void parseCathDomainList(BufferedReader bufferedReader) throws IOExcepti domainMap.put( cathDomain.getDomainName(), cathDomain ); } + if (counter == 0) { + throw new IOException("Could not parse any CATH domains from the domain list file."); + } } private void parseCathNames() throws IOException { @@ -388,7 +391,9 @@ private void parseCathNames(BufferedReader bufferedReader) throws IOException{ //int counter = 0; while ( (line = bufferedReader.readLine()) != null ) { if ( line.startsWith("#") ) continue; + if ( line.trim().isEmpty() ) continue; CathNode cathNode = parseCathNamesFileLine(line); + if ( cathNode == null ) continue; cathTree.put(cathNode.getNodeId(), cathNode); } } @@ -415,6 +420,7 @@ private void parseCathDomainDescriptionFile(BufferedReader bufferedReader) throw StringBuilder sseqs = null; while ( (line = bufferedReader.readLine()) != null ) { if ( line.startsWith("#") ) continue; + if ( line.trim().isEmpty() ) continue; if ( line.startsWith("FORMAT") ) { cathDescription = new CathDomain(); cathDescription.setFormat( line.substring(10) ); @@ -506,8 +512,11 @@ private void parseCathDomainDescriptionFile(BufferedReader bufferedReader) throw }*/ private CathDomain parseCathListFileLine(String line) { + String [] token = line.trim().split("\\s+"); + if (token.length < 12) { + return null; + } CathDomain cathDomain = new CathDomain(); - String [] token = line.split("\\s+"); cathDomain.setDomainName(token[0]); cathDomain.setClassId(Integer.parseInt(token[1])); cathDomain.setArchitectureId(Integer.parseInt(token[2])); @@ -524,8 +533,12 @@ private CathDomain parseCathListFileLine(String line) { } private CathNode parseCathNamesFileLine(String line) { + String[] token = line.trim().split("\\s+",3); + if (token.length < 3) { + LOGGER.debug("Invalid line in cath names file, was expecting 3 tokens but got {} tokens: {}", token.length, line); + return null; + } CathNode cathNode = new CathNode(); - String[] token = line.split("\\s+",3); cathNode.setNodeId( token[0] ); int idx = token[0].lastIndexOf("."); if ( idx == -1 ) idx = token[0].length(); @@ -546,8 +559,8 @@ private void parseCathDomall(BufferedReader bufferedReader) throws IOException{ String line; while ( ((line = bufferedReader.readLine()) != null) ) { if ( line.startsWith("#") ) continue; - if ( line.length() == 0 ) continue; - String[] token = line.split("\\s+"); + if ( line.trim().isEmpty() ) continue; + String[] token = line.trim().split("\\s+"); String chainId = token[0]; Integer numberOfDomains = Integer.parseInt( token[1].substring(1) ); Integer numberOfFragments = Integer.parseInt( token[2].substring(1) ); @@ -637,27 +650,26 @@ private void parseCathDomall(BufferedReader bufferedReader) throws IOException{ } protected void downloadFileFromRemote(URL remoteURL, File localFile) throws IOException{ -// System.out.println("downloading " + remoteURL + " to: " + localFile); LOGGER.info("Downloading file {} to local file {}", remoteURL, localFile); long timeS = System.currentTimeMillis(); - File tempFile = Files.createTempFile(FileDownloadUtils.getFilePrefix(localFile),"." + FileDownloadUtils.getFileExtension(localFile)).toFile(); - FileOutputStream out = new FileOutputStream(tempFile); - - InputStream in = remoteURL.openStream(); - byte[] buf = new byte[4 * 1024]; // 4K buffer - int bytesRead; - while ((bytesRead = in.read(buf)) != -1) { - out.write(buf, 0, bytesRead); + File parent = localFile.getAbsoluteFile().getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Could not create directory " + parent); } - in.close(); - out.close(); - Files.copy(tempFile.toPath(), localFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + // Previously this read the response with a bare remoteURL.openStream() and + // copied whatever came back. That silently accepted error and redirect + // responses: when download.cathdb.info began redirecting http to https, the + // body of the 301 was written here as though it were classification data, + // and the failure only surfaced much later as an unparseable file. + FileDownloadUtils.downloadFileWithValidation(remoteURL, localFile, null, + FileDownloadUtils.Hash.UNKNOWN, FileDownloadUtils.ETagPolicy.USE_IF_HEX_DIGEST); - // delete the tmp file - tempFile.delete(); + if (!FileDownloadUtils.validateFile(localFile)) { + throw new IOException("Downloaded file invalid: " + localFile); + } long size = localFile.length(); @@ -674,25 +686,25 @@ protected void downloadFileFromRemote(URL remoteURL, File localFile) throws IOEx private boolean domainDescriptionFileAvailable(){ String fileName = getDomainDescriptionFileName(); File f = new File(fileName); - return f.exists(); + return f.exists() && FileDownloadUtils.validateFile(f); } private boolean domainListFileAvailable(){ String fileName = getDomainListFileName(); File f = new File(fileName); - return f.exists(); + return f.exists() && FileDownloadUtils.validateFile(f); } private boolean nodeListFileAvailable(){ String fileName = getNodeListFileName(); File f = new File(fileName); - return f.exists(); + return f.exists() && FileDownloadUtils.validateFile(f); } private boolean domallFileAvailable() { String fileName = getDomallFileName(); File f= new File(fileName); - return f.exists(); + return f.exists() && FileDownloadUtils.validateFile(f); } protected void downloadDomainListFile() throws IOException{ diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/chem/DownloadChemCompProvider.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/chem/DownloadChemCompProvider.java index 9eb9c7c6cf..e19535bce8 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/chem/DownloadChemCompProvider.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/chem/DownloadChemCompProvider.java @@ -1,5 +1,6 @@ package org.biojava.nbio.structure.chem; +import org.biojava.nbio.core.util.FileDownloadUtils; import org.biojava.nbio.core.util.InputStreamProvider; import org.biojava.nbio.structure.align.util.URLConnectionTools; import org.biojava.nbio.structure.align.util.UserConfiguration; @@ -396,6 +397,17 @@ private static boolean downloadChemCompRecord(String recordName) { url = new URL(u); URLConnection uconn = URLConnectionTools.openURLConnection(url); + // A 4xx or 5xx already fails safely, because getInputStream() throws for + // those. A redirect does not: if the server answers 3xx and the JDK + // declines to follow it - which it always does when the redirect changes + // http to https - getInputStream() hands back the body of the redirect + // instead. That body is short but not empty, so the "did we read any + // lines" check below accepts it, and it is gzipped and stored under the + // component's name. Every later lookup then reads it back and fails to + // parse, long after the request that caused it. This is exactly how the + // CATH downloader broke when download.cathdb.info moved to https. + FileDownloadUtils.checkHttpStatus(uconn); + try (PrintWriter pw = new PrintWriter(new GZIPOutputStream(new FileOutputStream(newFile))); BufferedReader fileBuffer = new BufferedReader(new InputStreamReader(uconn.getInputStream()))) { String line; diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/chem/ZipChemCompProvider.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/chem/ZipChemCompProvider.java index 4fe19aca58..a68019efb7 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/chem/ZipChemCompProvider.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/chem/ZipChemCompProvider.java @@ -120,7 +120,7 @@ public ChemComp getChemComp(String recordName) { } // If a null record or an empty chemcomp, return a default ChemComp and blacklist. - if (cc == null || (null == cc.getName() && cc.getAtoms().size() == 0)) { + if (cc == null || (null == cc.getName() && cc.getAtoms().isEmpty())) { s_logger.info("Unable to find or download {} - excluding from future searches.", recordName); unavailable.add(recordName); return getEmptyChemComp(recordName); diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/cluster/SubunitCluster.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/cluster/SubunitCluster.java index 9a87e92f88..87c3c06e3c 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/cluster/SubunitCluster.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/cluster/SubunitCluster.java @@ -331,7 +331,7 @@ public boolean mergeIdenticalByEntityId(SubunitCluster other) { } } - if (thisAligned.size() == 0 && otherAligned.size() == 0) { + if (thisAligned.isEmpty() && otherAligned.isEmpty()) { logger.warn("No equivalent aligned atoms found between SubunitClusters {}-{} via entity SEQRES alignment. Is FileParsingParameters.setAlignSeqRes() set?", thisName, otherName); } @@ -507,27 +507,24 @@ public boolean mergeStructure(SubunitCluster other, SubunitClustererParameters p } } - AFPChain afp = aligner.align(this.subunits.get(this.representative) - .getRepresentativeAtoms(), - other.subunits.get(other.representative) - .getRepresentativeAtoms()); + AFPChain afp = aligner.align(this.subunits.get(this.representative).getRepresentativeAtoms(), + other.subunits.get(other.representative).getRepresentativeAtoms()); + String pairName = this.subunits.get(this.representative).getName() + "-" + other.subunits.get(other.representative).getName(); if (afp.getOptLength() < 1) { // alignment failed (eg if chains were too short) throw new StructureException( - String.format("Subunits failed to align using %s", params.getSuperpositionAlgorithm())); + String.format("Subunits %s failed to align using %s", pairName, params.getSuperpositionAlgorithm())); } // Convert AFPChain to MultipleAlignment for convenience MultipleAlignment msa = new MultipleAlignmentEnsembleImpl( afp, this.subunits.get(this.representative).getRepresentativeAtoms(), - other.subunits.get(other.representative) - .getRepresentativeAtoms(), false) - .getMultipleAlignment(0); + other.subunits.get(other.representative).getRepresentativeAtoms(), + false).getMultipleAlignment(0); - double structureCoverage = Math.min(msa.getCoverages().get(0), msa - .getCoverages().get(1)); + double structureCoverage = Math.min(msa.getCoverages().get(0), msa.getCoverages().get(1)); if(params.isUseStructureCoverage() && structureCoverage < params.getStructureCoverageThreshold()) { return false; @@ -543,8 +540,7 @@ public boolean mergeStructure(SubunitCluster other, SubunitClustererParameters p return false; } - logger.info(String.format("SubunitClusters are structurally similar with " - + "%.2f RMSD %.2f coverage", rmsd, structureCoverage)); + logger.info("SubunitClusters {} are structurally similar with [ {} ] RMSD and [ {} ] coverage", pairName, String.format("%.2f", rmsd), String.format("%.2f", structureCoverage)); // Merge clusters List> alignedRes = msa.getBlock(0).getAlignRes(); @@ -565,13 +561,18 @@ public boolean mergeStructure(SubunitCluster other, SubunitClustererParameters p // Only consider residues that are part of the SubunitCluster if (this.subunitEQR.get(this.representative).contains(thisIndex) - && other.subunitEQR.get(other.representative).contains( - otherIndex)) { + && other.subunitEQR.get(other.representative).contains(otherIndex)) { thisAligned.add(thisIndex); otherAligned.add(otherIndex); } } + // this can happen in very rare cases, e.g. 9y9z when merging E_1 into the cluster D_1, OM_1, Y_1 + if (thisAligned.isEmpty() && otherAligned.isEmpty()) { + logger.warn("No equivalent aligned atoms found between SubunitClusters {} via structure alignment. Will not merge the second one into the first.", pairName); + return false; + } + updateEquivResidues(other, thisAligned, otherAligned); this.method = SubunitClustererMethod.STRUCTURE; @@ -602,18 +603,12 @@ private void updateEquivResidues(SubunitCluster other, List thisAligned Collections.sort(otherRemove); Collections.reverse(otherRemove); - for (int t = 0; t < thisRemove.size(); t++) { - for (List eqr : this.subunitEQR) { - int column = thisRemove.get(t); - eqr.remove(column); - } + for (int column : thisRemove) { + this.subunitEQR.forEach(eqr -> eqr.remove(column)); } - for (int t = 0; t < otherRemove.size(); t++) { - for (List eqr : other.subunitEQR) { - int column = otherRemove.get(t); - eqr.remove(column); - } + for (int column : otherRemove) { + other.subunitEQR.forEach(eqr -> eqr.remove(column)); } // The representative is the longest sequence diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/cluster/SubunitClusterer.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/cluster/SubunitClusterer.java index 6295f8fdf0..fa63b96d96 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/cluster/SubunitClusterer.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/cluster/SubunitClusterer.java @@ -58,7 +58,7 @@ public static Stoichiometry cluster(Structure structure, public static Stoichiometry cluster(List subunits, SubunitClustererParameters params) { List clusters = new ArrayList<>(); - if (subunits.size() == 0) + if (subunits.isEmpty()) return new Stoichiometry(clusters); // First generate a new cluster for each Subunit @@ -83,8 +83,7 @@ public static Stoichiometry cluster(List subunits, SubunitClustererPara } } catch (CompoundNotFoundException e) { - logger.warn("Could not merge by Sequence. {}", - e.getMessage()); + logger.info("Could not merge by Sequence. {}", e.getMessage()); } } } @@ -100,7 +99,7 @@ public static Stoichiometry cluster(List subunits, SubunitClustererPara clusters.remove(c2); } } catch (StructureException e) { - logger.warn("Could not merge by Structure. {}", e.getMessage()); + logger.info("Could not merge by Structure. {}", e.getMessage()); } } } @@ -112,8 +111,7 @@ public static Stoichiometry cluster(List subunits, SubunitClustererPara try { clusters.get(c).divideInternally(params); } catch (StructureException e) { - logger.warn("Error analyzing internal symmetry. {}", - e.getMessage()); + logger.info("Error analyzing internal symmetry. {}", e.getMessage()); } } @@ -125,8 +123,7 @@ public static Stoichiometry cluster(List subunits, SubunitClustererPara if (clusters.get(c1).mergeStructure(clusters.get(c2), params)) clusters.remove(c2); } catch (StructureException e) { - logger.warn("Could not merge by Structure. {}", - e.getMessage()); + logger.info("Could not merge by Structure. {}", e.getMessage()); } } } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/AtomContactSet.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/AtomContactSet.java index 34de552786..9748065524 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/AtomContactSet.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/AtomContactSet.java @@ -28,6 +28,22 @@ /** * A set of atom-atom contacts to hold the results of intra and inter-chain contact calculations + *

+ * Contacts are keyed by the ordered pair of {@link AtomIdentifier}s of the 2 atoms, i.e. the + * pair (a,b) and the pair (b,a) are 2 different keys. Thus look-ups ({@link #hasContact(Atom, Atom)}, + * {@link #getContact(Atom, Atom)}) must give the 2 atoms in the same order in which the contact was + * calculated. The order produced by the calculation ({@link Grid}) is: + *

    + *
  • for contacts within a single set of atoms (e.g. the intra-chain contacts of + * StructureTools.getAtomsInContact(Chain, double)), the atom that comes first in the + * atom array is the first member of the pair
  • + *
  • for contacts between 2 sets of atoms (e.g. the inter-chain contacts of + * StructureTools.getAtomsInContact(Chain, Chain, double, boolean), or a + * {@link StructureInterface}), the atom belonging to the first set is the first member of the pair
  • + *
+ *

+ * Note that the order is not the order of PDB serials or of any other property of the atoms + * themselves: it is only the order in which the atoms were given to the calculation. * * @author duarte_j * @@ -37,6 +53,12 @@ public class AtomContactSet implements Serializable, Iterable { private static final long serialVersionUID = 1L; + /** + * The default load factor of a {@link HashMap}, needed to size the map from an expected number of + * entries. + */ + private static final float DEFAULT_LOAD_FACTOR = 0.75f; + private HashMap, AtomContact> contacts; private double cutoff; @@ -45,31 +67,79 @@ public AtomContactSet(double cutoff) { this.contacts = new HashMap<>(); } + /** + * Creates an AtomContactSet sized to hold the given number of contacts, so that the underlying map + * doesn't have to be repeatedly resized and rehashed as contacts are added. Contact calculations + * produce hundreds of thousands of contacts for a large structure, where the repeated rehashing + * that growing from the default capacity entails is a significant part of the cost. + * @param cutoff the distance cutoff + * @param expectedSize the number of contacts expected to be added. Only affects performance: an + * over-estimate merely leaves the map larger than it needs to be. + */ + public AtomContactSet(double cutoff, int expectedSize) { + this.cutoff = cutoff; + this.contacts = new HashMap<>((int) (expectedSize / DEFAULT_LOAD_FACTOR) + 1); + } + + /** + * Adds the given contact to this set, keyed by the ordered pair of its 2 atoms. If a contact + * with the same ordered pair of atoms is already present it is replaced. + * @param contact the contact to add + */ public void add(AtomContact contact) { this.contacts.put(getAtomIdPairFromContact(contact), contact); } + /** + * Adds all given contacts to this set, see {@link #add(AtomContact)}. + * @param list the contacts to add + */ public void addAll(Collection list) { for (AtomContact contact:list) { this.contacts.put(getAtomIdPairFromContact(contact), contact); } } + /** + * Tells whether a contact exists between the 2 given atoms, in the given order. + *

+ * The 2 atoms have to be passed in the same order in which the contacts of this set were + * calculated, otherwise this returns false even if the 2 atoms are within the distance cutoff. + * See the class documentation for the ordering convention. If the order is not known, both orders + * have to be queried. + * @param atom1 the first atom of the pair + * @param atom2 the second atom of the pair + * @return true if the 2 atoms are in contact in the given order, false otherwise + * @see #getContact(Atom, Atom) + */ public boolean hasContact(Atom atom1, Atom atom2) { return hasContact( new AtomIdentifier(atom1.getPDBserial(),atom1.getGroup().getChainId()), new AtomIdentifier(atom2.getPDBserial(),atom2.getGroup().getChainId()) ); } + /** + * Tells whether a contact exists between the 2 given atom identifiers, in the given order, + * see {@link #hasContact(Atom, Atom)}. + * @param atomId1 the identifier of the first atom of the pair + * @param atomId2 the identifier of the second atom of the pair + * @return true if the 2 atoms are in contact in the given order, false otherwise + */ public boolean hasContact(AtomIdentifier atomId1, AtomIdentifier atomId2) { return contacts.containsKey(new Pair(atomId1,atomId2)); } /** - * Returns the corresponding AtomContact or null if no contact exists between the 2 given atoms - * @param atom1 - * @param atom2 - * @return + * Returns the contact between the 2 given atoms in the given order, or null if there is + * no such contact in this set. + *

+ * As in {@link #hasContact(Atom, Atom)} the order of the 2 atoms matters: they have to be passed + * in the same order in which the contacts of this set were calculated, otherwise null is returned + * even if the 2 atoms are within the distance cutoff. See the class documentation for the + * ordering convention. + * @param atom1 the first atom of the pair + * @param atom2 the second atom of the pair + * @return the contact between the 2 atoms in the given order, or null if there is none */ public AtomContact getContact(Atom atom1, Atom atom2) { return contacts.get(new Pair( diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/Grid.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/Grid.java index 0047385ab2..ba2fd08cdb 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/Grid.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/Grid.java @@ -66,6 +66,7 @@ public class Grid { private GridCell[][][] cells; private double cutoff; + private double cutoffSq; private int cellSize; private Point3d[] iAtoms; @@ -91,6 +92,7 @@ public class Grid { */ public Grid(double cutoff) { this.cutoff = cutoff; + this.cutoffSq = cutoff * cutoff; this.cellSize = (int) Math.floor(cutoff*SCALE); this.noOverlap = false; } @@ -380,10 +382,12 @@ private int[] getIntBounds(BoundingBox coordbounds) { */ public AtomContactSet getAtomContacts() { - AtomContactSet contacts = new AtomContactSet(cutoff); - List list = getIndicesContacts(); + // each contact maps to at most one entry in the set, so the number of index contacts sizes it + // without ever under-allocating + AtomContactSet contacts = new AtomContactSet(cutoff, list.size()); + if (jAtomObjects == null) { for (Contact cont : list) { contacts.add(new AtomContact(new Pair(iAtomObjects[cont.getI()],iAtomObjects[cont.getJ()]),cont.getDistance())); @@ -496,6 +500,15 @@ public double getCutoff() { return cutoff; } + /** + * Returns the square of the cutoff, precomputed at construction. Used by {@link GridCell} to + * compare squared distances, avoiding a square root per candidate pair. + * @return the squared cutoff + */ + protected double getCutoffSq() { + return cutoffSq; + } + /** * Tells whether (after having added atoms to grid) the i and j grids are not overlapping. * Overlap is defined as enclosing bounds of the 2 grids being no more than one cell size apart. diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/GridCell.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/GridCell.java index 6028110eb6..9c75fa1c75 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/GridCell.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/GridCell.java @@ -21,6 +21,7 @@ package org.biojava.nbio.structure.contact; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import javax.vecmath.Point3d; @@ -35,30 +36,61 @@ public class GridCell { + /** + * Shared empty array so that cells that never receive indices (e.g. the j indices when only one + * set of atoms was added to the grid) don't allocate anything at all. + */ + private static final int[] EMPTY = new int[0]; + + /** + * Capacity of the index arrays on first insertion. Cell occupancy depends on the cutoff (the cell + * side is the cutoff), ranging from a handful of atoms for small cutoffs to a few tens for large + * ones, so we start small and grow geometrically. + */ + private static final int INITIAL_CAPACITY = 8; + private Grid grid; - private ArrayList iIndices; - private ArrayList jIndices; + + /** + * The indices of the i atoms in this cell, held as a primitive array to avoid the boxing (and the + * pointer chasing it entails) of a Collection of Integers: these are read in the innermost loop of + * the contact calculation. Only the first {@link #numIindices} elements are meaningful. + */ + private int[] iIndices; + private int numIindices; + + /** + * The indices of the j atoms in this cell. See {@link #iIndices}. + */ + private int[] jIndices; + private int numJindices; public GridCell(Grid parent){ - iIndices = new ArrayList<>(); - jIndices = new ArrayList<>(); + iIndices = EMPTY; + jIndices = EMPTY; this.grid = parent; } public void addIindex(int serial){ - iIndices.add(serial); + if (numIindices == iIndices.length) { + iIndices = Arrays.copyOf(iIndices, numIindices == 0 ? INITIAL_CAPACITY : numIindices * 2); + } + iIndices[numIindices++] = serial; } public void addJindex(int serial){ - jIndices.add(serial); + if (numJindices == jIndices.length) { + jIndices = Arrays.copyOf(jIndices, numJindices == 0 ? INITIAL_CAPACITY : numJindices * 2); + } + jIndices[numJindices++] = serial; } public int getNumIindices() { - return iIndices.size(); + return numIindices; } public int getNumJindices() { - return jIndices.size(); + return numJindices; } /** @@ -74,23 +106,31 @@ public List getContactsWithinCell(){ Point3d[] iAtoms = grid.getIAtoms(); Point3d[] jAtoms = grid.getJAtoms(); - double cutoff = grid.getCutoff(); + // we compare squared distances to the squared cutoff, so that the expensive square root is + // only computed for the pairs that are actually in contact (the large majority are not) + double cutoffSq = grid.getCutoffSq(); if (jAtoms==null) { - for (int i:iIndices) { - for (int j:iIndices) { + for (int a=0; ai) { - double distance = iAtoms[i].distance(iAtoms[j]); - if (distance getContactsToOtherCell(GridCell otherCell){ Point3d[] iAtoms = grid.getIAtoms(); Point3d[] jAtoms = grid.getJAtoms(); - double cutoff = grid.getCutoff(); + // we compare squared distances to the squared cutoff, so that the expensive square root is + // only computed for the pairs that are actually in contact (the large majority are not) + double cutoffSq = grid.getCutoffSq(); if (jAtoms==null) { - for (int i:iIndices) { - for (int j:otherCell.iIndices) { + int[] otherIndices = otherCell.iIndices; + int otherNum = otherCell.numIindices; + for (int a=0; ai) { - double distance = iAtoms[i].distance(iAtoms[j]); - if (distance getContactsToOtherCell(GridCell otherCell){ * @return */ public boolean hasContactToAtom(Point3d[] iAtoms, Point3d[] jAtoms, Point3d query, double cutoff) { - for( int i : iIndices ) { - double distance = iAtoms[i].distance(query); - if( distance pair) { } public double getMinDistance() { - if (atomContacts.size()==0) return 0; + if (atomContacts.isEmpty()) return 0; double minDistance = Double.MAX_VALUE; for (AtomContact atomContact:atomContacts) { diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/StructureInterfaceList.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/StructureInterfaceList.java index 60f7c3a91b..00cf7ef65c 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/StructureInterfaceList.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/contact/StructureInterfaceList.java @@ -380,7 +380,7 @@ public List getClusters(double contactOverlapScoreClu clusters = new ArrayList<>(); // nothing to do if we have no interfaces - if (list.size()==0) return clusters; + if (list.isEmpty()) return clusters; logger.debug("Calculating all-vs-all Jaccard scores for {} interfaces", list.size()); double[][] matrix = new double[list.size()][list.size()]; diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/ecod/EcodInstallation.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/ecod/EcodInstallation.java index f4be5cd4f5..027907ffa6 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/ecod/EcodInstallation.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/ecod/EcodInstallation.java @@ -137,9 +137,15 @@ public List getDomainsForPdb(String id) throws IOException { // unlock to allow ensureDomainsFileInstalled to get the write lock logger.trace("UNLOCK readlock"); domainsFileLock.readLock().unlock(); - indexDomains(); - domainsFileLock.readLock().lock(); - logger.trace("LOCK readlock"); + try { + indexDomains(); + } finally { + // re-acquire even if indexing failed, so the outer finally has a + // lock to release; otherwise IllegalMonitorStateException replaces + // the real cause and the failure becomes unreadable + domainsFileLock.readLock().lock(); + logger.trace("LOCK readlock"); + } } PdbId pdbId = null; @@ -244,9 +250,15 @@ public List getAllDomains() throws IOException { // unlock to allow ensureDomainsFileInstalled to get the write lock logger.trace("UNLOCK readlock"); domainsFileLock.readLock().unlock(); - ensureDomainsFileInstalled(); - domainsFileLock.readLock().lock(); - logger.trace("LOCK readlock"); + try { + ensureDomainsFileInstalled(); + } finally { + // re-acquire even if the download failed, so the outer finally has a + // lock to release; otherwise IllegalMonitorStateException replaces + // the real cause and the failure becomes unreadable + domainsFileLock.readLock().lock(); + logger.trace("LOCK readlock"); + } } return allDomains; } finally { @@ -272,12 +284,41 @@ public void clear() { * * Note that this may differ from the version requested in the constructor * for the special case of "latest" + *

+ * Since 7.3.0 this reads only the file's header rather than parsing the whole + * file, so it no longer has the side effect of loading every domain. * @return the ECOD version * @throws IOException If an error occurs while downloading or parsing the file */ @Override public String getVersion() throws IOException { - ensureDomainsFileInstalled(); + domainsFileLock.readLock().lock(); + logger.trace("LOCK readlock"); + try { + if( parsedVersion != null ) { + return parsedVersion; + } + } finally { + logger.trace("UNLOCK readlock"); + domainsFileLock.readLock().unlock(); + } + + // The version is declared in the first few lines of the file, so read those rather + // than the millions of domain records behind them. The current release is 657 MB and + // holds nearly three million records; parsing it in full to answer this question + // costs over a gigabyte of heap and several seconds. + ensureDomainsFileDownloaded(); + + domainsFileLock.writeLock().lock(); + logger.trace("LOCK writelock"); + try { + if( parsedVersion == null ) { + parsedVersion = parseVersionOnly(); + } + } finally { + logger.trace("UNLOCK writelock"); + domainsFileLock.writeLock().unlock(); + } if( parsedVersion == null) { return requestedVersion; @@ -285,6 +326,30 @@ public String getVersion() throws IOException { return parsedVersion; } + /** + * Reads the version from the header of the local domains file without parsing the + * domains themselves. + * @return the version, or null if the header does not declare one + * @throws IOException if the file cannot be read + * @since 7.3.0 + */ + private String parseVersionOnly() throws IOException { + try( BufferedReader in = new BufferedReader(new FileReader(getDomainFile())) ) { + String line; + while( (line = in.readLine()) != null ) { + Matcher match = EcodParser.VERSION_RE.matcher(line); + if( match.matches() ) { + return match.group(1); + } + if( !line.startsWith("#") ) { + // past the header block; from v294.1 the column names are not commented + return null; + } + } + } + return null; + } + /** * Get the top-level ECOD server URL. Defaults to "http://prodata.swmed.edu" * @return the url to the ecod server @@ -325,6 +390,24 @@ public void setCacheLocation(String cacheLocation) { domainsFileLock.writeLock().unlock(); } + /** + * Ensures the domains file is present and current locally, without parsing it. + * @throws IOException in cases of file I/O, including failure to download a healthy file + * @since 7.3.0 + */ + private void ensureDomainsFileDownloaded() throws IOException { + domainsFileLock.writeLock().lock(); + logger.trace("LOCK writelock"); + try { + if( !domainsAvailable() ) { + downloadDomains(); + } + } finally { + logger.trace("UNLOCK writelock"); + domainsFileLock.writeLock().unlock(); + } + } + /** * Blocks until ECOD domains file has been downloaded and parsed. * @@ -549,6 +632,24 @@ Current version (1.4) contains the following columns: v1.2 - added f-group identifiers to fasta file, domain description file. ECODf identifiers now used when available for F-group name. Domain assemblies now represented by assembly uid in domain assembly status. v1.4 - added seqid_range and headers (develop101) +v1.6 - renamed column 4 from f_id to t_id and inserted unp_acc (UniProt accession) as + column 9, giving 16 columns (seen in develop291) + +From v294.1 the distribution was redesigned. The header comment changed from +"#ECOD version develop291" to "# Version: v294.1", the column header row is no longer +commented out, and the columns became: + + uid ecod_domain_id manual_rep f_id pdb chain pdb_range seqid_range architecture_name + x_name h_name t_name f_name assembly_id domain_id_short range_count arch_manual + x_manual h_manual t_manual f_manual valid_structure ligand_binding + +v295 appends ligand_comp_ids and ligand_pdbnum, for 25 columns. Also note that +manual_rep now holds True/False rather than MANUAL_REP/AUTO_NONREP, that assembly_id +and domain_id_short are empty on every row, that f_name is empty rather than +F_UNCLASSIFIED for unclassified domains, and that uid restarts from 0. + +Because the columns have been renamed, reordered and added to repeatedly, files that +declare a column header are read by column name rather than by position. */ /** String for unclassified F-groups */ @@ -561,10 +662,28 @@ Current version (1.4) contains the following columns: public static final String IS_REPRESENTATIVE = "MANUAL_REP"; /** Indicates not a manual representative */ public static final String NOT_REPRESENTATIVE = "AUTO_NONREP"; + /** + * Matches the comment declaring the version, which has taken two forms: + * {@code #ECOD version develop291} up to develop292, and {@code # Version: v295} + * from v294.1 onwards. + * @since 7.3.0 + */ + static final Pattern VERSION_RE = Pattern.compile( + "^\\s*#\\s*(?:ECOD\\s+)?version\\s*:?\\s*(\\S+).*", Pattern.CASE_INSENSITIVE); private List domains; private String version; + // prevent too many warnings; negative numbers print all warnings + private int warnIsDomainAssembly = 1; + private int warnHierarchicalFormat = 5; + private int warnNumberOfFields = 10; + private int warnNumberFormat = 10; + /** Data lines that could not be turned into a domain, for the summary at the end */ + private int skippedLines = 0; + /** Data lines describing a domain in a computed model rather than a PDB entry */ + private int modelLines = 0; + public EcodParser(String filename) throws IOException { this(new File(filename)); } @@ -584,30 +703,55 @@ private void parse(BufferedReader in) throws IOException { // Allocate plenty of space for ECOD as of 2015 ArrayList domainsList = new ArrayList<>(500000); - Pattern versionRE = Pattern.compile("^\\s*#.*ECOD\\s*version\\s+(\\S+).*"); Pattern commentRE = Pattern.compile("^\\s*#.*"); - // prevent too many warnings; negative numbers print all warnings - int warnIsDomainAssembly = 1; - int warnHierarchicalFormat = 5; - int warnNumberOfFields = 10; + ColumnLayout layout = null; String line = in.readLine(); int lineNum = 1; while( line != null ) { // Check for requestedVersion string - Matcher match = versionRE.matcher(line); + Matcher match = VERSION_RE.matcher(line); if(match.matches()) { // special requestedVersion comment this.version = match.group(1); + } else if( ColumnLayout.isColumnHeader(line) ) { + // The column names. Since the columns have been renamed, reordered and + // added to several times, later lines are read by name rather than by + // position wherever this header is present (develop101 onwards). + layout = ColumnLayout.fromHeader(line); + logger.debug("Read ECOD column header at line {}: {} columns",lineNum,layout.size()); } else { match = commentRE.matcher(line); if(match.matches()) { // ignore comments } else { - // data line - String[] fields = line.split("\t"); - if( fields.length == 13 || fields.length == 14 || fields.length == 15) { + // data line. The last column is frequently empty, so keep trailing + // empty fields rather than letting split() discard them. + String[] fields = line.split("\t", -1); + if( layout != null ) { + String pdb = layout.get(fields, "pdb"); + if( pdb != null && pdb.isEmpty() ) { + // From v294.1 the distribution also classifies domains + // found in computed (AlphaFold) models, which have no PDB + // entry and so cannot be represented by an EcodDomain. + modelLines++; + } else { + try { + EcodDomain domain = parseDomain(fields, layout, lineNum); + if(domain != null) { + domainsList.add(domain); + } else { + skippedLines++; + warnMissingColumns(lineNum); + } + } catch(IllegalArgumentException e) { + // includes NumberFormatException and an unusable PDB id + skippedLines++; + warnUnparseableLine(lineNum, e); + } + } + } else if( fields.length == 13 || fields.length == 14 || fields.length == 15) { try { int i = 0; // field number, to allow future insertion of fields @@ -620,32 +764,16 @@ private void parse(BufferedReader in) throws IOException { // Manual column may be missing in version 1.0 files Boolean manual = null; if( fields.length >= 14) { - String manualString = fields[i++]; - if(manualString.equalsIgnoreCase(IS_REPRESENTATIVE)) { - manual = true; - } else if(manualString.equalsIgnoreCase(NOT_REPRESENTATIVE)) { - manual = false; - } else { - logger.warn("Unexpected value for manual field: {} in line {}",manualString,lineNum); - } + manual = parseManualRep(fields[i++], lineNum); } //Column 4: ECOD hierachy identifier - [X-group].[H-group].[T-group].[F-group] // hierarchical field, e.g. "1.1.4.1" - String[] xhtGroup = fields[i++].split("\\."); - if(xhtGroup.length < 3 || 4 < xhtGroup.length) { - if(warnHierarchicalFormat > 1) { - logger.warn("Unexpected format for hierarchical field \"{}\" in line {}",fields[i-1],lineNum); - warnHierarchicalFormat--; - } else if(warnHierarchicalFormat != 0) { - logger.warn("Unexpected format for hierarchical field \"{}\" in line {}. Not printing future similar warnings.",fields[i-1],lineNum); - warnHierarchicalFormat--; - } - } - Integer xGroup = xhtGroup.length>0 ? Integer.parseInt(xhtGroup[0]) : null; - Integer hGroup = xhtGroup.length>1 ? Integer.parseInt(xhtGroup[1]) : null; - Integer tGroup = xhtGroup.length>2 ? Integer.parseInt(xhtGroup[2]) : null; - Integer fGroup = xhtGroup.length>3 ? Integer.parseInt(xhtGroup[3]) : null; + Integer[] xhtfGroup = parseHierarchy(fields[i++], lineNum); + Integer xGroup = xhtfGroup[0]; + Integer hGroup = xhtfGroup[1]; + Integer tGroup = xhtfGroup[2]; + Integer fGroup = xhtfGroup[3]; //Column 5: PDB identifier String pdbId = fields[i++]; @@ -699,32 +827,18 @@ private void parse(BufferedReader in) throws IOException { assemblyId = Long.parseLong(assemblyStr); } - String ligandStr = fields[i++]; - Set ligands = null; - if( "NO_LIGANDS_4A".equals(ligandStr) || ligandStr.isEmpty() ) { - ligands = Collections.emptySet(); - } else { - String[] ligSplit = ligandStr.split(","); - ligands = new LinkedHashSet<>(ligSplit.length); - for(String s : ligSplit) { - ligands.add(s.intern()); - } - } + Set ligands = parseLigands(fields[i++]); EcodDomain domain = new EcodDomain(uid, domainId, manual, xGroup, hGroup, tGroup, fGroup,pdbId, chainId, range, seqId, architectureName, xGroupName, hGroupName, tGroupName, fGroupName, assemblyId, ligands); domainsList.add(domain); } catch(NumberFormatException e) { - logger.warn("Error in ECOD parsing at line "+lineNum,e); + skippedLines++; + warnUnparseableLine(lineNum, e); } } else { - if(warnNumberOfFields > 1) { - logger.warn("Unexpected number of fields in line {}.",lineNum); - warnNumberOfFields--; - } else if(warnNumberOfFields == 0) { - logger.warn("Unexpected number of fields in line {}. Not printing future similar warnings",lineNum); - warnIsDomainAssembly--; - } + skippedLines++; + warnMissingColumns(lineNum); } } } @@ -737,6 +851,23 @@ private void parse(BufferedReader in) throws IOException { else logger.info("Parsed {} ECOD domains from version {}",domainsList.size(),this.version); + if(modelLines > 0) { + logger.info("Ignored {} ECOD domains classified from computed models, " + + "which have no PDB entry", modelLines); + } + + if(domainsList.isEmpty() && skippedLines > 0) { + // Returning an empty list quietly is how an upstream format change went + // unnoticed for eight months. Say so instead. + logger.error("Parsed no ECOD domains from {} data lines of version {}. " + + "The file format has probably changed; please report this at " + + "https://github.com/biojava/biojava/issues", skippedLines, + this.version == null ? "unknown" : this.version); + } else if(skippedLines > 0) { + logger.warn("Skipped {} of {} ECOD data lines that could not be parsed", + skippedLines, skippedLines + domainsList.size()); + } + this.domains = Collections.unmodifiableList( domainsList ); @@ -747,6 +878,169 @@ private void parse(BufferedReader in) throws IOException { } } + /** + * Builds a domain from a data line using the column names the file declares in its + * header, rather than fixed offsets. This is what allows one parser to read the + * 15-column develop101 layout, the 16-column develop291 layout (which inserts + * {@code unp_acc}) and the 23- and 25-column v294.1 and v295 layouts. + * @param fields the tab-separated values of one data line + * @param layout the column names read from the file's header + * @param lineNum the line number, for warnings + * @return the domain, or null if the line does not carry every required column + * @throws NumberFormatException if a numeric column does not hold a number + * @since 7.3.0 + */ + private EcodDomain parseDomain(String[] fields, ColumnLayout layout, int lineNum) { + String uidStr = layout.get(fields, "uid"); + String domainId = layout.get(fields, "ecod_domain_id"); + // renamed from t_id to f_id when the hierarchy gained a fourth level + String hierarchy = layout.get(fields, "f_id", "t_id"); + String pdbId = layout.get(fields, "pdb"); + String chainId = layout.get(fields, "chain"); + String range = layout.get(fields, "pdb_range"); + if( uidStr == null || domainId == null || hierarchy == null + || pdbId == null || chainId == null || range == null ) { + return null; + } + + Long uid = Long.parseLong(uidStr); + Boolean manual = parseManualRep(layout.get(fields, "manual_rep"), lineNum); + Integer[] xhtfGroup = parseHierarchy(hierarchy, lineNum); + // absent before version 1.4 + String seqId = layout.get(fields, "seqid_range"); + + String architectureName = internName(layout.get(fields, "architecture_name", "arch_name")); + String xGroupName = internName(layout.get(fields, "x_name")); + String hGroupName = internName(layout.get(fields, "h_name")); + String tGroupName = internName(layout.get(fields, "t_name")); + // Up to develop292 an unclassified domain carried F_UNCLASSIFIED here. From + // v294.1 the name is simply empty, while f_id still classifies the domain to + // four levels, so the two are no longer equivalent and the empty value is + // deliberately left as it is rather than translated. + String fGroupName = internName(layout.get(fields, "f_name")); + + // v294.1 and later declare assembly_id but leave it empty on every row, which + // means the same as the NOT_DOMAIN_ASSEMBLY of earlier versions. + Long assemblyId = null; + String assemblyStr = layout.get(fields, "assembly_id", "asm_status"); + if( assemblyStr == null || assemblyStr.isEmpty() || NOT_DOMAIN_ASSEMBLY.equals(assemblyStr) ) { + assemblyId = uid; + } else if( IS_DOMAIN_ASSEMBLY.equals(assemblyStr) ) { + warnDomainAssembly(lineNum); + } else { + assemblyId = Long.parseLong(assemblyStr); + } + + // the ligand list moved from the last column to ligand_comp_ids in v295 + Set ligands = parseLigands(layout.get(fields, "ligand_comp_ids", "ligand")); + + return new EcodDomain(uid, domainId, manual, xhtfGroup[0], xhtfGroup[1], xhtfGroup[2], + xhtfGroup[3], pdbId, chainId, range, seqId, architectureName, xGroupName, + hGroupName, tGroupName, fGroupName, assemblyId, ligands); + } + + /** + * Reads the representative-status column, which held MANUAL_REP or AUTO_NONREP up to + * develop292 and True or False from v294.1 onwards. + * @return true, false, or null if the column is absent or unrecognised + * @since 7.3.0 + */ + private Boolean parseManualRep(String manualString, int lineNum) { + if(manualString == null) { + return null; + } + if(manualString.equalsIgnoreCase(IS_REPRESENTATIVE) || manualString.equalsIgnoreCase("true")) { + return true; + } + if(manualString.equalsIgnoreCase(NOT_REPRESENTATIVE) || manualString.equalsIgnoreCase("false")) { + return false; + } + logger.warn("Unexpected value for manual field: {} in line {}",manualString,lineNum); + return null; + } + + /** + * Splits the hierarchical identifier, e.g. "1.1.4.1". + * @return the X, H, T and F group numbers, any of which may be null if absent + * @since 7.3.0 + */ + private Integer[] parseHierarchy(String hierarchy, int lineNum) { + String[] xhtGroup = hierarchy.split("\\."); + if(xhtGroup.length < 3 || 4 < xhtGroup.length) { + if(warnHierarchicalFormat > 1) { + logger.warn("Unexpected format for hierarchical field \"{}\" in line {}",hierarchy,lineNum); + warnHierarchicalFormat--; + } else if(warnHierarchicalFormat != 0) { + logger.warn("Unexpected format for hierarchical field \"{}\" in line {}. Not printing future similar warnings.",hierarchy,lineNum); + warnHierarchicalFormat--; + } + } + Integer[] groups = new Integer[4]; + for(int j = 0; j < groups.length && j < xhtGroup.length; j++) { + groups[j] = Integer.parseInt(xhtGroup[j]); + } + return groups; + } + + /** + * Reads a comma-separated list of non-polymer entities close to the domain. + * @return the ligands, or an empty set for NO_LIGANDS_4A, an empty value or no column + * @since 7.3.0 + */ + private Set parseLigands(String ligandStr) { + if( ligandStr == null || ligandStr.isEmpty() || "NO_LIGANDS_4A".equals(ligandStr) ) { + return Collections.emptySet(); + } + String[] ligSplit = ligandStr.split(","); + Set ligands = new LinkedHashSet<>(ligSplit.length); + for(String s : ligSplit) { + ligands.add(s.intern()); + } + return ligands; + } + + /** + * Interns a name likely to be shared by many domains, stripping the quotes that + * versions up to develop292 wrapped some of them in. + * @since 7.3.0 + */ + private String internName(String name) { + if(name == null) { + return null; + } + return clearStringQuotes(name).intern(); + } + + private void warnDomainAssembly(int lineNum) { + if(warnIsDomainAssembly > 1) { + logger.info("Deprecated 'IS_DOMAIN_ASSEMBLY' value ignored in line {}.",lineNum); + warnIsDomainAssembly--; + } else if(warnIsDomainAssembly == 0) { + logger.info("Deprecated 'IS_DOMAIN_ASSEMBLY' value ignored in line {}. Not printing future similar warnings.",lineNum); + warnIsDomainAssembly--; + } + } + + private void warnMissingColumns(int lineNum) { + if(warnNumberOfFields > 1) { + logger.warn("Unexpected number of fields in line {}.",lineNum); + warnNumberOfFields--; + } else if(warnNumberOfFields == 1) { + logger.warn("Unexpected number of fields in line {}. Not printing future similar warnings",lineNum); + warnNumberOfFields--; + } + } + + private void warnUnparseableLine(int lineNum, IllegalArgumentException e) { + if(warnNumberFormat > 1) { + logger.warn("Error in ECOD parsing at line {}: {}", lineNum, e.getMessage()); + warnNumberFormat--; + } else if(warnNumberFormat == 1) { + logger.warn("Error in ECOD parsing at line {}: {}. Not printing future similar warnings", lineNum, e.getMessage()); + warnNumberFormat--; + } + } + private String clearStringQuotes(String name) { if ( name.startsWith("\"")) name = name.substring(1); @@ -770,6 +1064,77 @@ public List getDomains() { public String getVersion() { return version; } + + /** + * Maps the column names an ECOD domain file declares in its header onto their + * positions, so a data line can be read by name rather than by offset. + *

+ * Every distribution since develop101 carries such a header. It is commented + * (#uid<tab>ecod_domain_id<tab>...) up to develop292 and + * uncommented (uid<tab>ecod_domain_id<tab>...) from v294.1 + * onwards. Because names have also been changed between versions, lookups accept + * aliases and any name the file does not declare simply reads as absent. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + private static class ColumnLayout { + private final Map columns; + + private ColumnLayout(Map columns) { + this.columns = columns; + } + + /** + * @return true if this line names the columns rather than holding domain data + */ + public static boolean isColumnHeader(String line) { + int tab = line.indexOf('\t'); + if(tab < 0) { + return false; + } + String first = line.substring(0, tab).trim(); + if(first.startsWith("#")) { + first = first.substring(1).trim(); + } + return first.equalsIgnoreCase("uid"); + } + + public static ColumnLayout fromHeader(String line) { + String[] names = line.split("\t", -1); + Map columns = new HashMap<>(names.length * 2); + for(int i = 0; i < names.length; i++) { + String name = names[i].trim(); + if(i == 0 && name.startsWith("#")) { + name = name.substring(1).trim(); + } + if(!name.isEmpty()) { + columns.put(name.toLowerCase(), i); + } + } + return new ColumnLayout(columns); + } + + /** + * @param fields the values of one data line + * @param aliases the names this column has gone by, most recent first + * @return the value of the first alias the file declares, or null if it declares + * none of them or this line is too short to reach it + */ + public String get(String[] fields, String... aliases) { + for(String alias : aliases) { + Integer i = columns.get(alias); + if(i != null) { + return i < fields.length ? fields[i] : null; + } + } + return null; + } + + public int size() { + return columns.size(); + } + } } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/geometry/MomentsOfInertia.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/geometry/MomentsOfInertia.java index 8cfd032daa..5a6e69c4cf 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/geometry/MomentsOfInertia.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/geometry/MomentsOfInertia.java @@ -72,7 +72,7 @@ public void addPoint(Point3d point, double mass) { public Point3d getCenterOfMass() { - if (points.size() == 0) { + if (points.isEmpty()) { throw new IllegalStateException( "MomentsOfInertia: no points defined"); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/BondMaker.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/BondMaker.java index e6b8548025..b7c9a84dea 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/BondMaker.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/BondMaker.java @@ -273,7 +273,7 @@ private void trimBondLists() { for (Chain chain : structure.getChains(modelInd)) { for (Group group : chain.getAtomGroups()) { for (Atom atom : group.getAtoms()) { - if (atom.getBonds()!=null && atom.getBonds().size() > 0) { + if (atom.getBonds()!=null && !atom.getBonds().isEmpty()) { ((ArrayList) atom.getBonds()).trimToSize(); } } @@ -463,14 +463,24 @@ public void formBondsFromStructConn(StructConn conn) { } catch (StructureException e) { - logger.warn("Could not find atom specified in struct_conn record: {}{}({}) in chain {}, atom {} {}", seqId1, insCode1, resName1, chainId1, atomName1, altLocStr1); + // Note, in Calpha only mode the struct_conn atoms may not be present. + if (! params.isParseCAOnly()) { + logger.warn("Could not find atom specified in struct_conn record: {}{}({}) in chain {}, atom {} {}", seqId1, insCode1, resName1, chainId1, atomName1, altLocStr1); + } else { + logger.debug("Could not find atom specified in struct_conn record while parsing in parseCAonly mode: {}{}({}) in chain {}, atom {} {}", seqId1, insCode1, resName1, chainId1, atomName1, altLocStr1); + } continue; } try { a2 = getAtomFromRecord(atomName2, altLoc2, chainId2, seqId2, insCode2); } catch (StructureException e) { - logger.warn("Could not find atom specified in struct_conn record: {}{}({}) in chain {}, atom {} {}", seqId2, insCode2, resName2, chainId2, atomName2, altLocStr2); + // Note, in Calpha only mode the struct_conn atoms may not be present. + if (! params.isParseCAOnly()) { + logger.warn("Could not find atom specified in struct_conn record: {}{}({}) in chain {}, atom {} {}", seqId2, insCode2, resName2, chainId2, atomName2, altLocStr2); + } else { + logger.debug("Could not find atom specified in struct_conn record while parsing in parseCAonly mode: {}{}({}) in chain {}, atom {} {}", seqId2, insCode2, resName2, chainId2, atomName2, altLocStr2); + } continue; } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/LocalPDBDirectory.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/LocalPDBDirectory.java index 4ec4577f59..2e6b09fe9a 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/LocalPDBDirectory.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/LocalPDBDirectory.java @@ -550,7 +550,7 @@ private File downloadStructure(PdbId pdbId, String pathOnServer, boolean obsolet ftp = DEFAULT_BCIF_FILE_SERVER + filename; } else { ftp = String.format("%s%s/%s/%s", - serverName, pathOnServer, id.substring(id.length()-3, id.length()-1), getFilename(id)); + serverName, pathOnServer, getMiddleHash(id), getFilename(id)); } URL url = new URL(ftp); @@ -576,21 +576,45 @@ private File downloadStructure(PdbId pdbId, String pathOnServer, boolean obsolet logger.info("Fetching {}", ftp); logger.info("Writing to {}", realFile); - FileDownloadUtils.createValidationFiles(url, realFile, null, FileDownloadUtils.Hash.UNKNOWN); - FileDownloadUtils.downloadFile(url, realFile); + // A single connection, so the recorded size and checksum describe exactly the + // bytes that were written. The wwPDB servers return the content MD5 as the + // ETag, so this also gives the cached file a real integrity check. + FileDownloadUtils.downloadFileWithValidation(url, realFile, null, FileDownloadUtils.Hash.UNKNOWN, + FileDownloadUtils.ETagPolicy.USE_IF_HEX_DIGEST); if(! FileDownloadUtils.validateFile(realFile)) throw new IOException("Downloaded file invalid: "+realFile); return realFile; } + /** + * Returns the two-character directory name under which an entry is filed in the + * PDB's divided layout, e.g. cb for 1cbs. + *

+ * The characters are taken relative to the end of the identifier rather + * than the start, so that both spellings of the same entry land in the same + * bucket: 1cbs and its extended form pdb_00001cbs both + * give cb. Taking them from the start would file the extended form + * under db instead. The extended PDB identifier format is expected + * to keep using this same hashing scheme. + * + * @param pdbId a PDB identifier, in either the short or the extended form + * @return the lowercase two-character directory name + * @since 7.3.0 + */ + public static String getMiddleHash(String pdbId) { + int offset = pdbId.length() - 3; + return pdbId.substring(offset, offset + 2).toLowerCase(); + } + /** * Get the last modified time of the file in given url by retrieveing the "Last-Modified" header. * Note that this only works for http URLs * @param url * @return the last modified date or null if it couldn't be retrieved (in that case a warning will be logged) + * @since 7.3.0 made public so that other caching code can reuse it */ - private Date getLastModifiedTime(URL url) { + public static Date getLastModifiedTime(URL url) { // see http://stackoverflow.com/questions/2416872/how-do-you-obtain-modified-date-from-a-remote-file-java Date date = null; @@ -629,14 +653,12 @@ private Date getLastModifiedTime(URL url) { protected File getDir(String pdbId, boolean obsolete) { File dir = null; - int offset = pdbId.length() - 3; + String middle = getMiddleHash(pdbId); if (obsolete) { // obsolete is always split - String middle = pdbId.substring(offset, offset + 2).toLowerCase(); dir = new File(obsoleteDirPath, middle); } else { - String middle = pdbId.substring(offset, offset + 2).toLowerCase(); dir = new File(splitDirPath, middle); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/PDBFileParser.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/PDBFileParser.java index 176459bbf2..b1d327599e 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/PDBFileParser.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/PDBFileParser.java @@ -1406,7 +1406,7 @@ public void handleResolutionLine(String line, Pattern pR) { try { float res = Float.parseFloat(resString); final float resInHeader = pdbHeader.getResolution(); - if (resInHeader!=PDBHeader.DEFAULT_RESOLUTION && resInHeader != res) { + if (resInHeader!=PDBHeader.DEFAULT_RESOLUTION && Math.abs(resInHeader - res) > 0.001) { logger.warn("More than 1 resolution value present, will use last one {} and discard previous {} " ,resString, String.format("%4.2f",resInHeader)); } @@ -1943,7 +1943,7 @@ private Group getCorrectAltLocGroup( Character altLoc, // see if we know this altLoc already; List atoms = currentGroup.getAtoms(); - if ( atoms.size() > 0) { + if (!atoms.isEmpty()) { Atom a1 = atoms.get(0); // we are just adding atoms to the current group // probably there is a second group following later... @@ -1956,7 +1956,7 @@ private Group getCorrectAltLocGroup( Character altLoc, List altLocs = currentGroup.getAltLocs(); for ( Group altLocG : altLocs ){ atoms = altLocG.getAtoms(); - if ( atoms.size() > 0) { + if (!atoms.isEmpty()) { for ( Atom a1 : atoms) { if (a1.getAltLoc().equals( altLoc)) { @@ -1970,7 +1970,7 @@ private Group getCorrectAltLocGroup( Character altLoc, // build it up. if ( groupCode3.equals(currentGroup.getPDBName())) { - if ( currentGroup.getAtoms().size() == 0) { + if ( currentGroup.getAtoms().isEmpty()) { //System.out.println("current group is empty " + current_group + " " + altLoc); return currentGroup; } @@ -2762,7 +2762,7 @@ private void makeCompounds(List compoundList, } // System.out.println("[makeCompounds] adding sources to compounds from sourceLines"); // since we're starting again from the first compound, reset it here - if ( entities.size() == 0){ + if ( entities.isEmpty()){ current_compound = new EntityInfo(); } else { current_compound = entities.get(0); @@ -2921,7 +2921,7 @@ private void triggerEndFileChecks(){ pdbHeader.setBioAssemblies(bioAssemblyParser.getTransformationMap()); } - if (ncsOperators !=null && ncsOperators.size()>0) { + if (ncsOperators !=null && !ncsOperators.isEmpty()) { crystallographicInfo.setNcsOperators( ncsOperators.toArray(new Matrix4d[ncsOperators.size()])); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/SeqRes2AtomAligner.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/SeqRes2AtomAligner.java index 7ee21de4b4..0652ad1809 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/SeqRes2AtomAligner.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/SeqRes2AtomAligner.java @@ -198,7 +198,7 @@ public void mapSeqresRecords(Chain atomRes, Chain seqRes) { } } - if ( atomRes.getAtomGroups(GroupType.AMINOACID).size() < 1) { + if (atomRes.getAtomGroups(GroupType.AMINOACID).isEmpty()) { logger.debug("ATOM chain {} does not contain amino acids, ignoring...", atomRes.getId()); return; } @@ -215,7 +215,7 @@ public void mapSeqresRecords(Chain atomRes, Chain seqRes) { private void alignNucleotideChains(Chain seqRes, Chain atomRes) { - if ( atomRes.getAtomGroups(GroupType.NUCLEOTIDE).size() < 1) { + if (atomRes.getAtomGroups(GroupType.NUCLEOTIDE).isEmpty()) { logger.debug("ATOM chain {} does not contain nucleotides, ignoring...", atomRes.getId()); return; diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/cif/AbstractCifFileSupplier.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/cif/AbstractCifFileSupplier.java index 7e9d8ad7ac..e43565c827 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/cif/AbstractCifFileSupplier.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/cif/AbstractCifFileSupplier.java @@ -40,8 +40,27 @@ protected CifFile getInternal(Structure structure, List wrappedAtom // entity information List entityInfos = structure.getEntityInfos(); + PdbId pdbId = structure.getPdbId(); + MmCifBlockBuilder blockBuilder = CifBuilder.enterFile(StandardSchemata.MMCIF) - .enterBlock(structure.getPdbId() == null? "" : structure.getPdbId().getId()); + .enterBlock(pdbId == null? "" : pdbId.getId()); + + if (pdbId != null) { + // The block header alone does not carry the identifier for consumers: readers pick it up from + // _entry.id (e.g. Jmol) or from _struct.entry_id (BioJava's own CifStructureConsumerImpl). + // Both are written so that the identifier survives a write-then-read round trip either way. + blockBuilder.enterEntry() + .enterId() + .add(pdbId.getId()) + .leaveColumn() + .leaveCategory(); + + blockBuilder.enterStruct() + .enterEntryId() + .add(pdbId.getId()) + .leaveColumn() + .leaveCategory(); + } blockBuilder.enterStructKeywords().enterText() .add(String.join(", ", structure.getPDBHeader().getKeywords())) @@ -309,7 +328,8 @@ public void accept(WrappedAtom wrappedAtom) { } labelEntityId.add(entityId); // see https://github.com/biojava/biojava/issues/1116 - if (chain.getEntityInfo().getType() == EntityType.POLYMER) { + // note the first condition is to safeguard and to have a default that writes labelSeqId if there's no knowledge about what's the entity type + if (chain.getEntityInfo()==null || chain.getEntityInfo().getType() == EntityType.POLYMER) { labelSeqId.add(seqId); } else { labelSeqId.markNextNotPresent(); diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/cif/CifStructureConsumerImpl.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/cif/CifStructureConsumerImpl.java index 67514edd84..28bcb420a7 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/cif/CifStructureConsumerImpl.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/cif/CifStructureConsumerImpl.java @@ -205,6 +205,13 @@ public void consumeAtomSite(AtomSite atomSite) { IntColumn pdbx_pdb_model_num = atomSite.getPdbxPDBModelNum(); for (int atomIndex = 0; atomIndex < atomSite.getRowCount(); atomIndex++) { + // skip before any chain or group is set up, so that groups and chains without a + // C-alpha (waters, ligands, nucleotides) are not created at all + if (params.isParseCAOnly() && + !(labelAtomId.get(atomIndex).equals(StructureTools.CA_ATOM_NAME) && "C".equals(typeSymbol.get(atomIndex)))) { + continue; + } + boolean startOfNewChain = false; Character oneLetterCode = StructureTools.get1LetterCodeAmino(labelCompId.get(atomIndex)); @@ -318,12 +325,6 @@ public void consumeAtomSite(AtomSite atomSite) { } } - if (params.isParseCAOnly()) { - if (!labelAtomId.get(atomIndex).equals(StructureTools.CA_ATOM_NAME) && "C".equals(typeSymbol.get(atomIndex))) { - continue; - } - } - Atom atom = new AtomImpl(); atom.setPDBserial(id.get(atomIndex)); @@ -372,7 +373,7 @@ public void consumeAtomSite(AtomSite atomSite) { private Group getAltLocGroup(String recordName, Character altLoc, Character oneLetterCode, String threeLetterCode, long seqId) { List atoms = currentGroup.getAtoms(); - if (atoms.size() > 0) { + if (!atoms.isEmpty()) { if (atoms.get(0).getAltLoc().equals(altLoc)) { return currentGroup; } @@ -381,7 +382,7 @@ private Group getAltLocGroup(String recordName, Character altLoc, Character oneL List altLocs = currentGroup.getAltLocs(); for (Group altLocGroup : altLocs) { atoms = altLocGroup.getAtoms(); - if (atoms.size() > 0) { + if (!atoms.isEmpty()) { for (Atom a1 : atoms) { if (a1.getAltLoc().equals(altLoc)) { return altLocGroup; @@ -630,7 +631,8 @@ public void consumeDatabasePDBRev(DatabasePDBRev databasePDBrev) { modDate = relDate; } else { String dbrev = databasePDBrev.getDate().get(rowIndex); - modDate = convert(LocalDate.parse(dbrev, DATE_FORMAT)); + if (dbrev != null && !dbrev.isBlank()) + modDate = convert(LocalDate.parse(dbrev, DATE_FORMAT)); } pdbHeader.setModDate(modDate); } @@ -855,7 +857,8 @@ public void consumeRefine(Refine refine) { // we take the last one found so that behaviour is like in PDB file parsing double lsDResHigh = refine.getLsDResHigh().get(rowIndex); // TODO this could use a check to keep reasonable values - 1.5 may be overwritten by 0.0 - if (pdbHeader.getResolution() != PDBHeader.DEFAULT_RESOLUTION) { + if (pdbHeader.getResolution() != PDBHeader.DEFAULT_RESOLUTION && + Math.abs(pdbHeader.getResolution() - lsDResHigh) > 0.001) { logger.warn("More than 1 resolution value present, will use last one {} and discard previous {}", lsDResHigh, String.format("%4.2f",pdbHeader.getResolution())); } @@ -1478,7 +1481,7 @@ private void setStructNcsOps() { } } - if (ncsOperators.size() > 0) { + if (!ncsOperators.isEmpty()) { structure.getCrystallographicInfo() .setNcsOperators(ncsOperators.toArray(new Matrix4d[0])); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/AbstractDensityMapProvider.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/AbstractDensityMapProvider.java new file mode 100644 index 0000000000..3ac4057ba2 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/AbstractDensityMapProvider.java @@ -0,0 +1,360 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.util.Date; + +import org.biojava.nbio.core.util.FileDownloadUtils; +import org.biojava.nbio.core.util.HttpStatusException; +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.io.LocalPDBDirectory; +import org.biojava.nbio.structure.io.LocalPDBDirectory.FetchBehavior; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared machinery for the concrete density map providers: cache lookup, + * download with validation, size limiting and format sanity checking. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public abstract class AbstractDensityMapProvider implements DensityMapProvider { + + private static final Logger logger = LoggerFactory.getLogger(AbstractDensityMapProvider.class); + + /** + * Anything smaller than this is not a usable map. Density servers answer with a + * short body rather than an error status when they have nothing, so a size + * floor is a necessary part of deciding whether a download succeeded. + */ + public static final long MIN_DENSITY_FILE_SIZE = 1024L; + + private File cacheRoot; + private FetchBehavior fetchBehavior = FetchBehavior.FETCH_FILES; + private long maxDownloadBytes = DensityMapCache.DEFAULT_MAX_DOWNLOAD_BYTES; + + /** + * @param cacheRoot the BioJava cache directory that the density directory sits in + */ + protected AbstractDensityMapProvider(File cacheRoot) { + this.cacheRoot = cacheRoot; + } + + /** @return the BioJava cache directory */ + public File getCacheRoot() { + return cacheRoot; + } + + /** @param cacheRoot the BioJava cache directory */ + public void setCacheRoot(File cacheRoot) { + this.cacheRoot = cacheRoot; + } + + /** @return how aggressively this provider re-fetches */ + public FetchBehavior getFetchBehavior() { + return fetchBehavior; + } + + /** @param fetchBehavior how aggressively to re-fetch */ + public void setFetchBehavior(FetchBehavior fetchBehavior) { + this.fetchBehavior = fetchBehavior == null ? FetchBehavior.FETCH_FILES : fetchBehavior; + } + + /** @return the download size limit in bytes, or 0 for no limit */ + public long getMaxDownloadBytes() { + return maxDownloadBytes; + } + + /** @param maxDownloadBytes the download size limit in bytes, or 0 for no limit */ + public void setMaxDownloadBytes(long maxDownloadBytes) { + this.maxDownloadBytes = maxDownloadBytes; + } + + @Override + public boolean supports(DensityMapKind kind) { + return kind != null && kind != DensityMapKind.AUTO; + } + + /** + * The identifier spelling to put into a URL: four characters and lower case + * where the entry has a short form. + * + * @param pdbId the entry + * @return the identifier for use in a URL + */ + protected String urlId(PdbId pdbId) { + return DensityCacheLayout.shortIdOrFull(pdbId).toLowerCase(); + } + + /** + * The effective fetch behaviour for a request, preferring the request's own + * setting over this provider's. + * + * @param request the request + * @return the behaviour to apply + */ + protected FetchBehavior effectiveFetchBehavior(DensityMapRequest request) { + return request.getFetchBehavior() == null ? fetchBehavior : request.getFetchBehavior(); + } + + /** + * The effective size limit for a request, preferring the request's own setting. + * + * @param request the request + * @return the limit in bytes, or 0 for no limit + */ + protected long effectiveMaxBytes(DensityMapRequest request) { + return request.getMaxDownloadBytes() < 0 ? maxDownloadBytes : request.getMaxDownloadBytes(); + } + + /** + * The cache directory to use for a request, preferring the request's override. + * + * @param request the request + * @return the cache directory + */ + protected File effectiveCacheRoot(DensityMapRequest request) { + return request.getCacheDir() == null ? cacheRoot : request.getCacheDir(); + } + + /** + * Obtains a map, serving it from the cache when the fetch behaviour permits and + * downloading it otherwise. + * + * @param request what was asked for + * @param url where to fetch from + * @param target where to cache it + * @param kind the concrete kind of map being fetched + * @param emdbId the EMDB entry, if this is an EM map; otherwise null + * @param contourLevel the author-recommended contour level, or null + * @param sigma the map RMS deviation, or null + * @return the result, or null if the source has nothing for this entry + * @throws IOException on transport failure + */ + protected DensityMapResult obtain(DensityMapRequest request, URL url, File target, DensityMapKind kind, + String emdbId, Double contourLevel, Double sigma) throws IOException { + + FetchBehavior behavior = effectiveFetchBehavior(request); + + if (isCacheUsable(target, url, behavior)) { + // The kind always comes from the request, never from the sidecar: a single + // cached file can hold more than one kind of map, so the sidecar's kind + // records what was asked for first, not what the caller wants now. + DensityMapResult cached = DensityMapResult.readMeta(target); + Double cachedContour = contourLevel != null || cached == null ? contourLevel + : cached.getRecommendedContourLevel(); + Double cachedSigma = sigma != null || cached == null ? sigma : cached.getSigma(); + DensityMapResult result = new DensityMapResult(target, getSource(), getFormat(), kind, + request.getPdbId(), emdbId, url.toString(), true, cachedContour, cachedSigma); + if (cached == null) { + // The file is good but its description was lost; write one rather than + // downloading several megabytes again. + result.writeMeta(); + } + return result; + } + + if (behavior == FetchBehavior.LOCAL_ONLY) { + throw new HttpStatusException(404, url.toString(), + "not in the local cache and downloads are disabled (FetchBehavior.LOCAL_ONLY)"); + } + + File dir = target.getAbsoluteFile().getParentFile(); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Could not create density cache directory " + dir); + } + + enforceSizeLimit(url, effectiveMaxBytes(request), request); + + logger.info("Fetching {} density map for {} from {}", kind, + request.getPdbId() == null ? emdbId : request.getPdbId().getId(), url); + FileDownloadUtils.downloadFileWithValidation(url, target, null, FileDownloadUtils.Hash.UNKNOWN, + FileDownloadUtils.ETagPolicy.USE_IF_HEX_DIGEST); + + if (!isPlausibleMap(target)) { + // Do not leave a bad file behind to be picked up as a cache hit later. + deleteWithSidecars(target); + throw new IOException("The content downloaded from " + url + " is not a usable " + + getFormat() + " density map."); + } + + DensityMapResult result = new DensityMapResult(target, getSource(), getFormat(), kind, + request.getPdbId(), emdbId, url.toString(), false, contourLevel, sigma); + result.writeMeta(); + return result; + } + + /** + * Whether a cached file may be used as-is. + * + * @param target the cached file + * @param url where it came from + * @param behavior the fetch behaviour in force + * @return true if the cached file should be served + */ + protected boolean isCacheUsable(File target, URL url, FetchBehavior behavior) { + if (behavior == FetchBehavior.FORCE_DOWNLOAD) { + return false; + } + if (!target.isFile() || target.length() < MIN_DENSITY_FILE_SIZE) { + return false; + } + if (!FileDownloadUtils.validateFile(target)) { + logger.info("Cached density map [{}] failed validation and will be re-downloaded.", target); + return false; + } + if (!isPlausibleMap(target)) { + logger.info("Cached file [{}] is not a usable {} map and will be re-downloaded.", target, getFormat()); + return false; + } + if (behavior == FetchBehavior.LOCAL_ONLY) { + return true; + } + if (behavior == FetchBehavior.FETCH_IF_OUTDATED) { + Date serverDate = LocalPDBDirectory.getLastModifiedTime(url); + if (serverDate == null) { + // Density servers generate their responses on the fly and send no + // Last-Modified header. Treating that as "outdated" would re-download + // on every single call, so an unknown timestamp keeps the cache. + logger.debug("No server timestamp for {}; keeping the cached copy.", url); + return true; + } + return target.lastModified() >= serverDate.getTime(); + } + // FETCH_FILES, and FETCH_REMEDIATED which has no meaning for maps. + return true; + } + + /** + * Checks that a file looks like the format this provider delivers. Only the + * CCP4/MRC formats carry a recognisable stamp; the others are accepted on size + * alone. + * + * @param file the file to check + * @return true if the file is plausibly a map of this format + */ + protected boolean isPlausibleMap(File file) { + if (file == null || file.length() < MIN_DENSITY_FILE_SIZE) { + return false; + } + DensityFileFormat format = getFormat(); + if (format == DensityFileFormat.CCP4 || format == DensityFileFormat.CCP4_GZ) { + return Ccp4Header.isCcp4Quietly(file); + } + return true; + } + + /** + * Refuses a download whose declared size exceeds the limit, before any of the + * body is transferred. + * + * @param url the resource + * @param maxBytes the limit, or 0 for no limit + * @param request the request being served, used for reporting + * @throws DensityMapTooLargeException if the resource is too large + */ + protected void enforceSizeLimit(URL url, long maxBytes, DensityMapRequest request) + throws DensityMapTooLargeException { + if (maxBytes <= 0) { + return; + } + long size = declaredSize(url); + if (size > maxBytes) { + reportTooLarge(url, size, maxBytes, request); + throw new DensityMapTooLargeException(url.toString(), size, maxBytes); + } + } + + /** + * Announces that a map was skipped for being too large. + *

+ * This is reported through the logger and on both standard output and standard + * error. A user who asked for a map and silently got a coarser one from another + * source deserves to be told why, and a library log configuration that discards + * warnings should not be able to hide it. + * + * @param url the resource that was skipped + * @param size its size in bytes + * @param maxBytes the limit in bytes + * @param request the request being served + */ + protected void reportTooLarge(URL url, long size, long maxBytes, DensityMapRequest request) { + String entry = request.getPdbId() != null ? request.getPdbId().getId() + : (request.getEmdbId() != null ? request.getEmdbId() : "?"); + String message = String.format( + "BioJava density: skipping %s for %s (%,d bytes exceeds the %,d byte limit). " + + "Trying a smaller representation from another source; " + + "raise DensityMapCache.setMaxDownloadBytes() to allow it.", + url, entry, size, maxBytes); + logger.warn(message); + System.out.println(message); + System.err.println(message); + } + + /** + * The Content-Length a server declares for a resource. + * + * @param url the resource + * @return the size in bytes, or a negative value if the server did not say + */ + protected long declaredSize(URL url) { + try { + URLConnection connection = FileDownloadUtils.prepareURLConnection(url.toString(), 30000); + if (connection instanceof java.net.HttpURLConnection) { + ((java.net.HttpURLConnection) connection).setRequestMethod("HEAD"); + } + connection.connect(); + try { + return connection.getContentLengthLong(); + } finally { + if (connection instanceof java.net.HttpURLConnection) { + ((java.net.HttpURLConnection) connection).disconnect(); + } + } + } catch (IOException e) { + logger.debug("Could not determine the size of {}: {}", url, e.getMessage()); + return -1; + } + } + + /** + * Deletes a cached map along with its validation and metadata sidecars. + * + * @param target the cached map + */ + protected void deleteWithSidecars(File target) { + File dir = target.getAbsoluteFile().getParentFile(); + if (dir == null) { + return; + } + File[] siblings = dir.listFiles((d, name) -> name.startsWith(target.getName())); + if (siblings == null) { + return; + } + for (File f : siblings) { + if (!f.delete()) { + logger.debug("Could not delete [{}]", f); + } + } + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/Ccp4Header.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/Ccp4Header.java new file mode 100644 index 0000000000..594ad91927 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/Ccp4Header.java @@ -0,0 +1,133 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.zip.GZIPInputStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Recognises CCP4/MRC map files by their header. + *

+ * This is a cheap but effective guard against a cached file that is not + * actually a map. A misbehaving or overloaded server can answer with an HTML + * error page and an HTTP 200, in which case nothing about the status code or the + * content length reveals the problem — but the missing MAP  + * stamp does, turning a silent failure into a clean cache miss. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class Ccp4Header { + + private static final Logger logger = LoggerFactory.getLogger(Ccp4Header.class); + + /** + * Byte offset of the four-character format stamp within a CCP4/MRC header. It + * sits in word 53 of the 256-word header. + */ + public static final int MAP_STAMP_OFFSET = 208; + + /** The stamp itself: the three letters of "MAP" followed by a space. */ + public static final String MAP_STAMP = "MAP "; + + /** Number of header bytes that must be readable for the check to be possible. */ + private static final int HEADER_BYTES = MAP_STAMP_OFFSET + 4; + + private Ccp4Header() { + } + + /** + * Checks whether a file is a CCP4/MRC map. Gzipped files are decompressed on + * the fly, so .map.gz works as well as .ccp4. + * + * @param file the file to check + * @return true if the CCP4 stamp is present + * @throws IOException if the file could not be read + */ + public static boolean isCcp4(File file) throws IOException { + if (file == null || !file.isFile()) { + return false; + } + // Deliberately no shortcut on file.length(): a gzipped map compresses to far + // less than the size of the header it contains, so a length test here would + // reject perfectly good small maps. Reading decides it instead. + try (InputStream in = openPossiblyGzipped(file)) { + return isCcp4(in); + } + } + + /** + * Checks whether a stream carries a CCP4/MRC map header. The stream is read + * from its current position and is not closed; it is not un-read afterwards, + * so pass a fresh stream or one that supports marking. + * + * @param in the stream to check, already decompressed + * @return true if the CCP4 stamp is present + * @throws IOException if the stream could not be read + */ + public static boolean isCcp4(InputStream in) throws IOException { + byte[] header = new byte[HEADER_BYTES]; + int read = 0; + while (read < HEADER_BYTES) { + int n = in.read(header, read, HEADER_BYTES - read); + if (n < 0) { + return false; // shorter than a CCP4 header, so certainly not one + } + read += n; + } + String stamp = new String(header, MAP_STAMP_OFFSET, 4, StandardCharsets.US_ASCII); + return MAP_STAMP.equals(stamp); + } + + /** + * Same as {@link #isCcp4(File)} but reports a problem rather than propagating + * it, for use in cache-validity checks where an unreadable file and an invalid + * one lead to the same action. + * + * @param file the file to check + * @return true if the file is readable and carries the CCP4 stamp + */ + public static boolean isCcp4Quietly(File file) { + try { + return isCcp4(file); + } catch (IOException e) { + logger.debug("Could not read [{}] to check for a CCP4 header: {}", file, e.getMessage()); + return false; + } + } + + private static InputStream openPossiblyGzipped(File file) throws IOException { + InputStream in = new BufferedInputStream(Files.newInputStream(file.toPath())); + in.mark(2); + int b1 = in.read(); + int b2 = in.read(); + in.reset(); + if (b1 == 0x1F && b2 == 0x8B) { + return new GZIPInputStream(in); + } + return in; + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityCacheLayout.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityCacheLayout.java new file mode 100644 index 0000000000..60e6ae7b61 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityCacheLayout.java @@ -0,0 +1,242 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; + +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.io.LocalPDBDirectory; + +/** + * Where cached density files live on disk. + *

+ * PDB-keyed maps follow the divided layout the rest of BioJava already uses, so + * that a cache with many entries does not end up with one enormous directory: + *

+ * <cache>/density/cb/1cbs_2fofc_pdbe.ccp4
+ * <cache>/density/cb/1cbs_fofc_pdbe.ccp4
+ * <cache>/density/cb/1cbs_2fofc_rcsbvs_d0.bcif
+ * <cache>/density/cb/1cbs_2fofc_wwpdb.cif.gz
+ * 
+ * Both the kind and the source appear in the file name, so no two combinations + * can collide. + *

+ * EMDB maps are keyed by EMDB identifier instead, mirroring the EMDB archive: + *

+ * <cache>/density/emd/EMD-0262/emd_0262.map.gz
+ * 
+ * Several PDB entries are often fitted into a single EM map, and those maps can + * be hundreds of megabytes, so keying them by PDB entry would cache the same + * enormous file many times over. + *

+ * The two-character directory needs no attention when the archive moves to + * extended identifiers in July 2027. It is a device for spreading files over + * directories, not a copy of the archive's own layout, and + * {@link LocalPDBDirectory#getMiddleHash(String)} counts from the right hand end + * of the identifier, so 1cbs and pdb_00001cbs both land + * in cb — which is also the rule the wwPDB documents for the + * new archive. + *

+ * Deliberately not the archive's per-entry layout. A cache is not a mirror + * and cannot become one: the archive publishes structure factors and map + * coefficients but never grids, so density has to be fetched whatever else is + * mirrored, and writing it into a directory whose contents are an exact copy of + * upstream puts it at the mercy of the next rsync --delete. Should + * that judgement ever be revisited, every cached path is computed here and + * nowhere else, so a different layout is a change to this class plus a fallback + * probe for files in the old places — not a cache that everyone has to + * discard. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class DensityCacheLayout { + + /** Name of the density sub-directory within the BioJava cache directory. */ + public static final String DENSITY_DIR = "density"; + + /** Name of the sub-directory holding EMDB-keyed maps. */ + public static final String EMDB_DIR = "emd"; + + /** Name of the sub-directory holding cached PDB-to-EMDB mappings. */ + public static final String EMDB_MAPPING_DIR = "emdb-mapping"; + + /** + * File-name token for a file that holds more than one kind of map, as a density + * server response does. + */ + public static final String BOTH_KINDS_TOKEN = "both"; + + private DensityCacheLayout() { + } + + /** + * The root density directory inside a cache directory. + * + * @param cacheRoot the BioJava cache directory + * @return the density directory, which need not exist yet + */ + public static File densityRoot(File cacheRoot) { + return new File(cacheRoot, DENSITY_DIR); + } + + /** + * The cache file for a PDB-keyed map. + * + * @param cacheRoot the BioJava cache directory + * @param pdbId the entry + * @param kind the kind of map + * @param source the service it came from + * @param format the file format + * @param qualifier an extra discriminator such as a detail level, or + * null. Anything that changes the content but not the + * entry, kind or source belongs here. + * @return the file, which need not exist + */ + public static File pdbMapFile(File cacheRoot, PdbId pdbId, DensityMapKind kind, DensityMapSource source, + DensityFileFormat format, String qualifier) { + return pdbMapFile(cacheRoot, pdbId, kind.getFileToken(), source, format, qualifier); + } + + /** + * The cache file for a PDB-keyed map, naming the kind explicitly. + *

+ * The separate kind token exists for sources that deliver more than one kind of + * map in a single file. A density server response, for instance, carries both + * the 2Fo-Fc and the Fo-Fc blocks, so it is cached once under + * {@link #BOTH_KINDS_TOKEN} rather than downloaded and stored twice. + * + * @param cacheRoot the BioJava cache directory + * @param pdbId the entry + * @param kindToken the token naming what the file holds + * @param source the service it came from + * @param format the file format + * @param qualifier an extra discriminator such as a detail level, or null + * @return the file, which need not exist + */ + public static File pdbMapFile(File cacheRoot, PdbId pdbId, String kindToken, DensityMapSource source, + DensityFileFormat format, String qualifier) { + String id = shortIdOrFull(pdbId).toLowerCase(); + File dir = new File(densityRoot(cacheRoot), LocalPDBDirectory.getMiddleHash(id)); + StringBuilder name = new StringBuilder(id) + .append('_').append(kindToken) + .append('_').append(source.getFileToken()); + if (qualifier != null && !qualifier.isEmpty()) { + name.append('_').append(qualifier); + } + name.append(format.getExtension()); + return new File(dir, name.toString()); + } + + /** + * The cache file for an EMDB-keyed map. + * + * @param cacheRoot the BioJava cache directory + * @param emdbId the EMDB entry, in any accepted form + * @param source the service it came from + * @param format the file format + * @param qualifier an extra discriminator such as a detail level, or null + * @return the file, which need not exist + */ + public static File emdbMapFile(File cacheRoot, String emdbId, DensityMapSource source, + DensityFileFormat format, String qualifier) { + String canonical = DensityMapRequest.normalizeEmdbId(emdbId); + String number = DensityMapRequest.emdbNumber(emdbId); + File dir = new File(new File(densityRoot(cacheRoot), EMDB_DIR), canonical); + StringBuilder name = new StringBuilder("emd_").append(number); + if (source != DensityMapSource.EMDB_MAP) { + name.append('_').append(source.getFileToken()); + } + if (qualifier != null && !qualifier.isEmpty()) { + name.append('_').append(qualifier); + } + name.append(format.getExtension()); + return new File(dir, name.toString()); + } + + /** + * The file caching the EMDB identifiers and author contour level associated + * with a PDB entry. + * + * @param cacheRoot the BioJava cache directory + * @param pdbId the entry + * @return the file, which need not exist + */ + public static File emdbMappingFile(File cacheRoot, PdbId pdbId) { + String id = shortIdOrFull(pdbId).toLowerCase(); + File dir = new File(new File(densityRoot(cacheRoot), EMDB_MAPPING_DIR), LocalPDBDirectory.getMiddleHash(id)); + return new File(dir, id + ".emdb.properties"); + } + + /** + * The file caching an EMDB entry's map metadata as served by the EMDB API. + * + * @param cacheRoot the BioJava cache directory + * @param emdbId the EMDB entry, in any accepted form + * @return the file, which need not exist + */ + public static File emdbMapInfoFile(File cacheRoot, String emdbId) { + String canonical = DensityMapRequest.normalizeEmdbId(emdbId); + File dir = new File(new File(densityRoot(cacheRoot), EMDB_DIR), canonical); + return new File(dir, canonical + ".map-info.json"); + } + + /** + * The companion name a density-server file must have for Jmol to read its + * difference-map block. + *

+ * A density server response holds both a 2FO-FC and an + * FO-FC data block, and Jmol's reader chooses between them by + * testing whether the file name contains the literal text + * &diff=1. That works for a URL fetched straight from the + * server, where the marker rides along in the query string, but a cached local + * file has no query string; appending the marker to the file URL only makes + * Jmol look for a file that does not exist. Putting the marker into the name + * itself is what actually selects the block. + *

+ * This was verified against Jmol 14.31.10 and is unchanged in current Jmol: the + * relevant line in BCifDensityReader still carries the author's + * "what about cached data" to-do beside it. Should Jmol gain a cleaner way to + * choose the block, this can be retired. + * + * @param mapFile the cached density-server file + * @return the sibling path that selects the difference map + */ + public static File differenceMarkerFile(File mapFile) { + String name = mapFile.getName(); + int dot = name.lastIndexOf('.'); + String stem = dot < 0 ? name : name.substring(0, dot); + String ext = dot < 0 ? "" : name.substring(dot); + return new File(mapFile.getAbsoluteFile().getParentFile(), stem + "&diff=1" + ext); + } + + /** + * The short four-character spelling of an identifier where one exists. + *

+ * Every entry in the archive today has a four-character form, and the density + * services accept only that spelling. Extended-only identifiers will appear + * eventually; rather than refusing them, the extended spelling is passed + * through so that the services can start accepting it without a change here. + * + * @param pdbId the identifier + * @return the short spelling if available, otherwise the full one + */ + public static String shortIdOrFull(PdbId pdbId) { + return pdbId.getId(true); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityFileFormat.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityFileFormat.java new file mode 100644 index 0000000000..11cbb0e27e --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityFileFormat.java @@ -0,0 +1,89 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +/** + * The file format a density map was delivered in. + *

+ * The important distinction here is {@link #isJmolLoadable()}. Most of these + * formats are sampled grids that a viewer can contour directly; map + * coefficients are not, they are structure factors that require a Fourier + * transform first. Handing the latter to a viewer produces nothing at all, so + * the difference has to be visible to callers. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public enum DensityFileFormat { + + /** A CCP4/MRC sampled map, as served pre-computed by PDBe. */ + CCP4(".ccp4", true), + + /** A gzipped CCP4/MRC map, the form EMDB distributes its primary maps in. */ + CCP4_GZ(".map.gz", true), + + /** A BinaryCIF volume slice from a Mol* density server. */ + BCIF_VOLUME(".bcif", true), + + /** A text CIF volume slice from a Mol* density server. */ + CIF_VOLUME(".cif", true), + + /** + * Structure-factor amplitudes and phases in mmCIF, as published with the wwPDB + * validation reports. Not a density grid. A Fourier transform (for + * example gemmi sf2map, or CCP4's fft after + * cif2mtz) is needed before these can be displayed. + */ + MAP_COEFFICIENTS_CIF_GZ(".cif.gz", false); + + private final String extension; + private final boolean jmolLoadable; + + DensityFileFormat(String extension, boolean jmolLoadable) { + this.extension = extension; + this.jmolLoadable = jmolLoadable; + } + + /** + * @return the file extension used for cached files of this format, including + * the leading dot + */ + public String getExtension() { + return extension; + } + + /** + * Whether a viewer can contour this file as it stands. + * + * @return false for {@link #MAP_COEFFICIENTS_CIF_GZ}, which needs + * an FFT first; true for the sampled grid formats + */ + public boolean isJmolLoadable() { + return jmolLoadable; + } + + /** + * Whether files of this format are gzip-compressed on disk. Jmol detects gzip + * from the magic bytes, so such files do not need decompressing before display. + * + * @return true for the compressed formats + */ + public boolean isCompressed() { + return this == CCP4_GZ || this == MAP_COEFFICIENTS_CIF_GZ; + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapCache.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapCache.java new file mode 100644 index 0000000000..adbbd917a8 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapCache.java @@ -0,0 +1,526 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.biojava.nbio.core.util.FileDownloadUtils; +import org.biojava.nbio.core.util.HttpStatusException; +import org.biojava.nbio.structure.ExperimentalTechnique; +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.Structure; +import org.biojava.nbio.structure.align.util.UserConfiguration; +import org.biojava.nbio.structure.io.LocalPDBDirectory.FetchBehavior; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Downloads and caches electron density and cryo-EM maps, trying several sources + * in turn until one produces a map. + *

+ * Cached files live under the BioJava cache directory (PDB_CACHE_DIR), + * laid out as described in {@link DensityCacheLayout}. Typical use is simply: + *

+ * DensityMapCache cache = new DensityMapCache();
+ * DensityMapResult map = cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC);
+ * File file = map.getFile();
+ * 
+ *

+ * Source order. Sources are tried smallest-adequate-first, because they + * differ enormously in size for the same entry and the smallest is usually + * perfectly adequate to look at. For 1cbs a density-server slice is roughly a + * tenth the size of the equivalent pair of CCP4 files; for the cryo-EM entry + * behind EMD-0262 it is a few hundred kilobytes against 106 MB. Anything + * that cannot be displayed at all is tried last, and + * {@link DensityMapSource#WWPDB_MAP_COEFFICIENTS} is disabled altogether by + * default for that reason. Override with {@link #setSourceChain(DensityMapKind, + * java.util.List)} or {@link #setSourceEnabled(DensityMapSource, boolean)}. + *

+ * Failures. A source that has nothing for an entry is skipped and the next + * is tried; if every source is exhausted, {@link NoDensityMapException} is thrown + * carrying the reason from each one, so a caller can explain what happened. + * Genuine transport failures abort the chain instead, so that a network outage is + * never reported as "this entry has no density". + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class DensityMapCache { + + private static final Logger logger = LoggerFactory.getLogger(DensityMapCache.class); + + /** + * Default ceiling on a single download, 256 MiB. Exceeding it is not an + * error: the chain moves on to a source that offers a smaller representation. + * Set to 0 to remove the limit. + */ + public static final long DEFAULT_MAX_DOWNLOAD_BYTES = 256L * 1024 * 1024; + + /** Order in which sources are tried for X-ray and neutron entries. */ + public static final List DEFAULT_XRAY_SOURCE_CHAIN = Collections.unmodifiableList(Arrays.asList( + DensityMapSource.RCSB_VOLUME_SERVER, + DensityMapSource.PDBE_CCP4, + DensityMapSource.PDBE_VOLUME_SERVER, + DensityMapSource.WWPDB_MAP_COEFFICIENTS)); + + /** Order in which sources are tried for cryo-EM entries. */ + public static final List DEFAULT_EM_SOURCE_CHAIN = Collections.unmodifiableList(Arrays.asList( + DensityMapSource.RCSB_VOLUME_SERVER, + DensityMapSource.PDBE_VOLUME_SERVER, + DensityMapSource.EMDB_MAP)); + + private static DensityMapCache instance; + + private File cacheRoot; + private FetchBehavior fetchBehavior = FetchBehavior.FETCH_FILES; + private long maxDownloadBytes = DEFAULT_MAX_DOWNLOAD_BYTES; + private final Map providers = new EnumMap<>(DensityMapSource.class); + private final Set disabled = EnumSet.of(DensityMapSource.WWPDB_MAP_COEFFICIENTS); + private List xraySourceChain = DEFAULT_XRAY_SOURCE_CHAIN; + private List emSourceChain = DEFAULT_EM_SOURCE_CHAIN; + private EmdbEntryResolver emdbResolver; + + /** + * Creates a cache using the standard BioJava cache directory, i.e. + * PDB_CACHE_DIR falling back to PDB_DIR. + */ + public DensityMapCache() { + this(new UserConfiguration().getCacheFilePath()); + } + + /** + * @param cachePath the directory to cache under + */ + public DensityMapCache(String cachePath) { + this.cacheRoot = new File(FileDownloadUtils.expandUserHome(cachePath)); + this.emdbResolver = new EmdbEntryResolver(cacheRoot); + buildDefaultProviders(); + } + + /** + * A lazily created shared instance, for callers that do not want to manage one. + * + * @return the shared cache + */ + public static synchronized DensityMapCache getInstance() { + if (instance == null) { + instance = new DensityMapCache(); + } + return instance; + } + + private void buildDefaultProviders() { + providers.clear(); + register(new VolumeServerProvider(cacheRoot, VolumeServerProvider.Host.RCSB)); + register(new VolumeServerProvider(cacheRoot, VolumeServerProvider.Host.PDBE)); + register(new PdbeCcp4MapProvider(cacheRoot)); + register(new EmdbMapProvider(cacheRoot, emdbResolver)); + register(new WwpdbMapCoefficientsProvider(cacheRoot)); + applySettingsToProviders(); + } + + private void register(DensityMapProvider provider) { + providers.put(provider.getSource(), provider); + } + + private void applySettingsToProviders() { + for (DensityMapProvider p : providers.values()) { + if (p instanceof AbstractDensityMapProvider) { + AbstractDensityMapProvider a = (AbstractDensityMapProvider) p; + a.setCacheRoot(cacheRoot); + a.setFetchBehavior(fetchBehavior); + a.setMaxDownloadBytes(maxDownloadBytes); + } + } + emdbResolver.setCacheRoot(cacheRoot); + emdbResolver.setFetchBehavior(fetchBehavior); + } + + /** @return the directory maps are cached under */ + public String getCachePath() { + return cacheRoot.getAbsolutePath(); + } + + /** @param cachePath the directory to cache under */ + public void setCachePath(String cachePath) { + this.cacheRoot = new File(FileDownloadUtils.expandUserHome(cachePath)); + applySettingsToProviders(); + } + + /** @return how aggressively cached maps are re-fetched */ + public FetchBehavior getFetchBehavior() { + return fetchBehavior; + } + + /** + * @param fetchBehavior how aggressively to re-fetch. {@link FetchBehavior#FETCH_REMEDIATED} + * behaves as {@link FetchBehavior#FETCH_FILES}: the 2011 remediation date + * is a coordinate-file concept with no meaning for maps. + */ + public void setFetchBehavior(FetchBehavior fetchBehavior) { + this.fetchBehavior = fetchBehavior == null ? FetchBehavior.FETCH_FILES : fetchBehavior; + applySettingsToProviders(); + } + + /** @return the ceiling on a single download in bytes, or 0 for no limit */ + public long getMaxDownloadBytes() { + return maxDownloadBytes; + } + + /** @param maxDownloadBytes the ceiling on a single download in bytes, or 0 for no limit */ + public void setMaxDownloadBytes(long maxDownloadBytes) { + this.maxDownloadBytes = maxDownloadBytes; + applySettingsToProviders(); + } + + /** + * The order sources are tried in for a given kind of map. + * + * @param kind the kind of map + * @return the source order + */ + public List getSourceChain(DensityMapKind kind) { + return kind == DensityMapKind.EM ? emSourceChain : xraySourceChain; + } + + /** + * Overrides the order sources are tried in. + * + * @param kind {@link DensityMapKind#EM} to set the cryo-EM order, anything else + * to set the X-ray order + * @param chain the new order + */ + public void setSourceChain(DensityMapKind kind, List chain) { + List copy = Collections.unmodifiableList(new ArrayList<>(chain)); + if (kind == DensityMapKind.EM) { + emSourceChain = copy; + } else { + xraySourceChain = copy; + } + } + + /** + * @param source the source + * @return whether it will be tried + */ + public boolean isSourceEnabled(DensityMapSource source) { + return !disabled.contains(source); + } + + /** + * Enables or disables a source. + *

+ * {@link DensityMapSource#WWPDB_MAP_COEFFICIENTS} is disabled by default + * because it delivers structure factors rather than a map; enable it explicitly + * if you want the archival form and are prepared to run a Fourier transform. + * + * @param source the source + * @param enabled whether to try it + */ + public void setSourceEnabled(DensityMapSource source, boolean enabled) { + if (enabled) { + disabled.remove(source); + } else { + disabled.add(source); + } + } + + /** + * Replaces the provider used for a source. Mainly a testing seam, but also the + * way to plug in a site-local mirror. + * + * @param provider the provider to use + */ + public void registerProvider(DensityMapProvider provider) { + register(provider); + applySettingsToProviders(); + } + + /** @return the resolver used to map PDB entries to EMDB entries */ + public EmdbEntryResolver getEmdbResolver() { + return emdbResolver; + } + + /** @param resolver the resolver used to map PDB entries to EMDB entries */ + public void setEmdbResolver(EmdbEntryResolver resolver) { + this.emdbResolver = resolver; + applySettingsToProviders(); + } + + /** + * Fetches a density map, using the cache where possible. + * + * @param pdbId the entry + * @param kind the kind of map, or {@link DensityMapKind#AUTO} to take whatever + * the entry has + * @return the map + * @throws NoDensityMapException if no enabled source has a map for this entry + * @throws IOException on transport failure + */ + public DensityMapResult getDensityMap(PdbId pdbId, DensityMapKind kind) throws IOException { + return getDensityMap(DensityMapRequest.builder(pdbId).kind(kind).build()); + } + + /** + * Fetches a density map for a PDB or EMDB identifier. + * + * @param id a PDB identifier, or an EMDB identifier such as EMD-0262 + * @param kind the kind of map wanted + * @return the map + * @throws NoDensityMapException if no enabled source has a map for this entry + * @throws IOException on transport failure + */ + public DensityMapResult getDensityMap(String id, DensityMapKind kind) throws IOException { + return getDensityMap(DensityMapRequest.builder(id).kind(kind).build()); + } + + /** + * Fetches a density map for an already-loaded structure. + *

+ * Knowing the structure lets the experimental method be read directly rather + * than guessed, so a cryo-EM entry goes straight to the EM sources instead of + * trying the X-ray ones first. No extra network request is involved. + * + * @param structure the structure + * @param kind the kind of map wanted + * @return the map + * @throws NoDensityMapException if no enabled source has a map for this entry + * @throws IOException on transport failure + */ + public DensityMapResult getDensityMap(Structure structure, DensityMapKind kind) throws IOException { + PdbId pdbId = structure.getPdbId(); + if (pdbId == null) { + throw new IOException("The structure has no PDB ID, so no density map can be looked up for it."); + } + DensityMapRequest request = DensityMapRequest.builder(pdbId).kind(kind).build(); + return getDensityMap(request, kindOrderFor(structure, kind)); + } + + /** + * Fetches a density map. + * + * @param request what is wanted + * @return the map + * @throws NoDensityMapException if no enabled source has a map for this entry + * @throws IOException on transport failure + */ + public DensityMapResult getDensityMap(DensityMapRequest request) throws IOException { + return getDensityMap(request, request.getKind().resolve()); + } + + /** + * As {@link #getDensityMap(PdbId, DensityMapKind)} but returning an empty + * {@link Optional} rather than throwing when nothing is available. Genuine + * transport failures are still logged and swallowed, so use this only where + * "no map" and "could not reach the server" need not be told apart. + * + * @param pdbId the entry + * @param kind the kind of map wanted + * @return the map, if one could be obtained + */ + public Optional findDensityMap(PdbId pdbId, DensityMapKind kind) { + try { + return Optional.of(getDensityMap(pdbId, kind)); + } catch (NoDensityMapException e) { + logger.debug("{}", e.getMessage()); + return Optional.empty(); + } catch (IOException e) { + logger.warn("Could not fetch a density map for {}: {}", pdbId.getId(), e.getMessage()); + return Optional.empty(); + } + } + + /** + * Fetches both halves of the conventional X-ray pair: the 2mFo-DFc map and the + * mFo-DFc difference map. + * + * @param pdbId the entry + * @return whichever of the two could be obtained, in that order; possibly empty + */ + public List getDifferenceMapPair(PdbId pdbId) { + List results = new ArrayList<>(2); + findDensityMap(pdbId, DensityMapKind.TWO_FO_FC).ifPresent(results::add); + findDensityMap(pdbId, DensityMapKind.FO_FC).ifPresent(results::add); + return results; + } + + /** + * Looks a map up in the cache without contacting any server. + * + * @param pdbId the entry + * @param kind the kind of map + * @param source the source it would have come from + * @return the cached map, or null if it is not cached + */ + public DensityMapResult getCached(PdbId pdbId, DensityMapKind kind, DensityMapSource source) { + DensityMapProvider provider = providers.get(source); + if (provider == null) { + return null; + } + File file = DensityCacheLayout.pdbMapFile(cacheRoot, pdbId, kind, source, provider.getFormat(), null); + return file.isFile() ? DensityMapResult.readMeta(file) : null; + } + + /** + * Removes every cached density file for an entry, including the sidecars. + * + * @param pdbId the entry + * @return the number of files deleted + */ + public int deleteDensityMaps(PdbId pdbId) { + String id = DensityCacheLayout.shortIdOrFull(pdbId).toLowerCase(); + File dir = DensityCacheLayout.pdbMapFile(cacheRoot, pdbId, DensityMapKind.TWO_FO_FC, + DensityMapSource.PDBE_CCP4, DensityFileFormat.CCP4, null).getParentFile(); + File[] files = dir == null ? null : dir.listFiles((d, name) -> name.startsWith(id + "_")); + if (files == null) { + return 0; + } + int deleted = 0; + for (File f : files) { + if (f.delete()) { + deleted++; + } + } + return deleted; + } + + private DensityMapResult getDensityMap(DensityMapRequest request, List kinds) throws IOException { + Map attempts = new LinkedHashMap<>(); + + for (DensityMapKind kind : kinds) { + DensityMapRequest kindRequest = request.withKind(kind); + + if (kind == DensityMapKind.EM && kindRequest.getEmdbId() == null) { + String emdbId = resolveEmdbId(kindRequest); + if (emdbId == null) { + attempts.put(DensityMapSource.EMDB_MAP, "no associated EMDB entry"); + continue; + } + kindRequest = kindRequest.withEmdbId(emdbId); + } + + List chain = kindRequest.getSourceChain() != null + ? kindRequest.getSourceChain() : getSourceChain(kind); + + for (DensityMapSource source : chain) { + if (!isSourceEnabled(source)) { + attempts.put(source, "disabled"); + continue; + } + DensityMapProvider provider = providers.get(source); + if (provider == null) { + attempts.put(source, "no provider registered"); + continue; + } + if (!provider.supports(kind)) { + continue; // structurally impossible; not worth reporting + } + if (!kindRequest.isAllowNonRenderableFormats() && !provider.getFormat().isJmolLoadable()) { + attempts.put(source, "cannot be displayed without a Fourier transform"); + continue; + } + + try { + DensityMapResult result = provider.fetch(kindRequest); + if (result != null) { + return withContourLevel(result); + } + attempts.put(source, "no map for this entry"); + } catch (HttpStatusException e) { + if (!e.isNotFound()) { + // A server error or an authentication problem is a real failure, + // not evidence that the entry has no density. + throw e; + } + attempts.put(source, "HTTP " + e.getStatusCode()); + } catch (DensityMapTooLargeException e) { + attempts.put(source, "too large (" + e.getSizeBytes() + " bytes)"); + } catch (IOException e) { + throw new IOException("Failed to fetch a density map for " + + (request.getPdbId() == null ? request.getEmdbId() : request.getPdbId().getId()) + + " from " + source + ": " + e.getMessage(), e); + } + } + } + + throw new NoDensityMapException(request.getPdbId(), request.getKind(), attempts); + } + + /** + * Fills in the author-recommended contour level for an EM map when the source + * that supplied the file did not know it. + *

+ * A density server returns voxels and nothing else, but an EM map is + * conventionally displayed at the level its depositors chose rather than at a + * multiple of sigma, so a viewer needs that number whichever source the map came + * from. It costs one small metadata request, cached thereafter. + */ + private DensityMapResult withContourLevel(DensityMapResult result) { + if (result.getKind() != DensityMapKind.EM || result.getRecommendedContourLevel() != null + || result.getEmdbId() == null || emdbResolver == null) { + return result; + } + EmdbEntryInfo info = emdbResolver.getEntryInfo(result.getEmdbId()); + if (info == null || (info.getRecommendedContourLevel() == null && info.getSigma() == null)) { + return result; + } + DensityMapResult enriched = new DensityMapResult(result.getFile(), result.getSource(), result.getFormat(), + result.getKind(), result.getPdbId(), result.getEmdbId(), result.getSourceUrl(), result.isFromCache(), + info.getRecommendedContourLevel(), info.getSigma()); + enriched.writeMeta(); + return enriched; + } + + private String resolveEmdbId(DensityMapRequest request) { + if (request.getPdbId() == null || emdbResolver == null) { + return null; + } + List ids = emdbResolver.getEmdbIds(request.getPdbId()); + return ids.isEmpty() ? null : ids.get(0); + } + + /** + * Chooses which kinds to try, and in what order, using the structure's declared + * experimental method. Reading it from the structure costs nothing; note that + * the resolution field is deliberately not consulted, since BioJava parses it + * incorrectly for some cryo-EM entries (biojava/biojava#1000). + */ + private List kindOrderFor(Structure structure, DensityMapKind kind) { + if (kind != DensityMapKind.AUTO) { + return kind.resolve(); + } + Set techniques = structure.getPDBHeader() == null + ? null : structure.getPDBHeader().getExperimentalTechniques(); + if (techniques != null && techniques.contains(ExperimentalTechnique.ELECTRON_MICROSCOPY) + && !ExperimentalTechnique.isCrystallographic(techniques)) { + return Arrays.asList(DensityMapKind.EM, DensityMapKind.TWO_FO_FC); + } + return kind.resolve(); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapKind.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapKind.java new file mode 100644 index 0000000000..8b472092ef --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapKind.java @@ -0,0 +1,99 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The kind of density map being requested, i.e. what the values in the grid mean. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public enum DensityMapKind { + + /** + * The 2mFo-DFc "best" map: the electron density itself, showing how the model + * fits the experimental data. This is what is normally meant by "the electron + * density" of an X-ray structure. + */ + TWO_FO_FC("2fofc"), + + /** + * The mFo-DFc difference map, showing density that the model does not account + * for (positive) and model that has no density to support it (negative). It is + * conventionally displayed as a signed pair of surfaces. + */ + FO_FC("fofc"), + + /** + * The primary map of a cryo-EM or cryo-ET reconstruction. Strictly this is a + * Coulomb potential map rather than an electron density map, and it is + * conventionally contoured at an absolute author-recommended level rather than + * in multiples of sigma. + */ + EM("em"), + + /** + * Not a map in itself: asks for whichever map the entry actually has. Resolves + * to {@link #TWO_FO_FC} first and then {@link #EM}, which covers X-ray and + * cryo-EM entries without the caller having to know which it is holding. + */ + AUTO(null); + + private final String fileToken; + + DensityMapKind(String fileToken) { + this.fileToken = fileToken; + } + + /** + * The short token used to distinguish this kind in a cached file name. + * + * @return the token, or null for {@link #AUTO}, which is never + * itself cached + */ + public String getFileToken() { + return fileToken; + } + + /** + * Expands this kind into the concrete kinds to try, in order. + * + * @return a single-element list for a concrete kind, or the X-ray-then-EM order + * for {@link #AUTO} + */ + public List resolve() { + if (this == AUTO) { + return Arrays.asList(TWO_FO_FC, EM); + } + return Collections.singletonList(this); + } + + /** + * Whether this kind is conventionally displayed as a signed pair of surfaces, + * one positive and one negative. + * + * @return true only for {@link #FO_FC} + */ + public boolean isDifferenceMap() { + return this == FO_FC; + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapProvider.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapProvider.java new file mode 100644 index 0000000000..c628387ffd --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapProvider.java @@ -0,0 +1,78 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.IOException; + +import org.biojava.nbio.core.util.HttpStatusException; + +/** + * Fetches density maps from one particular service. + *

+ * Implementations are combined into an ordered chain by {@link DensityMapCache}, + * which tries each in turn until one produces a map. The contract around + * exceptions is what makes that chain safe: + *

    + *
  • Throw {@link HttpStatusException} with {@link HttpStatusException#isNotFound()} + * — or return null — when this service simply has nothing + * for the entry. The chain moves on to the next source.
  • + *
  • Throw {@link DensityMapTooLargeException} when the map exists but exceeds + * the caller's size limit. The chain also moves on, so a smaller representation + * from another source can be used instead.
  • + *
  • Throw any other {@link IOException} for a genuine transport failure. The + * chain stops, so that a network outage is never mistaken for "this entry has no + * density".
  • + *
+ * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public interface DensityMapProvider { + + /** + * @return which service this provider talks to + */ + DensityMapSource getSource(); + + /** + * @return the format this provider delivers + */ + DensityFileFormat getFormat(); + + /** + * Whether this provider can serve a given kind of map at all. Used to skip + * requests that could not possibly succeed, such as asking an X-ray map service + * for a cryo-EM map. + * + * @param kind the kind of map wanted + * @return true if it is worth trying + */ + boolean supports(DensityMapKind kind); + + /** + * Fetches a map, using the cache if the request's fetch behaviour allows. + * + * @param request what is wanted. Its kind is always concrete, never + * {@link DensityMapKind#AUTO}. + * @return the map, or null if this service has nothing for the entry + * @throws DensityMapTooLargeException if the map exceeds the request's size limit + * @throws IOException on transport failure; use {@link HttpStatusException} so + * that a missing resource can be told apart from a broken connection + */ + DensityMapResult fetch(DensityMapRequest request) throws IOException; +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapRequest.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapRequest.java new file mode 100644 index 0000000000..d6677a3835 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapRequest.java @@ -0,0 +1,275 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; +import java.util.Collections; +import java.util.List; + +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.io.LocalPDBDirectory.FetchBehavior; + +/** + * A request for a density map, describing what is wanted and how hard to look + * for it. + *

+ * Instances are immutable; build them with {@link #builder(PdbId)} or + * {@link #builder(String)}. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class DensityMapRequest { + + private final PdbId pdbId; + private final String emdbId; + private final DensityMapKind kind; + private final FetchBehavior fetchBehavior; + private final File cacheDir; + private final boolean allowNonRenderableFormats; + private final long maxDownloadBytes; + private final List sourceChain; + + private DensityMapRequest(Builder b) { + this.pdbId = b.pdbId; + this.emdbId = b.emdbId; + this.kind = b.kind; + this.fetchBehavior = b.fetchBehavior; + this.cacheDir = b.cacheDir; + this.allowNonRenderableFormats = b.allowNonRenderableFormats; + this.maxDownloadBytes = b.maxDownloadBytes; + this.sourceChain = b.sourceChain == null ? null : Collections.unmodifiableList(b.sourceChain); + } + + /** + * Starts a request for a PDB entry. + * + * @param pdbId the entry + * @return a new builder + */ + public static Builder builder(PdbId pdbId) { + return new Builder(pdbId, null); + } + + /** + * Starts a request from an identifier string. An identifier beginning with + * EMD- (case-insensitively) is taken as an EMDB entry, anything + * else as a PDB entry. + * + * @param id a PDB or EMDB identifier + * @return a new builder + */ + public static Builder builder(String id) { + if (id == null) { + throw new IllegalArgumentException("Identifier must not be null"); + } + String trimmed = id.trim(); + if (trimmed.toUpperCase().startsWith("EMD-") || trimmed.toUpperCase().startsWith("EMD_")) { + return new Builder(null, normalizeEmdbId(trimmed)).kind(DensityMapKind.EM); + } + return new Builder(new PdbId(trimmed), null); + } + + /** + * Normalises an EMDB identifier to the canonical EMD-1234 form. + * + * @param emdbId an identifier such as emd-1234, EMD_1234 + * or a bare number + * @return the canonical form + */ + public static String normalizeEmdbId(String emdbId) { + if (emdbId == null) { + return null; + } + String digits = emdbId.trim().toUpperCase().replaceFirst("^EMD[-_]?", ""); + return "EMD-" + digits; + } + + /** + * Extracts the numeric part of an EMDB identifier, as used in file names and + * some URL templates. + * + * @param emdbId an EMDB identifier in any accepted form + * @return the digits, without the EMD- prefix + */ + public static String emdbNumber(String emdbId) { + return normalizeEmdbId(emdbId).substring("EMD-".length()); + } + + /** @return the PDB entry requested, or null for an EMDB-only request */ + public PdbId getPdbId() { + return pdbId; + } + + /** @return the EMDB entry, if known or explicitly requested; otherwise null */ + public String getEmdbId() { + return emdbId; + } + + /** @return the kind of map wanted; never null */ + public DensityMapKind getKind() { + return kind; + } + + /** @return how aggressively to re-fetch, or null to use the cache's setting */ + public FetchBehavior getFetchBehavior() { + return fetchBehavior; + } + + /** @return an override for the cache directory, or null to use the cache's own */ + public File getCacheDir() { + return cacheDir; + } + + /** + * Whether sources that deliver something a viewer cannot contour directly + * — currently only map coefficients — may be used. + * + * @return true by default; a viewer should set it to + * false + */ + public boolean isAllowNonRenderableFormats() { + return allowNonRenderableFormats; + } + + /** @return the download size limit in bytes, or 0 for no limit */ + public long getMaxDownloadBytes() { + return maxDownloadBytes; + } + + /** @return an explicit source order, or null to let the cache choose */ + public List getSourceChain() { + return sourceChain; + } + + /** + * Returns a copy of this request with a different map kind, used when expanding + * {@link DensityMapKind#AUTO}. + * + * @param newKind the kind to use + * @return a new request + */ + public DensityMapRequest withKind(DensityMapKind newKind) { + return toBuilder().kind(newKind).build(); + } + + /** + * Returns a copy of this request with the EMDB entry filled in. + * + * @param newEmdbId the EMDB identifier + * @return a new request + */ + public DensityMapRequest withEmdbId(String newEmdbId) { + return toBuilder().emdbId(newEmdbId).build(); + } + + private Builder toBuilder() { + Builder b = new Builder(pdbId, emdbId); + b.kind = kind; + b.fetchBehavior = fetchBehavior; + b.cacheDir = cacheDir; + b.allowNonRenderableFormats = allowNonRenderableFormats; + b.maxDownloadBytes = maxDownloadBytes; + b.sourceChain = sourceChain; + return b; + } + + @Override + public String toString() { + return String.format("DensityMapRequest[%s%s, %s]", + pdbId == null ? "" : pdbId.getId(), + emdbId == null ? "" : (pdbId == null ? emdbId : "/" + emdbId), + kind); + } + + /** + * Builder for {@link DensityMapRequest}. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ + public static final class Builder { + + private final PdbId pdbId; + private String emdbId; + private DensityMapKind kind = DensityMapKind.AUTO; + private FetchBehavior fetchBehavior; + private File cacheDir; + private boolean allowNonRenderableFormats = true; + private long maxDownloadBytes = -1; + private List sourceChain; + + private Builder(PdbId pdbId, String emdbId) { + this.pdbId = pdbId; + this.emdbId = emdbId; + } + + /** @param kind the kind of map wanted; null means {@link DensityMapKind#AUTO} */ + public Builder kind(DensityMapKind kind) { + this.kind = kind == null ? DensityMapKind.AUTO : kind; + return this; + } + + /** @param emdbId the EMDB entry to use, skipping the PDB-to-EMDB lookup */ + public Builder emdbId(String emdbId) { + this.emdbId = emdbId == null ? null : normalizeEmdbId(emdbId); + return this; + } + + /** @param fetchBehavior how aggressively to re-fetch */ + public Builder fetchBehavior(FetchBehavior fetchBehavior) { + this.fetchBehavior = fetchBehavior; + return this; + } + + /** @param cacheDir an override for the cache directory */ + public Builder cacheDir(File cacheDir) { + this.cacheDir = cacheDir; + return this; + } + + /** + * @param allow whether formats that cannot be contoured directly may be used. + * A viewer should pass false. + */ + public Builder allowNonRenderableFormats(boolean allow) { + this.allowNonRenderableFormats = allow; + return this; + } + + /** @param bytes the download size limit, or 0 for no limit, or a negative value to use the cache's setting */ + public Builder maxDownloadBytes(long bytes) { + this.maxDownloadBytes = bytes; + return this; + } + + /** @param chain an explicit source order, overriding the cache's choice */ + public Builder sourceChain(List chain) { + this.sourceChain = chain; + return this; + } + + /** @return the finished request */ + public DensityMapRequest build() { + if (pdbId == null && emdbId == null) { + throw new IllegalArgumentException("A request needs either a PDB ID or an EMDB ID"); + } + return new DensityMapRequest(this); + } + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapResult.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapResult.java new file mode 100644 index 0000000000..7275466c9e --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapResult.java @@ -0,0 +1,282 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.time.Instant; +import java.util.Properties; + +import org.biojava.nbio.structure.PdbId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A density map that was successfully obtained, together with everything a + * caller needs to know in order to use it. + *

+ * Which source answered matters to the caller and is therefore part of the + * result: the file might be a full CCP4 grid, a downsampled volume slice, or + * — if non-renderable formats were allowed — a set of structure + * factors that must be Fourier-transformed before anything can be drawn. See + * {@link #isRenderable()}. + *

+ * Every field is persisted to a .meta sidecar beside the cached + * file, so a result can be reconstructed later without contacting any server. + * That is what lets the cache serve + * {@link org.biojava.nbio.structure.io.LocalPDBDirectory.FetchBehavior#LOCAL_ONLY} + * requests fully offline. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class DensityMapResult { + + private static final Logger logger = LoggerFactory.getLogger(DensityMapResult.class); + + /** Extension of the metadata sidecar written beside every cached map. */ + public static final String META_EXT = ".meta"; + + private final File file; + private final DensityMapSource source; + private final DensityFileFormat format; + private final DensityMapKind kind; + private final PdbId pdbId; + private final String emdbId; + private final String sourceUrl; + private final boolean fromCache; + private final Double recommendedContourLevel; + private final Double sigma; + + /** + * @param file the cached map file + * @param source which service supplied it + * @param format the file format + * @param kind what the values mean; never {@link DensityMapKind#AUTO} + * @param pdbId the PDB entry, may be null + * @param emdbId the EMDB entry, may be null + * @param sourceUrl the URL it came from + * @param fromCache whether it was already on disk rather than freshly downloaded + * @param recommendedContourLevel the author-recommended contour level in absolute + * map units, or null if unknown + * @param sigma the RMS deviation of the map, or null if unknown + */ + public DensityMapResult(File file, DensityMapSource source, DensityFileFormat format, DensityMapKind kind, + PdbId pdbId, String emdbId, String sourceUrl, boolean fromCache, + Double recommendedContourLevel, Double sigma) { + this.file = file; + this.source = source; + this.format = format; + this.kind = kind; + this.pdbId = pdbId; + this.emdbId = emdbId; + this.sourceUrl = sourceUrl; + this.fromCache = fromCache; + this.recommendedContourLevel = recommendedContourLevel; + this.sigma = sigma; + } + + /** @return the cached map file */ + public File getFile() { + return file; + } + + /** @return which service supplied the map */ + public DensityMapSource getSource() { + return source; + } + + /** @return the file format */ + public DensityFileFormat getFormat() { + return format; + } + + /** @return what the map values mean; never {@link DensityMapKind#AUTO} */ + public DensityMapKind getKind() { + return kind; + } + + /** @return the PDB entry, or null */ + public PdbId getPdbId() { + return pdbId; + } + + /** @return the EMDB entry, or null */ + public String getEmdbId() { + return emdbId; + } + + /** @return the URL the map was fetched from */ + public String getSourceUrl() { + return sourceUrl; + } + + /** @return true if the file was already cached rather than downloaded now */ + public boolean isFromCache() { + return fromCache; + } + + /** + * Whether a viewer can contour this file as it stands. + * + * @return false for map coefficients, which need a Fourier + * transform first + * @see DensityFileFormat#isJmolLoadable() + */ + public boolean isRenderable() { + return format != null && format.isJmolLoadable(); + } + + /** + * The contour level recommended by the depositors, in absolute map units. + * Normally only available for EMDB maps, where it is the conventional way to + * contour rather than a multiple of sigma. + * + * @return the level, or null if unknown + */ + public Double getRecommendedContourLevel() { + return recommendedContourLevel; + } + + /** + * @return the RMS deviation of the map values, or null if unknown + */ + public Double getSigma() { + return sigma; + } + + /** + * The recommended contour expressed in multiples of sigma, for viewers that + * prefer to work that way. + * + * @return the level divided by sigma, or null if either is unknown + * or sigma is zero + */ + public Double getContourInSigma() { + if (recommendedContourLevel == null || sigma == null || sigma == 0.0) { + return null; + } + return recommendedContourLevel / sigma; + } + + /** @return the size of the cached file in bytes, or 0 if it is missing */ + public long getFileSizeBytes() { + return file == null ? 0 : file.length(); + } + + /** + * The metadata sidecar file for a cached map. + * + * @param mapFile the cached map + * @return the sidecar path, which need not exist + */ + public static File metaFileFor(File mapFile) { + return new File(mapFile.getAbsoluteFile().getParentFile(), mapFile.getName() + META_EXT); + } + + /** + * Writes this result's metadata beside the cached file, so that it can be + * reconstructed later without any network access. + */ + public void writeMeta() { + Properties p = new Properties(); + put(p, "source", source); + put(p, "format", format); + put(p, "kind", kind); + put(p, "pdbId", pdbId == null ? null : pdbId.getId()); + put(p, "emdbId", emdbId); + put(p, "url", sourceUrl); + put(p, "downloaded", Instant.now().toString()); + put(p, "bytes", getFileSizeBytes()); + put(p, "contourLevel", recommendedContourLevel); + put(p, "sigma", sigma); + File meta = metaFileFor(file); + try (OutputStream out = Files.newOutputStream(meta.toPath())) { + p.store(out, "BioJava density map metadata"); + } catch (IOException e) { + // Losing the sidecar costs us the offline description, not the map itself. + logger.warn("Could not write density metadata [{}]: {}", meta, e.getMessage()); + } + } + + /** + * Reconstructs a result from a cached file and its metadata sidecar. + * + * @param mapFile the cached map + * @return the reconstructed result, or null if the sidecar is + * missing or unusable + */ + public static DensityMapResult readMeta(File mapFile) { + File meta = metaFileFor(mapFile); + if (!meta.isFile()) { + return null; + } + Properties p = new Properties(); + try (InputStream in = Files.newInputStream(meta.toPath())) { + p.load(in); + } catch (IOException e) { + logger.warn("Could not read density metadata [{}]: {}", meta, e.getMessage()); + return null; + } + try { + String pdb = p.getProperty("pdbId"); + return new DensityMapResult(mapFile, + DensityMapSource.valueOf(p.getProperty("source")), + DensityFileFormat.valueOf(p.getProperty("format")), + DensityMapKind.valueOf(p.getProperty("kind")), + pdb == null || pdb.isEmpty() ? null : new PdbId(pdb), + emptyToNull(p.getProperty("emdbId")), + p.getProperty("url"), + true, + parseDouble(p.getProperty("contourLevel")), + parseDouble(p.getProperty("sigma"))); + } catch (RuntimeException e) { + logger.warn("Density metadata [{}] is unusable: {}", meta, e.getMessage()); + return null; + } + } + + private static void put(Properties p, String key, Object value) { + p.setProperty(key, value == null ? "" : String.valueOf(value)); + } + + private static String emptyToNull(String s) { + return s == null || s.isEmpty() ? null : s; + } + + private static Double parseDouble(String s) { + if (s == null || s.isEmpty()) { + return null; + } + try { + return Double.valueOf(s); + } catch (NumberFormatException e) { + return null; + } + } + + @Override + public String toString() { + return String.format("%s %s map from %s (%s, %d bytes)%s", + pdbId == null ? emdbId : pdbId.getId(), kind, source, format, getFileSizeBytes(), + isRenderable() ? "" : " [needs an FFT before it can be displayed]"); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapSource.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapSource.java new file mode 100644 index 0000000000..a0f2e1a365 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapSource.java @@ -0,0 +1,84 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +/** + * A remote service that density data can be fetched from. + *

+ * These differ enormously in size for the same entry, which is why more than one + * is supported. For PDB entry 1cbs a density-server slice at the coarsest detail + * level is about 210 kB and contains both the 2Fo-Fc and Fo-Fc maps, where + * the two pre-computed CCP4 files come to about 2.1 MB together. For cryo-EM + * the gap is far wider: the primary map of EMD-0262 is about 106 MB, against + * roughly 480 kB for the equivalent density-server slice. + *

+ * Note that RCSB's own edmaps.rcsb.org service, which used to serve + * DSN6 and MTZ files, was shut down in October 2024. The map coefficients + * published with the wwPDB validation reports replaced it, but those are + * structure factors rather than a sampled grid; see + * {@link DensityFileFormat#MAP_COEFFICIENTS_CIF_GZ}. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public enum DensityMapSource { + + /** + * RCSB's Mol* density server at maps.rcsb.org, which serves + * downsampled BinaryCIF volume slices for both X-ray and EM entries. + */ + RCSB_VOLUME_SERVER("rcsbvs"), + + /** + * PDBe's Mol* density server, equivalent to {@link #RCSB_VOLUME_SERVER}. + */ + PDBE_VOLUME_SERVER("pdbevs"), + + /** + * PDBe's pre-computed full CCP4 maps. This is the source Jmol itself uses for + * its built-in map-loading shortcuts. + */ + PDBE_CCP4("pdbe"), + + /** + * The primary map of an EMDB entry, at full resolution. Can be very large. + */ + EMDB_MAP("emdb"), + + /** + * Map coefficients from the wwPDB validation reports. Archival only: these + * cannot be displayed without an FFT. + */ + WWPDB_MAP_COEFFICIENTS("wwpdb"); + + private final String fileToken; + + DensityMapSource(String fileToken) { + this.fileToken = fileToken; + } + + /** + * The short token used to distinguish this source in a cached file name, so + * that maps of the same kind from different sources never collide. + * + * @return the token + */ + public String getFileToken() { + return fileToken; + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapTooLargeException.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapTooLargeException.java new file mode 100644 index 0000000000..ce62e36d1d --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/DensityMapTooLargeException.java @@ -0,0 +1,84 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.IOException; + +/** + * Thrown when a density map exceeds the configured download size limit. + *

+ * Cryo-EM primary maps in particular can be very large — hundreds of + * megabytes is common and gigabyte maps exist — while a downsampled slice + * of the same map from a density server is usually a fraction of a percent of + * that size and quite adequate for display. Rather than failing, the fallback + * chain treats this as "try the next source", which is exactly why the density + * servers are tried ahead of the full-resolution archives. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class DensityMapTooLargeException extends IOException { + + private static final long serialVersionUID = 1L; + + private final long sizeBytes; + private final long limitBytes; + + /** + * @param url the resource that was too large + * @param sizeBytes its size, or a negative value if only a lower bound is known + * @param limitBytes the configured limit + */ + public DensityMapTooLargeException(String url, long sizeBytes, long limitBytes) { + super(String.format("%s is %s, which exceeds the %s download limit.", + url, describe(sizeBytes), describe(limitBytes))); + this.sizeBytes = sizeBytes; + this.limitBytes = limitBytes; + } + + private static String describe(long bytes) { + if (bytes < 0) { + return "of unknown size"; + } + if (bytes < 1024) { + return bytes + " B"; + } + double value = bytes; + String[] units = {"kB", "MB", "GB", "TB"}; + int unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return String.format("%.1f %s", value, units[unit]); + } + + /** + * @return the size of the resource in bytes, or a negative value if unknown + */ + public long getSizeBytes() { + return sizeBytes; + } + + /** + * @return the configured limit in bytes + */ + public long getLimitBytes() { + return limitBytes; + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbEntryInfo.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbEntryInfo.java new file mode 100644 index 0000000000..7c8b0dd302 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbEntryInfo.java @@ -0,0 +1,85 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +/** + * The few facts about an EMDB entry's primary map that matter when fetching and + * displaying it. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class EmdbEntryInfo { + + private final String emdbId; + private final Double recommendedContourLevel; + private final Double sigma; + private final Long mapSizeBytes; + + /** + * @param emdbId the entry identifier, canonical form + * @param recommendedContourLevel the author-recommended contour level in absolute + * map units, or null + * @param sigma the RMS deviation of the map values, or null + * @param mapSizeBytes the size of the primary map file, or null + */ + public EmdbEntryInfo(String emdbId, Double recommendedContourLevel, Double sigma, Long mapSizeBytes) { + this.emdbId = emdbId; + this.recommendedContourLevel = recommendedContourLevel; + this.sigma = sigma; + this.mapSizeBytes = mapSizeBytes; + } + + /** @return the entry identifier in canonical EMD-1234 form */ + public String getEmdbId() { + return emdbId; + } + + /** + * The contour level the depositors recommend, in absolute map units. EM maps + * are conventionally displayed at this level rather than at a multiple of + * sigma, so it is the right default for a viewer. + * + * @return the level, or null if the entry does not state one + */ + public Double getRecommendedContourLevel() { + return recommendedContourLevel; + } + + /** + * @return the RMS deviation of the map values, which converts between absolute + * and sigma-relative contour levels, or null if unknown + */ + public Double getSigma() { + return sigma; + } + + /** + * @return the size of the full primary map in bytes, or null if + * unknown. Used to decline a download before it starts. + */ + public Long getMapSizeBytes() { + return mapSizeBytes; + } + + @Override + public String toString() { + return String.format("%s[contour=%s, sigma=%s, %s bytes]", + emdbId, recommendedContourLevel, sigma, mapSizeBytes); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbEntryResolver.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbEntryResolver.java new file mode 100644 index 0000000000..fab0101b34 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbEntryResolver.java @@ -0,0 +1,382 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.align.util.URLConnectionTools; +import org.biojava.nbio.structure.io.LocalPDBDirectory.FetchBehavior; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Finds the EMDB entry, if any, associated with a PDB entry, and reads the few + * facts about its map that are needed to fetch and display it. + *

+ * The primary lookup is EMDB's own search API, which returns the identifier and + * the author-recommended contour level together in a single small CSV response. + * That is also the route Jmol uses, so BioJava's answer matches what a user sees + * elsewhere. If it fails, RCSB's entry API is consulted for the identifier alone. + *

+ * The experimental method is deliberately not inferred from a structure's + * resolution: BioJava is known to mis-parse resolution for some cryo-EM entries + * (biojava/biojava#1000), so the presence of an EMDB identifier is the reliable + * signal. + *

+ * Answers are cached on disk. Under + * {@link FetchBehavior#LOCAL_ONLY} the cached answer is used whatever its age and + * no connection is opened. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class EmdbEntryResolver { + + private static final Logger logger = LoggerFactory.getLogger(EmdbEntryResolver.class); + + /** + * Default template for the EMDB search that maps a PDB entry to the EM + * reconstructions it was fitted into, returning the contour level as well. + */ + public static final String DEFAULT_SEARCH_URL_TEMPLATE = + "https://www.ebi.ac.uk/emdb/api/search/fitted_pdbs:{pdbid_lc}?fl=emdb_id,map_contour_level_value&wt=csv"; + + /** Default template for the EMDB entry map metadata. */ + public static final String DEFAULT_MAP_INFO_URL_TEMPLATE = + "https://www.ebi.ac.uk/emdb/api/entry/map/{emdb_id}"; + + /** Default template for the RCSB entry record, used as a fallback. */ + public static final String DEFAULT_RCSB_ENTRY_URL_TEMPLATE = + "https://data.rcsb.org/rest/v1/core/entry/{pdbid_lc}"; + + private static final int TIMEOUT_MILLIS = 30000; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static String searchUrlTemplate = DEFAULT_SEARCH_URL_TEMPLATE; + private static String mapInfoUrlTemplate = DEFAULT_MAP_INFO_URL_TEMPLATE; + private static String rcsbEntryUrlTemplate = DEFAULT_RCSB_ENTRY_URL_TEMPLATE; + + private File cacheRoot; + private FetchBehavior fetchBehavior = FetchBehavior.FETCH_FILES; + private int mappingMaxAgeDays = 30; + + /** + * @param cacheRoot the BioJava cache directory + */ + public EmdbEntryResolver(File cacheRoot) { + this.cacheRoot = cacheRoot; + } + + /** @param cacheRoot the BioJava cache directory */ + public void setCacheRoot(File cacheRoot) { + this.cacheRoot = cacheRoot; + } + + /** @param fetchBehavior how aggressively to refresh cached answers */ + public void setFetchBehavior(FetchBehavior fetchBehavior) { + this.fetchBehavior = fetchBehavior == null ? FetchBehavior.FETCH_FILES : fetchBehavior; + } + + /** + * @param days how long a cached mapping stays fresh. Mappings change only when + * an entry is re-released, so this can be generous. + */ + public void setMappingMaxAgeDays(int days) { + this.mappingMaxAgeDays = days; + } + + /** @param template the EMDB search URL template */ + public static void setSearchUrlTemplate(String template) { + searchUrlTemplate = template == null ? DEFAULT_SEARCH_URL_TEMPLATE : template; + } + + /** @param template the EMDB map metadata URL template */ + public static void setMapInfoUrlTemplate(String template) { + mapInfoUrlTemplate = template == null ? DEFAULT_MAP_INFO_URL_TEMPLATE : template; + } + + /** @param template the RCSB entry URL template used as a fallback */ + public static void setRcsbEntryUrlTemplate(String template) { + rcsbEntryUrlTemplate = template == null ? DEFAULT_RCSB_ENTRY_URL_TEMPLATE : template; + } + + /** Restores all default URL templates. */ + public static void resetToDefaults() { + searchUrlTemplate = DEFAULT_SEARCH_URL_TEMPLATE; + mapInfoUrlTemplate = DEFAULT_MAP_INFO_URL_TEMPLATE; + rcsbEntryUrlTemplate = DEFAULT_RCSB_ENTRY_URL_TEMPLATE; + } + + /** + * The EMDB entries a PDB entry was fitted into. + * + * @param pdbId the PDB entry + * @return the EMDB identifiers, most relevant first; empty if the entry is not + * an EM structure or has no associated map + */ + public List getEmdbIds(PdbId pdbId) { + Mapping cached = readMapping(pdbId); + if (cached != null && (fetchBehavior == FetchBehavior.LOCAL_ONLY || cached.isFresh(mappingMaxAgeDays))) { + return cached.emdbIds; + } + if (fetchBehavior == FetchBehavior.LOCAL_ONLY) { + return Collections.emptyList(); + } + + Mapping fetched = searchEmdb(pdbId); + if (fetched == null) { + fetched = queryRcsb(pdbId); + } + if (fetched == null) { + // Serve a stale answer rather than nothing when the services are unreachable. + return cached == null ? Collections.emptyList() : cached.emdbIds; + } + writeMapping(pdbId, fetched); + return fetched.emdbIds; + } + + /** + * The contour level the depositors recommend for the map a PDB entry was fitted + * into, as reported by the EMDB search. + * + * @param pdbId the PDB entry + * @return the level in absolute map units, or null if unknown + */ + public Double getContourLevelFor(PdbId pdbId) { + Mapping cached = readMapping(pdbId); + if (cached != null && (fetchBehavior == FetchBehavior.LOCAL_ONLY || cached.isFresh(mappingMaxAgeDays))) { + return cached.contourLevel; + } + getEmdbIds(pdbId); + Mapping refreshed = readMapping(pdbId); + return refreshed == null ? null : refreshed.contourLevel; + } + + /** + * Reads an EMDB entry's map metadata: contour level, RMS deviation and the size + * of the primary map file. + *

+ * The response is cached verbatim, so asking twice costs one request. + * + * @param emdbId the EMDB entry, in any accepted form + * @return the metadata, or null if it could not be obtained + */ + public EmdbEntryInfo getEntryInfo(String emdbId) { + String canonical = DensityMapRequest.normalizeEmdbId(emdbId); + File cacheFile = DensityCacheLayout.emdbMapInfoFile(cacheRoot, canonical); + + String json = null; + if (cacheFile.isFile()) { + try { + json = new String(Files.readAllBytes(cacheFile.toPath()), StandardCharsets.UTF_8); + } catch (IOException e) { + logger.debug("Could not read cached EMDB metadata [{}]: {}", cacheFile, e.getMessage()); + } + } + if (json == null) { + if (fetchBehavior == FetchBehavior.LOCAL_ONLY) { + return null; + } + String url = UrlTemplates.expand(mapInfoUrlTemplate, UrlTemplates.values(null, canonical, -1)); + try { + json = read(new URL(url)); + } catch (IOException e) { + logger.warn("Could not read EMDB metadata for {}: {}", canonical, e.getMessage()); + return null; + } + writeCache(cacheFile, json); + } + + try { + JsonNode map = MAPPER.readTree(json).path("map"); + Double contour = null; + JsonNode contours = map.path("contour_list").path("contour"); + for (JsonNode c : contours) { + if (contour == null || c.path("primary").asBoolean(false)) { + contour = c.path("level").isNumber() ? c.path("level").asDouble() : contour; + } + } + Double sigma = map.path("statistics").path("std").isNumber() + ? map.path("statistics").path("std").asDouble() : null; + Long bytes = map.path("size_kbytes").isNumber() + ? map.path("size_kbytes").asLong() * 1024L : null; + return new EmdbEntryInfo(canonical, contour, sigma, bytes); + } catch (IOException | RuntimeException e) { + logger.warn("Could not parse EMDB metadata for {}: {}", canonical, e.getMessage()); + return null; + } + } + + private Mapping searchEmdb(PdbId pdbId) { + String url = UrlTemplates.expand(searchUrlTemplate, + UrlTemplates.values(DensityCacheLayout.shortIdOrFull(pdbId), null, -1)); + try { + String csv = read(new URL(url)); + List ids = new ArrayList<>(); + Double contour = null; + String[] lines = csv.split("\\R"); + for (int i = 1; i < lines.length; i++) { // line 0 is the header + String line = lines[i].trim(); + if (line.isEmpty()) { + continue; + } + String[] cols = line.split(","); + if (cols.length > 0 && !cols[0].isEmpty()) { + ids.add(DensityMapRequest.normalizeEmdbId(cols[0])); + } + if (contour == null && cols.length > 1 && !cols[1].isEmpty()) { + try { + contour = Double.valueOf(cols[1].trim()); + } catch (NumberFormatException ignored) { + // the column is optional and occasionally blank + } + } + } + return new Mapping(ids, contour, Instant.now()); + } catch (IOException e) { + logger.debug("EMDB search for {} failed: {}", pdbId.getId(), e.getMessage()); + return null; + } + } + + private Mapping queryRcsb(PdbId pdbId) { + String url = UrlTemplates.expand(rcsbEntryUrlTemplate, + UrlTemplates.values(DensityCacheLayout.shortIdOrFull(pdbId), null, -1)); + try { + JsonNode root = MAPPER.readTree(read(new URL(url))); + JsonNode ids = root.path("rcsb_entry_container_identifiers").path("emdb_ids"); + List result = new ArrayList<>(); + for (JsonNode id : ids) { + result.add(DensityMapRequest.normalizeEmdbId(id.asText())); + } + return new Mapping(result, null, Instant.now()); + } catch (IOException | RuntimeException e) { + logger.debug("RCSB entry lookup for {} failed: {}", pdbId.getId(), e.getMessage()); + return null; + } + } + + private String read(URL url) throws IOException { + try (InputStream in = URLConnectionTools.getInputStream(url, true, TIMEOUT_MILLIS); + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + char[] buffer = new char[8192]; + int n; + while ((n = reader.read(buffer)) != -1) { + sb.append(buffer, 0, n); + } + return sb.toString(); + } + } + + private Mapping readMapping(PdbId pdbId) { + File file = DensityCacheLayout.emdbMappingFile(cacheRoot, pdbId); + if (!file.isFile()) { + return null; + } + Properties p = new Properties(); + try (InputStream in = Files.newInputStream(file.toPath())) { + p.load(in); + } catch (IOException e) { + return null; + } + String ids = p.getProperty("emdbIds", ""); + List list = ids.isEmpty() ? Collections.emptyList() : Arrays.asList(ids.split(",")); + Double contour = null; + try { + String c = p.getProperty("contourLevel", ""); + contour = c.isEmpty() ? null : Double.valueOf(c); + } catch (NumberFormatException ignored) { + // leave it null + } + Instant retrieved; + try { + retrieved = Instant.parse(p.getProperty("retrieved")); + } catch (RuntimeException e) { + retrieved = Instant.EPOCH; + } + return new Mapping(list, contour, retrieved); + } + + private void writeMapping(PdbId pdbId, Mapping mapping) { + File file = DensityCacheLayout.emdbMappingFile(cacheRoot, pdbId); + File dir = file.getParentFile(); + if (!dir.isDirectory() && !dir.mkdirs()) { + logger.debug("Could not create [{}]", dir); + return; + } + Properties p = new Properties(); + p.setProperty("pdbId", DensityCacheLayout.shortIdOrFull(pdbId)); + p.setProperty("emdbIds", String.join(",", mapping.emdbIds)); + p.setProperty("contourLevel", mapping.contourLevel == null ? "" : mapping.contourLevel.toString()); + p.setProperty("retrieved", mapping.retrieved.toString()); + try (OutputStream out = Files.newOutputStream(file.toPath())) { + p.store(out, "BioJava PDB to EMDB mapping"); + } catch (IOException e) { + logger.debug("Could not cache the EMDB mapping for {}: {}", pdbId.getId(), e.getMessage()); + } + } + + private void writeCache(File file, String content) { + File dir = file.getParentFile(); + if (!dir.isDirectory() && !dir.mkdirs()) { + return; + } + try { + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + logger.debug("Could not cache [{}]: {}", file, e.getMessage()); + } + } + + /** A cached PDB-to-EMDB answer. */ + private static final class Mapping { + final List emdbIds; + final Double contourLevel; + final Instant retrieved; + + Mapping(List emdbIds, Double contourLevel, Instant retrieved) { + this.emdbIds = Collections.unmodifiableList(new ArrayList<>(emdbIds)); + this.contourLevel = contourLevel; + this.retrieved = retrieved; + } + + boolean isFresh(int maxAgeDays) { + return Duration.between(retrieved, Instant.now()).toDays() < maxAgeDays; + } + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbMapProvider.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbMapProvider.java new file mode 100644 index 0000000000..7f90814c30 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/EmdbMapProvider.java @@ -0,0 +1,142 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; +import java.io.IOException; +import java.net.URL; + +/** + * Fetches the full-resolution primary map of an EMDB entry. + *

+ * These are gzipped CCP4/MRC files, and they are big: the primary map of + * EMD-0262 is about 106 MB, and gigabyte maps exist. For most display + * purposes a slice from a density server is a far better trade — a few + * hundred kilobytes for the same entry — which is why + * {@link DensityMapCache} tries {@link VolumeServerProvider} first and only falls + * back here. Use this source when the full sampling genuinely matters. + *

+ * The size limit is checked against the size EMDB itself reports before any of + * the body is transferred, so exceeding it costs one small metadata request + * rather than a partial download. + *

+ * Jmol recognises gzip from the file's magic bytes, so the cached + * .map.gz can be handed to it without decompressing first. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class EmdbMapProvider extends AbstractDensityMapProvider { + + /** Default base URL of the EMDB archive. */ + public static final String DEFAULT_SERVER_URL = "https://ftp.ebi.ac.uk/pub/databases/emdb/structures/"; + + /** Default path template for an entry's primary map. */ + public static final String DEFAULT_MAP_TEMPLATE = "{emdb_id}/map/emd_{emdb_num}.map.gz"; + + private static String serverBaseUrl = DEFAULT_SERVER_URL; + private static String mapTemplate = DEFAULT_MAP_TEMPLATE; + + private final EmdbEntryResolver resolver; + + /** + * @param cacheRoot the BioJava cache directory + * @param resolver used to find the EMDB entry for a PDB entry and to read its + * contour level and size + */ + public EmdbMapProvider(File cacheRoot, EmdbEntryResolver resolver) { + super(cacheRoot); + this.resolver = resolver; + } + + /** @return the base URL of the EMDB archive */ + public static String getServerBaseUrl() { + return serverBaseUrl; + } + + /** @param url the base URL; a trailing slash is added if missing */ + public static void setServerBaseUrl(String url) { + serverBaseUrl = url == null ? DEFAULT_SERVER_URL : (url.endsWith("/") ? url : url + "/"); + } + + /** @param template the path template for an entry's primary map */ + public static void setMapUrlTemplate(String template) { + mapTemplate = template == null ? DEFAULT_MAP_TEMPLATE : template; + } + + /** Restores the default server and template. */ + public static void resetToDefaults() { + serverBaseUrl = DEFAULT_SERVER_URL; + mapTemplate = DEFAULT_MAP_TEMPLATE; + } + + @Override + public DensityMapSource getSource() { + return DensityMapSource.EMDB_MAP; + } + + @Override + public DensityFileFormat getFormat() { + return DensityFileFormat.CCP4_GZ; + } + + @Override + public boolean supports(DensityMapKind kind) { + return kind == DensityMapKind.EM; + } + + /** + * Builds the URL of an entry's primary map without fetching it. + * + * @param emdbId the EMDB entry, in any accepted form + * @return the URL as a string + */ + public String buildUrl(String emdbId) { + return serverBaseUrl + UrlTemplates.expand(mapTemplate, UrlTemplates.values(null, emdbId, -1)); + } + + @Override + public DensityMapResult fetch(DensityMapRequest request) throws IOException { + if (!supports(request.getKind())) { + return null; + } + String emdbId = request.getEmdbId(); + if (emdbId == null) { + return null; + } + + EmdbEntryInfo info = resolver == null ? null : resolver.getEntryInfo(emdbId); + + // Decline before transferring anything: EMDB reports the map size in its + // metadata, so an oversized map costs one small request rather than a + // partial multi-hundred-megabyte download. + long limit = effectiveMaxBytes(request); + if (limit > 0 && info != null && info.getMapSizeBytes() != null && info.getMapSizeBytes() > limit) { + URL url = new URL(buildUrl(emdbId)); + reportTooLarge(url, info.getMapSizeBytes(), limit, request); + throw new DensityMapTooLargeException(url.toString(), info.getMapSizeBytes(), limit); + } + + URL url = new URL(buildUrl(emdbId)); + File target = DensityCacheLayout.emdbMapFile(effectiveCacheRoot(request), emdbId, + getSource(), getFormat(), null); + return obtain(request, url, target, DensityMapKind.EM, emdbId, + info == null ? null : info.getRecommendedContourLevel(), + info == null ? null : info.getSigma()); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/NoDensityMapException.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/NoDensityMapException.java new file mode 100644 index 0000000000..6691b584d9 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/NoDensityMapException.java @@ -0,0 +1,99 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.biojava.nbio.structure.PdbId; + +/** + * Thrown when no enabled source could supply a density map for an entry. + *

+ * This is a routine outcome rather than an error: not every entry has density. + * Structures deposited without structure factors have none at all (4HHB, for + * instance), and a cryo-EM entry has no X-ray maps by construction. The + * per-source reasons are kept so that a caller — particularly a user + * interface — can explain why rather than just reporting a failure. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class NoDensityMapException extends IOException { + + private static final long serialVersionUID = 1L; + + private final PdbId pdbId; + private final DensityMapKind kind; + private final Map attempts; + + /** + * @param pdbId the entry that was requested, may be null for a + * request made by EMDB identifier + * @param kind the kind of map that was requested + * @param attempts what happened at each source that was tried, in the order tried + */ + public NoDensityMapException(PdbId pdbId, DensityMapKind kind, Map attempts) { + super(buildMessage(pdbId, kind, attempts)); + this.pdbId = pdbId; + this.kind = kind; + this.attempts = attempts == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(attempts)); + } + + private static String buildMessage(PdbId pdbId, DensityMapKind kind, Map attempts) { + StringBuilder sb = new StringBuilder("No "); + sb.append(kind == null ? "density" : kind).append(" map is available for "); + sb.append(pdbId == null ? "the requested entry" : pdbId.getId()).append('.'); + if (attempts != null && !attempts.isEmpty()) { + sb.append(" Tried:"); + for (Map.Entry e : attempts.entrySet()) { + sb.append(' ').append(e.getKey()).append(" (").append(e.getValue()).append(");"); + } + sb.setLength(sb.length() - 1); + } + return sb.toString(); + } + + /** + * @return the entry that was requested, or null + */ + public PdbId getPdbId() { + return pdbId; + } + + /** + * @return the kind of map that was requested + */ + public DensityMapKind getKind() { + return kind; + } + + /** + * What happened at each source, in the order they were tried. Suitable for + * building a human-readable explanation. + * + * @return an unmodifiable map from source to reason + */ + public Map getAttempts() { + return attempts; + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/PdbeCcp4MapProvider.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/PdbeCcp4MapProvider.java new file mode 100644 index 0000000000..a825ccfa70 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/PdbeCcp4MapProvider.java @@ -0,0 +1,142 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; +import java.io.IOException; +import java.net.URL; + +import org.biojava.nbio.structure.PdbId; + +/** + * Fetches PDBe's pre-computed CCP4 maps. + *

+ * These are full-resolution sampled grids, one file per map kind, and are the + * source Jmol itself uses for its built-in map shortcuts. They are larger than a + * density-server slice — about 1 MB per map for a small entry such as + * 1cbs — but need no interpretation beyond contouring. + *

+ * The service accepts only the lower-case four-character spelling of an + * identifier; 1CBS.ccp4 and pdb_00001cbs.ccp4 both + * return HTTP 404. Entries deposited without structure factors, and cryo-EM + * entries, have no maps here at all. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class PdbeCcp4MapProvider extends AbstractDensityMapProvider { + + /** Default base URL of the PDBe map service. */ + public static final String DEFAULT_SERVER_URL = "https://www.ebi.ac.uk/pdbe/coordinates/files/"; + + /** + * An equivalent base URL serving the same files, kept in the documentation as a + * ready alternative should the primary one change. + */ + public static final String ALTERNATIVE_SERVER_URL = "https://www.ebi.ac.uk/pdbe/entry-files/"; + + /** Default path template for the 2mFo-DFc map. */ + public static final String DEFAULT_TWO_FO_FC_TEMPLATE = "{pdbid_lc}.ccp4"; + + /** Default path template for the mFo-DFc difference map. */ + public static final String DEFAULT_FO_FC_TEMPLATE = "{pdbid_lc}_diff.ccp4"; + + private static String serverBaseUrl = DEFAULT_SERVER_URL; + private static String twoFoFcTemplate = DEFAULT_TWO_FO_FC_TEMPLATE; + private static String foFcTemplate = DEFAULT_FO_FC_TEMPLATE; + + /** + * @param cacheRoot the BioJava cache directory + */ + public PdbeCcp4MapProvider(File cacheRoot) { + super(cacheRoot); + } + + /** @return the base URL of the map service */ + public static String getServerBaseUrl() { + return serverBaseUrl; + } + + /** + * @param url the base URL of the map service; a trailing slash is added if missing + */ + public static void setServerBaseUrl(String url) { + serverBaseUrl = url == null ? DEFAULT_SERVER_URL : (url.endsWith("/") ? url : url + "/"); + } + + /** + * Overrides the path template for a map kind. + * + * @param kind {@link DensityMapKind#TWO_FO_FC} or {@link DensityMapKind#FO_FC} + * @param template a template understood by {@link UrlTemplates} + */ + public static void setPathUrlTemplate(DensityMapKind kind, String template) { + if (kind == DensityMapKind.TWO_FO_FC) { + twoFoFcTemplate = template == null ? DEFAULT_TWO_FO_FC_TEMPLATE : template; + } else if (kind == DensityMapKind.FO_FC) { + foFcTemplate = template == null ? DEFAULT_FO_FC_TEMPLATE : template; + } else { + throw new IllegalArgumentException("PDBe CCP4 maps exist only for 2Fo-Fc and Fo-Fc, not " + kind); + } + } + + /** Restores the default server and templates. */ + public static void resetToDefaults() { + serverBaseUrl = DEFAULT_SERVER_URL; + twoFoFcTemplate = DEFAULT_TWO_FO_FC_TEMPLATE; + foFcTemplate = DEFAULT_FO_FC_TEMPLATE; + } + + @Override + public DensityMapSource getSource() { + return DensityMapSource.PDBE_CCP4; + } + + @Override + public DensityFileFormat getFormat() { + return DensityFileFormat.CCP4; + } + + @Override + public boolean supports(DensityMapKind kind) { + return kind == DensityMapKind.TWO_FO_FC || kind == DensityMapKind.FO_FC; + } + + /** + * Builds the URL for a map without fetching it. + * + * @param pdbId the entry + * @param kind the kind of map + * @return the URL as a string + */ + public String buildUrl(PdbId pdbId, DensityMapKind kind) { + String template = kind == DensityMapKind.FO_FC ? foFcTemplate : twoFoFcTemplate; + return serverBaseUrl + UrlTemplates.expand(template, UrlTemplates.values(urlId(pdbId), null, -1)); + } + + @Override + public DensityMapResult fetch(DensityMapRequest request) throws IOException { + if (request.getPdbId() == null || !supports(request.getKind())) { + return null; + } + URL url = new URL(buildUrl(request.getPdbId(), request.getKind())); + File target = DensityCacheLayout.pdbMapFile(effectiveCacheRoot(request), request.getPdbId(), + request.getKind(), getSource(), getFormat(), null); + return obtain(request, url, target, request.getKind(), null, null, null); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/UrlTemplates.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/UrlTemplates.java new file mode 100644 index 0000000000..0518f1d4a4 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/UrlTemplates.java @@ -0,0 +1,152 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.StructureException; + +/** + * Expands the named placeholders used in the configurable density-server URL + * templates. + *

+ * The recognised placeholders are: + *

+ *
{pdbid}
the PDB identifier as given
+ *
{pdbid_lc}
the PDB identifier in lower case
+ *
{pdbid_uc}
the PDB identifier in upper case
+ *
{mid}
the two-character divided-archive directory, + * e.g. cb for 1cbs
+ *
{extid}
the extended identifier in lower case, the + * spelling the archive itself uses, e.g. pdb_00001cbs for + * 1cbs
+ *
{emdb_id}
the EMDB identifier, e.g. EMD-0262
+ *
{emdb_num}
the EMDB number alone, e.g. 0262
+ *
{detail}
the density-server detail level
+ *
+ * A placeholder with no supplied value is left in place rather than replaced by + * an empty string, so that a misconfigured template produces an obviously wrong + * URL instead of a subtly wrong one. + *

+ * {mid} and {extid} exist for mirrors rather than for + * the default URLs. The services BioJava fetches from resolve an entry by name + * alone, but a site pointing this code at its own copy of the archive has to + * spell out a directory path — and so does any mirror that publishes one, + * such as EBI. Both the present divided layout and the per-entry layout that + * replaces it in July 2027 are directory paths, and between them the two + * placeholders express either without a code change: + *

+ * {mid}/{pdbid_lc}/{pdbid_lc}_validation_2fo-fc_map_coef.cif.gz
+ * entries/{mid}/{extid}/validation_reports/{extid}_validation_2fo-fc_map_coef.cif.gz
+ * 
+ *

+ * This is deliberately separate from the template mechanism in + * {@code DownloadChemCompProvider}, which resolves a single chemical-component + * identifier with optional substring indices. The two solve different problems: + * here several distinct values are substituted by name. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class UrlTemplates { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{([a-zA-Z_]+)\\}"); + + private UrlTemplates() { + } + + /** + * Expands a template against a set of named values. + * + * @param template the template string + * @param values the values, keyed by placeholder name without the braces + * @return the expanded string + */ + public static String expand(String template, Map values) { + if (template == null) { + return null; + } + Matcher m = PLACEHOLDER.matcher(template); + StringBuilder out = new StringBuilder(); + int last = 0; + while (m.find()) { + String value = values.get(m.group(1)); + out.append(template, last, m.start()); + // An unknown placeholder is kept verbatim: a template that was configured + // wrongly should fail loudly rather than quietly produce a plausible URL. + out.append(value == null ? m.group(0) : value); + last = m.end(); + } + out.append(template.substring(last)); + return out.toString(); + } + + /** + * Builds the standard value map for an entry. + * + * @param pdbId the PDB identifier, may be null + * @param emdbId the EMDB identifier in any accepted form, may be null + * @param detail the density-server detail level, or a negative value to omit it + * @return a map suitable for {@link #expand(String, Map)} + */ + public static Map values(String pdbId, String emdbId, int detail) { + Map values = new LinkedHashMap<>(); + if (pdbId != null) { + values.put("pdbid", pdbId); + values.put("pdbid_lc", pdbId.toLowerCase()); + values.put("pdbid_uc", pdbId.toUpperCase()); + if (pdbId.length() >= 3) { + // Correct for both spellings: the hash is counted from the right hand + // end, so 1cbs and pdb_00001cbs both yield "cb". + values.put("mid", org.biojava.nbio.structure.io.LocalPDBDirectory.getMiddleHash(pdbId)); + } + String extendedId = extendedId(pdbId); + if (extendedId != null) { + values.put("extid", extendedId); + } + } + if (emdbId != null) { + values.put("emdb_id", DensityMapRequest.normalizeEmdbId(emdbId)); + values.put("emdb_num", DensityMapRequest.emdbNumber(emdbId)); + } + if (detail >= 0) { + values.put("detail", Integer.toString(detail)); + } + return values; + } + + /** + * The extended spelling of an identifier, in the lower case the archive uses. + * + * @param pdbId an identifier in either spelling + * @return the extended spelling, or null if the argument is + * neither a short nor an extended identifier, in which case + * {extid} is left unexpanded rather than guessed at + */ + private static String extendedId(String pdbId) { + try { + return PdbId.toExtendedId(pdbId).toLowerCase(); + } catch (StructureException e) { + return null; + } + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/VolumeServerProvider.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/VolumeServerProvider.java new file mode 100644 index 0000000000..1700f3a472 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/VolumeServerProvider.java @@ -0,0 +1,246 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; + +/** + * Fetches downsampled volume slices from a Mol* density server. + *

+ * This is usually the most economical source by a wide margin. For PDB entry + * 1cbs the coarsest slice is about 210 kB against roughly 2.1 MB for + * the two pre-computed CCP4 files, and for cryo-EM the difference is far larger + * still: about 480 kB against the 106 MB primary map of EMD-0262. A + * single response carries both the 2Fo-Fc and Fo-Fc data blocks for X-ray + * entries. + *

+ * Caveats worth knowing. Responses are chunked, with no + * Content-Length, no ETag and no + * Last-Modified, so the usual server-provided validation metadata is + * unavailable; the size recorded for a cached slice is the byte count observed + * during our own download instead. Detail levels are not linear either — + * for a small entry the response stops growing past detail 1, while for a large + * EM map the step from detail 2 to 3 multiplies the size several-fold. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class VolumeServerProvider extends AbstractDensityMapProvider { + + /** + * Which density server to talk to. + */ + public enum Host { + /** RCSB's server at maps.rcsb.org. */ + RCSB(DensityMapSource.RCSB_VOLUME_SERVER, "https://maps.rcsb.org/", 3), + /** PDBe's server at www.ebi.ac.uk/pdbe/volume-server. */ + PDBE(DensityMapSource.PDBE_VOLUME_SERVER, "https://www.ebi.ac.uk/pdbe/volume-server/", 6); + + private final DensityMapSource source; + private final String defaultBaseUrl; + private final int defaultDetail; + + Host(DensityMapSource source, String defaultBaseUrl, int defaultDetail) { + this.source = source; + this.defaultBaseUrl = defaultBaseUrl; + this.defaultDetail = defaultDetail; + } + + /** @return the source constant this host corresponds to */ + public DensityMapSource getSource() { + return source; + } + + /** @return the default base URL */ + public String getDefaultBaseUrl() { + return defaultBaseUrl; + } + + /** @return the detail level this host's own clients use by default */ + public int getDefaultDetail() { + return defaultDetail; + } + } + + /** Default path template for an X-ray entry's whole unit cell. */ + public static final String DEFAULT_XRAY_CELL_TEMPLATE = "x-ray/{pdbid_lc}/cell?detail={detail}&encoding={encoding}"; + + /** Default path template for an EM entry's whole cell. */ + public static final String DEFAULT_EM_CELL_TEMPLATE = "em/emd-{emdb_num}/cell?detail={detail}&encoding={encoding}"; + + private final Host host; + private String baseUrl; + private int detail; + private String encoding = "bcif"; + + /** + * @param cacheRoot the BioJava cache directory + * @param host which density server to use + */ + public VolumeServerProvider(File cacheRoot, Host host) { + super(cacheRoot); + this.host = host; + this.baseUrl = host.getDefaultBaseUrl(); + this.detail = host.getDefaultDetail(); + } + + /** @return which density server this instance uses */ + public Host getHost() { + return host; + } + + /** @return the base URL in use */ + public String getBaseUrl() { + return baseUrl; + } + + /** @param baseUrl the base URL; a trailing slash is added if missing */ + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl == null ? host.getDefaultBaseUrl() + : (baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"); + } + + /** @return the detail level requested from the server */ + public int getDetail() { + return detail; + } + + /** + * Sets the detail level. Higher means a finer grid and a larger download; the + * relationship is neither linear nor the same for every entry. + * + * @param detail the level, normally 0 to 6 + */ + public void setDetail(int detail) { + this.detail = detail; + } + + /** @return the encoding requested, either bcif or cif */ + public String getEncoding() { + return encoding; + } + + /** + * @param encoding bcif for BinaryCIF (default, and much smaller) or + * cif for text + */ + public void setEncoding(String encoding) { + this.encoding = encoding == null ? "bcif" : encoding; + } + + @Override + public DensityMapSource getSource() { + return host.getSource(); + } + + @Override + public DensityFileFormat getFormat() { + return "cif".equalsIgnoreCase(encoding) ? DensityFileFormat.CIF_VOLUME : DensityFileFormat.BCIF_VOLUME; + } + + @Override + public boolean supports(DensityMapKind kind) { + return kind == DensityMapKind.TWO_FO_FC || kind == DensityMapKind.FO_FC || kind == DensityMapKind.EM; + } + + /** + * Builds the URL for a request without fetching it. + * + * @param request the request; its kind must be concrete + * @return the URL as a string, or null if the request cannot be served + */ + public String buildUrl(DensityMapRequest request) { + if (request.getKind() == DensityMapKind.EM) { + if (request.getEmdbId() == null) { + return null; + } + return baseUrl + UrlTemplates.expand(withEncoding(DEFAULT_EM_CELL_TEMPLATE), + UrlTemplates.values(null, request.getEmdbId(), detail)); + } + if (request.getPdbId() == null) { + return null; + } + return baseUrl + UrlTemplates.expand(withEncoding(DEFAULT_XRAY_CELL_TEMPLATE), + UrlTemplates.values(urlId(request.getPdbId()), null, detail)); + } + + private String withEncoding(String template) { + return template.replace("{encoding}", encoding); + } + + @Override + public DensityMapResult fetch(DensityMapRequest request) throws IOException { + if (!supports(request.getKind())) { + return null; + } + String urlString = buildUrl(request); + if (urlString == null) { + return null; + } + URL url = new URL(urlString); + + // The detail level changes the content, so it has to be part of the cache key. + String qualifier = "d" + detail; + File target; + if (request.getKind() == DensityMapKind.EM) { + target = DensityCacheLayout.emdbMapFile(effectiveCacheRoot(request), request.getEmdbId(), + getSource(), getFormat(), qualifier); + } else { + // One response carries both the 2Fo-Fc and the Fo-Fc blocks, so the two + // kinds share a cache entry: asking for each separately would otherwise + // download and store the identical file twice. Which block to read is + // decided at display time, not here. + target = DensityCacheLayout.pdbMapFile(effectiveCacheRoot(request), request.getPdbId(), + DensityCacheLayout.BOTH_KINDS_TOKEN, getSource(), getFormat(), qualifier); + } + DensityMapResult result = obtain(request, url, target, request.getKind(), request.getEmdbId(), null, null); + + if (request.getKind() == DensityMapKind.FO_FC) { + return presentAsDifferenceMap(result); + } + return result; + } + + /** + * Points the result at the companion file name that makes Jmol read the + * difference-map block. See + * {@link DensityCacheLayout#differenceMarkerFile(File)} for why the marker has + * to be in the name. + *

+ * The companion is a hard link where the filesystem allows one, so the second + * name costs no additional space; a copy is only made if linking is refused. + */ + private DensityMapResult presentAsDifferenceMap(DensityMapResult result) throws IOException { + File marker = DensityCacheLayout.differenceMarkerFile(result.getFile()); + if (!marker.isFile() || marker.length() != result.getFile().length()) { + Files.deleteIfExists(marker.toPath()); + try { + Files.createLink(marker.toPath(), result.getFile().toPath()); + } catch (IOException | UnsupportedOperationException e) { + Files.copy(result.getFile().toPath(), marker.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + return new DensityMapResult(marker, result.getSource(), result.getFormat(), result.getKind(), + result.getPdbId(), result.getEmdbId(), result.getSourceUrl(), result.isFromCache(), + result.getRecommendedContourLevel(), result.getSigma()); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/WwpdbMapCoefficientsProvider.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/WwpdbMapCoefficientsProvider.java new file mode 100644 index 0000000000..bd1b066a64 --- /dev/null +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/density/WwpdbMapCoefficientsProvider.java @@ -0,0 +1,219 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import java.io.File; +import java.io.IOException; +import java.net.URL; + +import org.biojava.nbio.structure.PdbId; + +/** + * Fetches the map coefficients published alongside the wwPDB validation reports. + *

+ * These are not density maps. They are structure-factor amplitudes and + * phases in mmCIF, exactly as used to produce the pictures in a validation + * report, and a Fourier transform is required before anything can be drawn from + * them — gemmi sf2map, or cif2mtz followed by + * CCP4's fft. Nothing in BioJava or Jmol will render them. + *

+ * They are supported because this is the route RCSB documents since + * edmaps.rcsb.org was shut down in October 2024, and because they + * are the authoritative archival form. For anything that needs to be displayed, + * prefer {@link PdbeCcp4MapProvider} or {@link VolumeServerProvider}. Accordingly + * this source is disabled by default in {@link DensityMapCache}. + *

+ * One useful property: these servers return the content MD5 as the HTTP + * ETag, so downloads from here are checksum-verified automatically. + *

+ * The URLs are built against the documented download endpoint, which resolves an + * entry by file name, rather than against the divided archive path. This is the + * only provider here that ever had a choice — the density servers and the + * EMDB archive are addressed by identifier already — and it matters because + * the PDB moves to extended identifiers and a per-entry directory layout in July + * 2027. A name survives that move; a constructed directory path does not. Mirrors + * that publish directories instead of an endpoint are still reachable, through + * {@link #DIVIDED_TWO_FO_FC_TEMPLATE} and {@link #ENTRIES_TWO_FO_FC_TEMPLATE}. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class WwpdbMapCoefficientsProvider extends AbstractDensityMapProvider { + + /** Default base URL, the wwPDB validation report download endpoint. */ + public static final String DEFAULT_SERVER_URL = "https://files.wwpdb.org/validation/download/"; + + /** An RCSB mirror serving byte-identical files. */ + public static final String RCSB_MIRROR_URL = "https://files.rcsb.org/validation/download/"; + + /** + * The wwPDB beta archive, which already holds the re-organised content that + * replaces the current archive on 21 July 2027 and serves the same endpoint. + *

+ * Deliberately not the default, despite being the newer archive. The wwPDB + * describes this host as transitional: on the cutover date the beta archive + * replaces the main one, after which the beta URL is supported by redirection + * for three years. So it is the hostname that needs changing, twice, whereas + * {@link #DEFAULT_SERVER_URL} becomes the new archive and needs changing never. + *

+ * Its value is in testing. Because this host is the post-2027 content today, a + * request against it checks the endpoint against the archive as it will be, + * rather than against the archive as it is. + */ + public static final String BETA_SERVER_URL = "https://files-beta.wwpdb.org/validation/download/"; + + /** + * An EBI mirror serving byte-identical files. + *

+ * Unlike the two above, EBI publishes no name-resolving endpoint — only + * full directory paths — so selecting it means setting the divided + * templates as well: + *

+	 * setServerBaseUrl(EBI_MIRROR_URL);
+	 * setPathUrlTemplate(DensityMapKind.TWO_FO_FC, DIVIDED_TWO_FO_FC_TEMPLATE);
+	 * setPathUrlTemplate(DensityMapKind.FO_FC, DIVIDED_FO_FC_TEMPLATE);
+	 * 
+ * Setting the base alone yields 404s, because the flat file names do not exist + * there. + */ + public static final String EBI_MIRROR_URL = "https://ftp.ebi.ac.uk/pub/databases/pdb/validation_reports/"; + + /** + * Default path template for the 2mFo-DFc coefficients: the file name alone. + *

+ * The endpoint resolves an entry by name, so no directory path is built here. + * That is deliberate. In July 2027 the archive moves to extended identifiers + * and a per-entry directory layout, and a path assembled from a hash and an + * identifier would have to be rewritten for it; a name does not. Both spellings + * of an identifier resolve, so whichever {@code PdbId} yields is accepted. + */ + public static final String DEFAULT_TWO_FO_FC_TEMPLATE = + "{pdbid_lc}_validation_2fo-fc_map_coef.cif.gz"; + + /** Default path template for the mFo-DFc coefficients; see {@link #DEFAULT_TWO_FO_FC_TEMPLATE}. */ + public static final String DEFAULT_FO_FC_TEMPLATE = + "{pdbid_lc}_validation_fo-fc_map_coef.cif.gz"; + + /** + * Path template for the 2mFo-DFc coefficients in the divided archive, for + * mirrors that publish directories rather than an endpoint. + */ + public static final String DIVIDED_TWO_FO_FC_TEMPLATE = + "{mid}/{pdbid_lc}/{pdbid_lc}_validation_2fo-fc_map_coef.cif.gz"; + + /** Path template for the mFo-DFc coefficients in the divided archive. */ + public static final String DIVIDED_FO_FC_TEMPLATE = + "{mid}/{pdbid_lc}/{pdbid_lc}_validation_fo-fc_map_coef.cif.gz"; + + /** + * Path template for the 2mFo-DFc coefficients in the per-entry archive that + * replaces the divided one in July 2027, relative to a base URL ending in + * .../pdb/data/. + *

+ * Provided so that a mirror of the new layout can be used the day it exists, + * without waiting for a release. + */ + public static final String ENTRIES_TWO_FO_FC_TEMPLATE = + "entries/{mid}/{extid}/validation_reports/{extid}_validation_2fo-fc_map_coef.cif.gz"; + + /** Path template for the mFo-DFc coefficients in the per-entry archive. */ + public static final String ENTRIES_FO_FC_TEMPLATE = + "entries/{mid}/{extid}/validation_reports/{extid}_validation_fo-fc_map_coef.cif.gz"; + + private static String serverBaseUrl = DEFAULT_SERVER_URL; + private static String twoFoFcTemplate = DEFAULT_TWO_FO_FC_TEMPLATE; + private static String foFcTemplate = DEFAULT_FO_FC_TEMPLATE; + + /** + * @param cacheRoot the BioJava cache directory + */ + public WwpdbMapCoefficientsProvider(File cacheRoot) { + super(cacheRoot); + } + + /** @return the base URL of the validation report archive */ + public static String getServerBaseUrl() { + return serverBaseUrl; + } + + /** @param url the base URL; a trailing slash is added if missing */ + public static void setServerBaseUrl(String url) { + serverBaseUrl = url == null ? DEFAULT_SERVER_URL : (url.endsWith("/") ? url : url + "/"); + } + + /** + * Overrides the path template for a map kind. + * + * @param kind {@link DensityMapKind#TWO_FO_FC} or {@link DensityMapKind#FO_FC} + * @param template a template understood by {@link UrlTemplates} + */ + public static void setPathUrlTemplate(DensityMapKind kind, String template) { + if (kind == DensityMapKind.TWO_FO_FC) { + twoFoFcTemplate = template == null ? DEFAULT_TWO_FO_FC_TEMPLATE : template; + } else if (kind == DensityMapKind.FO_FC) { + foFcTemplate = template == null ? DEFAULT_FO_FC_TEMPLATE : template; + } else { + throw new IllegalArgumentException("Map coefficients exist only for 2Fo-Fc and Fo-Fc, not " + kind); + } + } + + /** Restores the default server and templates. */ + public static void resetToDefaults() { + serverBaseUrl = DEFAULT_SERVER_URL; + twoFoFcTemplate = DEFAULT_TWO_FO_FC_TEMPLATE; + foFcTemplate = DEFAULT_FO_FC_TEMPLATE; + } + + @Override + public DensityMapSource getSource() { + return DensityMapSource.WWPDB_MAP_COEFFICIENTS; + } + + @Override + public DensityFileFormat getFormat() { + return DensityFileFormat.MAP_COEFFICIENTS_CIF_GZ; + } + + @Override + public boolean supports(DensityMapKind kind) { + return kind == DensityMapKind.TWO_FO_FC || kind == DensityMapKind.FO_FC; + } + + /** + * Builds the URL for a set of coefficients without fetching them. + * + * @param pdbId the entry + * @param kind the kind of map + * @return the URL as a string + */ + public String buildUrl(PdbId pdbId, DensityMapKind kind) { + String template = kind == DensityMapKind.FO_FC ? foFcTemplate : twoFoFcTemplate; + return serverBaseUrl + UrlTemplates.expand(template, UrlTemplates.values(urlId(pdbId), null, -1)); + } + + @Override + public DensityMapResult fetch(DensityMapRequest request) throws IOException { + if (request.getPdbId() == null || !supports(request.getKind())) { + return null; + } + URL url = new URL(buildUrl(request.getPdbId(), request.getKind())); + File target = DensityCacheLayout.pdbMapFile(effectiveCacheRoot(request), request.getPdbId(), + request.getKind(), getSource(), getFormat(), null); + return obtain(request, url, target, request.getKind(), null, null, null); + } +} diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/mmtf/MmtfStructureReader.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/mmtf/MmtfStructureReader.java index c2830c1685..865c9e0da4 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/io/mmtf/MmtfStructureReader.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/io/mmtf/MmtfStructureReader.java @@ -372,7 +372,7 @@ public void setInterGroupBond(int indOne, int indTwo, int bondOrder) { private Group getCorrectAltLocGroup(Character altLoc) { // see if we know this altLoc already; List atoms = group.getAtoms(); - if (atoms.size() > 0) { + if (!atoms.isEmpty()) { Atom a1 = atoms.get(0); // we are just adding atoms to the current group // probably there is a second group following later... @@ -396,7 +396,7 @@ private Group getCorrectAltLocGroup(Character altLoc) { } // no matching altLoc group found. // build it up. - if (group.getAtoms().size() == 0) { + if (group.getAtoms().isEmpty()) { return group; } Group altLocG = (Group) group.clone(); diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BioAssemblyTools.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BioAssemblyTools.java index 7c359121de..8f76b2ae61 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BioAssemblyTools.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BioAssemblyTools.java @@ -55,7 +55,7 @@ public static boolean isUnaryExpression(String expression) { if (first < 0 || last < 0) { return true; } - return ! (first == 0 && last > first); + return first != 0 || last <= first; } public static List parseUnaryOperatorExpression(String operatorExpression) { @@ -279,7 +279,7 @@ public static double[] getBiologicalMoleculeCentroid( final Structure asymUnit, return centroid; } - if ( transformations.size() == 0) { + if ( transformations.isEmpty()) { return Calc.getCentroid(atoms).getCoords(); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BiologicalAssemblyBuilder.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BiologicalAssemblyBuilder.java index c6ec6bc8ff..9edb8f404c 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BiologicalAssemblyBuilder.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BiologicalAssemblyBuilder.java @@ -205,7 +205,7 @@ private void addChainMultiModel(Structure s, Chain newChain, String transformId) // multi-model bioassembly - if ( modelIndex.size() == 0) + if (modelIndex.isEmpty()) modelIndex.add("PLACEHOLDER FOR ASYM UNIT"); int modelCount = modelIndex.indexOf(transformId); diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BiologicalAssemblyTransformation.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BiologicalAssemblyTransformation.java index 36bccd7b39..ff1efd6e32 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BiologicalAssemblyTransformation.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/quaternary/BiologicalAssemblyTransformation.java @@ -226,7 +226,7 @@ public static BiologicalAssemblyTransformation fromXML(String xml) List transformations = fromMultiXML(xml); - if ( transformations.size() > 0) + if (!transformations.isEmpty()) return transformations.get(0); else diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/scop/ScopInstallation.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/scop/ScopInstallation.java index d092d5485e..4ac55cfa08 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/scop/ScopInstallation.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/scop/ScopInstallation.java @@ -655,7 +655,7 @@ private List extractRanges(String range) { } protected void downloadClaFile() throws IOException{ - if(mirrors.size()<1) { + if(mirrors.isEmpty()) { initScopURLs(); } IOException exception = null; @@ -676,7 +676,7 @@ protected void downloadClaFile() throws IOException{ } protected void downloadDesFile() throws IOException{ - if(mirrors.size()<1) { + if(mirrors.isEmpty()) { initScopURLs(); } IOException exception = null; @@ -697,7 +697,7 @@ protected void downloadDesFile() throws IOException{ } protected void downloadHieFile() throws IOException{ - if(mirrors.size()<1) { + if(mirrors.isEmpty()) { initScopURLs(); } IOException exception = null; @@ -719,7 +719,7 @@ protected void downloadHieFile() throws IOException{ } protected void downloadComFile() throws IOException{ - if(mirrors.size()<1) { + if(mirrors.isEmpty()) { initScopURLs(); } IOException exception = null; diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/secstruc/SecStrucTools.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/secstruc/SecStrucTools.java index 7732c04b80..42191d682d 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/secstruc/SecStrucTools.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/secstruc/SecStrucTools.java @@ -57,7 +57,7 @@ public static List getSecStrucInfo(Structure s) { Group g = iter.next(); if (g.hasAminoAtoms()) { Object p = g.getProperty(Group.SEC_STRUC); - if (!(p == null)) { + if (p != null) { SecStrucInfo ss = (SecStrucInfo) p; listSSI.add(ss); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/HelicalRepeatUnit.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/HelicalRepeatUnit.java index ff9c77cf13..cd16aa5f87 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/HelicalRepeatUnit.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/HelicalRepeatUnit.java @@ -64,7 +64,7 @@ public Map getInteractingRepeatUnits() { private void run() { this.repeatUnitCenters = calcRepeatUnitCenters(); - if (this.repeatUnitCenters.size() == 0) { + if (this.repeatUnitCenters.isEmpty()) { return; } this.repeatUnits = calcRepeatUnits(); diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/PermutationGroup.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/PermutationGroup.java index e1f4792410..b3ac53f385 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/PermutationGroup.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/PermutationGroup.java @@ -65,7 +65,7 @@ public void completeGroup() { Set> known = new HashSet<>(permutations); //breadth-first search through the map of all members List> currentLevel = new ArrayList<>(permutations); - while( currentLevel.size() > 0) { + while(!currentLevel.isEmpty()) { List> nextLevel = new ArrayList<>(); for( List p : currentLevel) { for(List gen : gens) { diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/QuatSymmetrySubunits.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/QuatSymmetrySubunits.java index b0bef7f1e6..7f434cabaf 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/QuatSymmetrySubunits.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/QuatSymmetrySubunits.java @@ -211,7 +211,7 @@ public MomentsOfInertia getMomentsOfInertia() { } private void run() { - if (centers.size() > 0) { + if (!centers.isEmpty()) { return; } calcOriginalCenters(); diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/RotationGroup.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/RotationGroup.java index 70b69afe14..002d046e52 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/RotationGroup.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/RotationGroup.java @@ -83,7 +83,7 @@ public void removeRotation(int index) { public void complete() { if (modified) { - if (rotations.size() > 0) { + if (!rotations.isEmpty()) { findHighestOrderAxis(); setEAxis(); calcAxesDirections(); @@ -98,7 +98,7 @@ public void complete() { public String getPointGroup() { if (modified) { - if (rotations.size() == 0) { + if (rotations.isEmpty()) { return "C1"; } complete(); @@ -344,7 +344,7 @@ private void calcPointGroup() { // when a structure is symmetric, some subunits are below the rmsd threshold, // and some are just above the rmsd threshold int n = 0; - if (rotations.size() > 0) { + if (!rotations.isEmpty()) { n = rotations.get(0).getPermutation().size(); rotations.clear(); } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/RotationSolver.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/RotationSolver.java index 37a44be7ac..b1566a6d2f 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/RotationSolver.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/RotationSolver.java @@ -305,7 +305,7 @@ private boolean isSpherical() { * @return null if invalid, or a rotation if valid */ private Rotation isValidPermutation(List permutation) { - if (permutation.size() == 0) { + if (permutation.isEmpty()) { return null; } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/SystematicSolver.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/SystematicSolver.java index d13fa4db16..a449771b58 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/SystematicSolver.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/core/SystematicSolver.java @@ -145,7 +145,7 @@ private void completeRotationGroup() { } private boolean isValidPermutation(List permutation) { - if (permutation.size() == 0) { + if (permutation.isEmpty()) { return false; } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/geometry/DistanceBox.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/geometry/DistanceBox.java index 2d9b1d6dca..25d37693fb 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/geometry/DistanceBox.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/geometry/DistanceBox.java @@ -166,7 +166,7 @@ private List getBoxTwo(long location) { } // ensure that boxTwo has no empty element by copying from tempBox of defined size List boxTwo = null; - if (tempBox.size() == 0) { + if (tempBox.isEmpty()) { boxTwo = Collections.emptyList(); } else if (tempBox.size() == 1) { boxTwo = Collections.singletonList(tempBox.get(0)); diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/internal/SequenceFunctionRefiner.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/internal/SequenceFunctionRefiner.java index a6ad226698..0d52e92fef 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/internal/SequenceFunctionRefiner.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/internal/SequenceFunctionRefiner.java @@ -79,7 +79,7 @@ public static AFPChain refineSymmetry(AFPChain afpChain, Atom[] ca1, Atom[] ca2, // Refine the alignment Map Map refined = refineSymmetry(alignment, k); - if (refined.size() < 1) + if (refined.isEmpty()) throw new RefinerFailedException("Refiner returned empty alignment"); //Substitute and partition the alignment diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/internal/SymmOptimizer.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/internal/SymmOptimizer.java index d03e90080f..627908ac8b 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/internal/SymmOptimizer.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/internal/SymmOptimizer.java @@ -392,10 +392,7 @@ private boolean checkGaps() { length--; } - if (shrinkColumns.size() != 0) - return true; - else - return false; + return !shrinkColumns.isEmpty(); } /** diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/utils/BlastClustReader.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/utils/BlastClustReader.java index b2b3298157..5d1faa8754 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/utils/BlastClustReader.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/symmetry/utils/BlastClustReader.java @@ -140,7 +140,7 @@ public List> getChainIdsInEntry(String pdbId) { private void loadClusters(int sequenceIdentity) { // load clusters only once - if (clusters.size() > 0) { + if (!clusters.isEmpty()) { return; } diff --git a/biojava-structure/src/main/java/org/biojava/nbio/structure/xtal/SpaceGroup.java b/biojava-structure/src/main/java/org/biojava/nbio/structure/xtal/SpaceGroup.java index cff84c70f8..852d213bb9 100644 --- a/biojava-structure/src/main/java/org/biojava/nbio/structure/xtal/SpaceGroup.java +++ b/biojava-structure/src/main/java/org/biojava/nbio/structure/xtal/SpaceGroup.java @@ -645,10 +645,10 @@ public List getTransfAlgebraic() { public void setTransfAlgebraic(List transfAlgebraic) { //System.out.println("setting transfAlgebraic " + transfAlgebraic); - if ( transformations == null || transformations.size() == 0) + if ( transformations == null || transformations.isEmpty()) transformations = new ArrayList(transfAlgebraic.size()); - if ( this.transfAlgebraic == null || this.transfAlgebraic.size() == 0) + if ( this.transfAlgebraic == null || this.transfAlgebraic.isEmpty()) this.transfAlgebraic = new ArrayList<>(transfAlgebraic.size()); for ( String transf : transfAlgebraic){ diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/align/util/AtomCacheTest.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/align/util/AtomCacheTest.java index 073a679dbb..f2f06ed2f5 100644 --- a/biojava-structure/src/test/java/org/biojava/nbio/structure/align/util/AtomCacheTest.java +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/align/util/AtomCacheTest.java @@ -24,6 +24,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.fail; import java.io.File; @@ -408,7 +409,7 @@ public void testEmptyChemComp() throws IOException, StructureException { // should be unknown ChemComp chem = g.getChemComp(); assertNotNull(chem); - assertTrue(chem.getAtoms().size() > 0); + assertFalse(chem.getAtoms().isEmpty()); assertEquals("NON-POLYMER", chem.getType()); } finally { FileDownloadUtils.deleteDirectory(tmpCache); @@ -471,7 +472,7 @@ public void testEmptyGZChemComp() throws IOException, StructureException { // should be unknown ChemComp chem = g.getChemComp(); assertNotNull(chem); - assertTrue(chem.getAtoms().size() > 0); + assertFalse(chem.getAtoms().isEmpty()); assertEquals("NON-POLYMER", chem.getType()); } finally { FileDownloadUtils.deleteDirectory(tmpCache); diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/asa/TestAsaCalc.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/asa/TestAsaCalc.java index 7be56be156..c6a268f1f5 100644 --- a/biojava-structure/src/test/java/org/biojava/nbio/structure/asa/TestAsaCalc.java +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/asa/TestAsaCalc.java @@ -126,19 +126,19 @@ public void testNeighborIndicesFinding() throws StructureException, IOException AsaCalculator.DEFAULT_PROBE_SIZE, 1000, 1, false); - AsaCalculator.IndexAndDistance[][] allNbsSh = asaCalc.findNeighborIndicesSpatialHashing(); + AsaCalculator.Neighbors[] allNbsSh = asaCalc.findNeighborIndicesSpatialHashing(); - AsaCalculator.IndexAndDistance[][] allNbs = asaCalc.findNeighborIndices(); + AsaCalculator.Neighbors[] allNbs = asaCalc.findNeighborIndices(); for (int indexToTest =0; indexToTest < asaCalc.getAtomCoords().length; indexToTest++) { //int indexToTest = 198; - AsaCalculator.IndexAndDistance[] nbsSh = allNbsSh[indexToTest]; - AsaCalculator.IndexAndDistance[] nbs = allNbs[indexToTest]; + int[] nbsSh = allNbsSh[indexToTest].indices; + int[] nbs = allNbs[indexToTest].indices; List listOfMatchingIndices = new ArrayList<>(); for (int i = 0; i < nbsSh.length; i++) { for (int j = 0; j < nbs.length; j++) { - if (nbs[j].index == nbsSh[i].index) { + if (nbs[j] == nbsSh[i]) { listOfMatchingIndices.add(j); break; } @@ -229,21 +229,21 @@ public void testNoNeighborsIssue() { AsaCalculator.DEFAULT_PROBE_SIZE, 1000, 1); - AsaCalculator.IndexAndDistance[][] allNbsSh = asaCalc.findNeighborIndicesSpatialHashing(); + AsaCalculator.Neighbors[] allNbsSh = asaCalc.findNeighborIndicesSpatialHashing(); - AsaCalculator.IndexAndDistance[][] allNbs = asaCalc.findNeighborIndices(); + AsaCalculator.Neighbors[] allNbs = asaCalc.findNeighborIndices(); assertEquals(3, allNbs.length); assertEquals(3, allNbsSh.length); for (int indexToTest =0; indexToTest < asaCalc.getAtomCoords().length; indexToTest++) { - AsaCalculator.IndexAndDistance[] nbsSh = allNbsSh[indexToTest]; - AsaCalculator.IndexAndDistance[] nbs = allNbs[indexToTest]; + int[] nbsSh = allNbsSh[indexToTest].indices; + int[] nbs = allNbs[indexToTest].indices; List listOfMatchingIndices = new ArrayList<>(); for (int i = 0; i < nbsSh.length; i++) { for (int j = 0; j < nbs.length; j++) { - if (nbs[j].index == nbsSh[i].index) { + if (nbs[j] == nbsSh[i]) { listOfMatchingIndices.add(j); break; } @@ -256,7 +256,7 @@ public void testNoNeighborsIssue() { } // first atom should have no neighbors - assertEquals(0, allNbsSh[0].length); + assertEquals(0, allNbsSh[0].indices.length); } private Atom getAtom(double x, double y, double z) { diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/cath/CathInstallationTest.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/cath/CathInstallationTest.java new file mode 100644 index 0000000000..69944cb51c --- /dev/null +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/cath/CathInstallationTest.java @@ -0,0 +1,87 @@ +/* + * BioJava development code + * + * This code may be freely distributed and modified under the + * terms of the GNU Lesser General Public Licence. This should + * be distributed with the code. If you do not have a copy, + * see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual + * authors. These should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, + * or to join the biojava-l mailing list, visit the home page + * at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.cath; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CathInstallationTest { + + @Test + public void testParseCathDomainListSuccess() throws IOException { + String data = "# CATH domain list\n" + + "\n" + + "1oaiA00 1 10 490 10 1 1 1 1 1 124 1.80\n" + + "1oaiA01 1 10 490 10 1 1 1 1 2 150 1.80\n"; + + CathInstallation installation = new CathInstallation(""); + BufferedReader reader = new BufferedReader(new StringReader(data)); + installation.parseCathDomainList(reader); + installation.setInstalledDomainList(new AtomicBoolean(true)); //1 + installation.setInstalledDomall(new AtomicBoolean(true)); //2 + + CathDomain domain = installation.getDomainByCathId("1oaiA00"); + assertNotNull(domain); + assertEquals("1oaiA00", domain.getDomainName()); + assertEquals(1, domain.getClassId()); + assertEquals(10, domain.getArchitectureId()); + assertEquals(490, domain.getTopologyId()); + assertEquals(10, domain.getHomologyId()); + assertEquals(124, domain.getLength()); + assertEquals(1.80, domain.getResolution(), 0.001); + } + + @Test + public void testParseCathDomainListEmptyThrowsException() { + CathInstallation installation = new CathInstallation(""); + BufferedReader reader = new BufferedReader(new StringReader("")); + assertThrows(IOException.class, () -> installation.parseCathDomainList(reader)); + } + + @Test + public void testParseCathDomainListOnlyCommentsAndWhitespaceThrowsException() { + String data = "# comment 1\n" + + "# comment 2\n" + + " \n" + + "\t\n"; + CathInstallation installation = new CathInstallation(""); + BufferedReader reader = new BufferedReader(new StringReader(data)); + assertThrows(IOException.class, () -> installation.parseCathDomainList(reader)); + } + + @Test + public void testParseCathDomainListNoParsableLinesThrowsException() { + String data = "# comment\n" + + "invalid line with too few tokens\n" + + "another bad line\n"; + CathInstallation installation = new CathInstallation(""); + BufferedReader reader = new BufferedReader(new StringReader(data)); + IOException exception = assertThrows(IOException.class, () -> installation.parseCathDomainList(reader)); + assertNotNull(exception.getMessage()); + } +} diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/chem/TestChemCompRedirectNotCached.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/chem/TestChemCompRedirectNotCached.java new file mode 100644 index 0000000000..b948e9fbfb --- /dev/null +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/chem/TestChemCompRedirectNotCached.java @@ -0,0 +1,154 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.chem; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +import com.sun.net.httpserver.HttpServer; + +import org.biojava.nbio.core.util.FlatFileCache; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * A server that redirects, or errors, must not have its response body cached as + * though it were a chemical component definition. + *

+ * This is the failure that took the CATH downloader out when + * download.cathdb.info moved to https, and the chem comp download had + * the same shape. A 4xx already failed safely, because + * getInputStream() throws for those; a redirect did not, because when + * the JDK declines to follow a 3xx it hands back the redirect's body instead, and + * that body is short but not empty. + *

+ * The test serves the responses from a local {@link HttpServer} rather than a real + * service. Pointing it at a third-party server that happens to redirect today would + * make the test fail on the day they stop, which is precisely the coupling that made + * the build unreliable in the first place. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class TestChemCompRedirectNotCached { + + private HttpServer server; + private String originalServerUrl; + + @Before + public void setUp() { + originalServerUrl = DownloadChemCompProvider.serverBaseUrl; + } + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + } + // Static state: leaving either of these set would corrupt unrelated tests. + DownloadChemCompProvider.serverBaseUrl = originalServerUrl; + FlatFileCache.clear(); + } + + /** + * Starts a local server that answers every request with the given status and body. + * + * @return the base URL to point the provider at + */ + private String startServer(int status, String location, String body) throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + if (location != null) { + exchange.getResponseHeaders().add("Location", location); + } + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + }); + server.start(); + return "http://127.0.0.1:" + server.getAddress().getPort() + "/"; + } + + private File cacheFileFor(String id) { + File file = new File(DownloadChemCompProvider.getLocalFileName(id)); + file.delete(); + FlatFileCache.clear(); + return file; + } + + /** + * The case that broke CATH: a redirect the JDK will not follow because it + * changes protocol. Its body must not end up on disk under the component's name. + */ + @Test + public void redirectBodyIsNotCached() throws IOException { + File cached = cacheFileFor("ATP"); + DownloadChemCompProvider.serverBaseUrl = + startServer(301, "https://example.invalid/ATP.cif", "Moved Permanently"); + + ChemComp cc = new DownloadChemCompProvider().getChemComp("ATP"); + + assertFalse("the body of a redirect must never be cached as a definition", cached.exists()); + assertNull("nothing parseable was returned, so the component must be empty", cc.getName()); + } + + /** + * A 200 is still cached, so the guard has not simply disabled downloading. + *

+ * What is under test is the download path, not the CIF parser: the response is + * written to the cache before anything tries to parse it, so a parse failure on + * this deliberately minimal body says nothing about whether the guard behaved. + */ + @Test + public void aValidResponseIsStillCached() throws IOException { + File cached = cacheFileFor("ATP"); + DownloadChemCompProvider.serverBaseUrl = startServer(200, null, + "data_ATP\n#\n_chem_comp.id ATP\n_chem_comp.name \"ADENOSINE-5'-TRIPHOSPHATE\"\n#\n"); + + try { + new DownloadChemCompProvider().getChemComp("ATP"); + } catch (RuntimeException parseFailure) { + // see the note above + } + + assertTrue("a 200 response should still be cached", cached.exists()); + cached.delete(); + } + + /** A server error must not be cached either. */ + @Test + public void serverErrorBodyIsNotCached() throws IOException { + File cached = cacheFileFor("ATP"); + DownloadChemCompProvider.serverBaseUrl = + startServer(503, null, "Service Unavailable"); + + new DownloadChemCompProvider().getChemComp("ATP"); + + assertFalse("the body of a 5xx must never be cached as a definition", cached.exists()); + } +} diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/ecod/EcodParserTest.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/ecod/EcodParserTest.java new file mode 100644 index 0000000000..295806e915 --- /dev/null +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/ecod/EcodParserTest.java @@ -0,0 +1,323 @@ +/* + * BioJava development code + * + * This code may be freely distributed and modified under the + * terms of the GNU Lesser General Public Licence. This should + * be distributed with the code. If you do not have a copy, + * see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual + * authors. These should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, + * or to join the biojava-l mailing list, visit the home page + * at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.ecod; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +import org.biojava.nbio.structure.ecod.EcodInstallation.EcodParser; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Checks that {@link EcodParser} reads every layout ECOD has distributed. + *

+ * The columns have been renamed, reordered and added to several times, and the version + * comment itself changed form at v294.1. Because the full distribution is 657 MB, none of + * that was covered by a test that could run in reasonable time, and a format change went + * unnoticed for months. These fixtures are taken verbatim from the real files, so the + * contract is pinned in milliseconds rather than by a download. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +class EcodParserTest { + + /** develop204, list format 1.5: 15 columns, commented header, quoted names. */ + private static final String DEVELOP204 = String.join("\n", + "#/data/ecod/database_versions/v204/ecod.develop204.domains.txt", + "#ECOD version develop204", + "#Domain list version 1.5", + "#Grishin lab (http://prodata.swmed.edu/ecod)", + "#uid\tecod_domain_id\tmanual_rep\tf_id\tpdb\tchain\tpdb_range\tseqid_range" + + "\tarch_name\tx_name\th_name\tt_name\tf_name\tasm_status\tligand", + "002137905\te6b4nA1\tAUTO_NONREP\t1.1.1\t6b4n\tA\tA:1-99\tA:1-99\tbeta barrels" + + "\t\"cradle loop barrel\"\t\"RIFT-related\"\t\"acid protease\"" + + "\tF_UNCLASSIFIED\tNOT_DOMAIN_ASSEMBLY\tCL,G53,NA"); + + /** develop291, list format 1.6: 16 columns, f_id renamed t_id, unp_acc inserted at 9. */ + private static final String DEVELOP291 = String.join("\n", + "#/data/ecod/database_versions/v291/ecod.develop291.domains.txt", + "#ECOD version develop291", + "#Domain list version 1.6", + "#Grishin lab (http://prodata.swmed.edu/ecod)", + "#uid\tecod_domain_id\tmanual_rep\tt_id\tpdb\tchain\tpdb_range\tseqid_range\tunp_acc" + + "\tarch_name\tx_name\th_name\tt_name\tf_name\tasm_status\tligand", + "000000267\te1udzA1\tMANUAL_REP\t1.1.1\t1udz\tA\tA:203-381\tA:4-182\tP12345" + + "\tbeta barrels\t\"cradle loop barrel\"\t\"RIFT-related\"\t\"acid protease\"" + + "\tF_UNCLASSIFIED\tNOT_DOMAIN_ASSEMBLY\tNO_LIGANDS_4A"); + + private static final String V295_COLUMNS = + "uid\tecod_domain_id\tmanual_rep\tf_id\tpdb\tchain\tpdb_range\tseqid_range" + + "\tarchitecture_name\tx_name\th_name\tt_name\tf_name\tassembly_id\tdomain_id_short" + + "\trange_count\tarch_manual\tx_manual\th_manual\tt_manual\tf_manual" + + "\tvalid_structure\tligand_binding\tligand_comp_ids\tligand_pdbnum"; + + /** v295: 25 columns, uncommented header, True/False, empty assembly_id, moved ligands. */ + private static final String V295 = String.join("\n", + "# ECOD Domain List", + "# Version: v295", + "# Generated: 2026-06-24 22:47:42", + "# Ligand cutoff: 4.0 A (NO_LIGANDS_4A = no contact within cutoff)", + "#", + V295_COLUMNS, + "0\te2nmzA1\tTrue\t1.1.1.3\t2nmz\tA\tA:1-99\tA:1-99\tbeta barrels\tcradle loop barrel" + + "\tRIFT-related\tacid protease\tRVP\t\t\t1\tFalse\tFalse\tFalse\tFalse\tTrue" + + "\tTrue\tTrue\tROC,SO4\tA:601,A:602,B:401", + // the last column is empty on four rows in five, so split() must keep it + "3\te2rspA1\tTrue\t1.1.1.3\t2rsp\tA\tA:1-124\tA:1-124\tbeta barrels\tcradle loop barrel" + + "\tRIFT-related\tacid protease\tRVP\t\t\t1\tFalse\tFalse\tFalse\tFalse\tTrue" + + "\tTrue\tFalse\tNO_LIGANDS_4A\t", + // a domain classified from an AlphaFold model: no PDB entry, so no EcodDomain + "3163557\tP44140_F1_nD2\tFalse\t2004.1.1.123\t\t\t131-315\t131-315\talpha bundles" + + "\tsomething\tsomething else\ta third thing\t\t\t\t1\tFalse\tFalse\tFalse" + + "\tFalse\tTrue\tTrue\tFalse\tNO_LIGANDS_4A\t"); + + private static List parse(String contents) throws IOException { + return new EcodParser(new StringReader(contents)).getDomains(); + } + + private static String version(String contents) throws IOException { + return new EcodParser(new StringReader(contents)).getVersion(); + } + + @Nested + class Version { + @Test + void oldHeaderForm() throws IOException { + assertEquals("develop204", version(DEVELOP204)); + assertEquals("develop291", version(DEVELOP291)); + } + + @Test + void newHeaderForm() throws IOException { + assertEquals("v295", version(V295)); + assertEquals("v294.1", version("# ECOD Domain List\n# Version: v294.1\n")); + } + + @Test + void listFormatVersionIsNotTheEcodVersion() throws IOException { + // "#Domain list version 1.5" describes the columns, not the release + assertNull(version("#Grishin lab\n#Domain list version 1.5\n")); + } + + @Test + void absentVersionIsNull() throws IOException { + assertNull(version("#Grishin lab (http://prodata.swmed.edu/ecod)\n")); + } + } + + @Nested + class OldFormats { + @Test + void listFormat15() throws IOException { + List domains = parse(DEVELOP204); + assertEquals(1, domains.size()); + EcodDomain d = domains.get(0); + assertEquals(Long.valueOf(2137905), d.getUid()); + assertEquals("e6b4nA1", d.getDomainId()); + assertEquals(Boolean.FALSE, d.getManual()); + assertEquals(Integer.valueOf(1), d.getXGroup()); + assertEquals(Integer.valueOf(1), d.getHGroup()); + assertEquals(Integer.valueOf(1), d.getTGroup()); + assertNull(d.getFGroup()); + assertEquals("6B4N", d.getPdbId().getId()); + assertEquals("A", d.getChainId()); + assertEquals("A:1-99", d.getRange()); + assertEquals("A:1-99", d.getSeqIdRange()); + assertEquals("beta barrels", d.getArchitectureName()); + // quotes were stripped up to develop292 + assertEquals("cradle loop barrel", d.getXGroupName()); + assertEquals("RIFT-related", d.getHGroupName()); + assertEquals("acid protease", d.getTGroupName()); + assertEquals("F_UNCLASSIFIED", d.getFGroupName()); + assertEquals(Long.valueOf(2137905), d.getAssemblyId()); + assertEquals(new LinkedHashSet<>(Arrays.asList("CL", "G53", "NA")), d.getLigands()); + } + + /** + * develop291 inserted unp_acc before arch_name. Read positionally, every field from + * there on shifts by one and the domain is silently mangled or dropped. + */ + @Test + void listFormat16InsertsUnpAcc() throws IOException { + List domains = parse(DEVELOP291); + assertEquals(1, domains.size()); + EcodDomain d = domains.get(0); + assertEquals(Boolean.TRUE, d.getManual()); + assertEquals("1UDZ", d.getPdbId().getId()); + assertEquals("A:4-182", d.getSeqIdRange()); + assertEquals("beta barrels", d.getArchitectureName()); + assertEquals("acid protease", d.getTGroupName()); + assertEquals("F_UNCLASSIFIED", d.getFGroupName()); + assertEquals(Long.valueOf(267), d.getAssemblyId()); + assertEquals(Collections.emptySet(), d.getLigands()); + } + + /** + * Headers were only added in develop101. Older files are still read by position. + */ + @Test + void thirteenColumnsWithoutAHeader() throws IOException { + List domains = parse(String.join("\n", + "#ECOD version develop45", + "000000001\te1udzA1\t1.1.1\t1udz\tA\tA:203-381\tbeta barrels" + + "\t\"cradle loop barrel\"\t\"RIFT-related\"\t\"acid protease\"" + + "\tF_UNCLASSIFIED\tNOT_DOMAIN_ASSEMBLY\tNO_LIGANDS_4A")); + assertEquals(1, domains.size()); + EcodDomain d = domains.get(0); + assertNull(d.getManual(), "no manual_rep column before list format 1.1"); + assertNull(d.getSeqIdRange(), "no seqid_range column before list format 1.4"); + assertEquals("1UDZ", d.getPdbId().getId()); + assertEquals("acid protease", d.getTGroupName()); + } + } + + @Nested + class NewFormat { + @Test + void twentyFiveColumns() throws IOException { + List domains = parse(V295); + // two PDB domains; the AlphaFold-derived row cannot be an EcodDomain + assertEquals(2, domains.size()); + + EcodDomain d = domains.get(0); + assertEquals(Long.valueOf(0), d.getUid()); + assertEquals("e2nmzA1", d.getDomainId()); + assertEquals(Boolean.TRUE, d.getManual(), "manual_rep is now True/False"); + assertEquals(Integer.valueOf(1), d.getXGroup()); + assertEquals(Integer.valueOf(1), d.getHGroup()); + assertEquals(Integer.valueOf(1), d.getTGroup()); + assertEquals(Integer.valueOf(3), d.getFGroup(), "f_id now carries a fourth level"); + assertEquals("2NMZ", d.getPdbId().getId()); + assertEquals("A", d.getChainId()); + assertEquals("A:1-99", d.getRange()); + assertEquals("A:1-99", d.getSeqIdRange()); + assertEquals("beta barrels", d.getArchitectureName()); + assertEquals("cradle loop barrel", d.getXGroupName()); + assertEquals("RIFT-related", d.getHGroupName()); + assertEquals("acid protease", d.getTGroupName()); + assertEquals("RVP", d.getFGroupName()); + // assembly_id is empty on every row of v294.1 and later, which means the same + // as the NOT_DOMAIN_ASSEMBLY of earlier versions + assertEquals(Long.valueOf(0), d.getAssemblyId()); + assertEquals(new LinkedHashSet<>(Arrays.asList("ROC", "SO4")), d.getLigands(), + "the ligand list moved to ligand_comp_ids"); + } + + /** + * ligand_pdbnum, the last column, is empty on four rows in five. String.split + * discards trailing empty fields unless asked not to, which would make those rows + * look one column short. + */ + @Test + void rowEndingInAnEmptyColumn() throws IOException { + EcodDomain d = parse(V295).get(1); + assertEquals("e2rspA1", d.getDomainId()); + assertEquals("2RSP", d.getPdbId().getId()); + assertEquals(Collections.emptySet(), d.getLigands()); + } + + @Test + void twentyThreeColumnsOfV2941() throws IOException { + List domains = parse(String.join("\n", + "# ECOD Domain List", + "# Version: v294.1", + "#", + "uid\tecod_domain_id\tmanual_rep\tf_id\tpdb\tchain\tpdb_range\tseqid_range" + + "\tarchitecture_name\tx_name\th_name\tt_name\tf_name\tassembly_id" + + "\tdomain_id_short\trange_count\tarch_manual\tx_manual\th_manual" + + "\tt_manual\tf_manual\tvalid_structure\tligand_binding", + "1\te1hvcA1\tFalse\t1.1.1.3\t1hvc\tA\tA:1B-99A\tA:1-203\tbeta barrels" + + "\tcradle loop barrel\tRIFT-related\tacid protease\tRVP\t\t\t\tFalse" + + "\tFalse\tFalse\tFalse\tFalse\tTrue\tFalse")); + assertEquals(1, domains.size()); + EcodDomain d = domains.get(0); + assertEquals("1HVC", d.getPdbId().getId()); + assertEquals("A:1B-99A", d.getRange()); + assertEquals("RVP", d.getFGroupName()); + // there is no ligand column at all in v294.1 + assertEquals(Collections.emptySet(), d.getLigands()); + } + + @Test + void columnHeaderIsNotADomain() throws IOException { + // v294.1 stopped commenting the column names out, so they arrive looking like data + for (EcodDomain d : parse(V295)) { + assertFalse("uid".equals(d.getDomainId())); + } + } + + /** + * An empty f_name is not the same as F_UNCLASSIFIED: f_id still classifies the + * domain to four levels, so the empty value is left as it is rather than translated. + */ + @Test + void emptyFGroupNameIsLeftAlone() throws IOException { + List domains = parse(String.join("\n", + "# Version: v295", + V295_COLUMNS, + "7\te4fivA1\tTrue\t1.1.1.3\t4fiv\tA\tA:4-116\tA:1-113\tbeta barrels" + + "\tcradle loop barrel\tRIFT-related\tacid protease\t\t\t\t1\tFalse" + + "\tFalse\tFalse\tFalse\tTrue\tTrue\tTrue\tLP1\tA:201")); + assertEquals(1, domains.size()); + assertEquals("", domains.get(0).getFGroupName()); + } + } + + @Nested + class Robustness { + @Test + void unparseableLinesAreSkippedNotFatal() throws IOException { + List domains = parse(String.join("\n", + "# Version: v295", + V295_COLUMNS, + "not-a-number\tefoo\tTrue\t1.1.1.3\tfoo1\tA\tA:1-9\tA:1-9\ta\tb\tc\td\te" + + "\t\t\t1\tFalse\tFalse\tFalse\tFalse\tTrue\tTrue\tFalse\t\t", + "0\te2nmzA1\tTrue\t1.1.1.3\t2nmz\tA\tA:1-99\tA:1-99\tbeta barrels" + + "\tcradle loop barrel\tRIFT-related\tacid protease\tRVP\t\t\t1" + + "\tFalse\tFalse\tFalse\tFalse\tTrue\tTrue\tTrue\tROC,SO4\tA:601")); + assertEquals(1, domains.size(), "the good line is still read"); + assertEquals("e2nmzA1", domains.get(0).getDomainId()); + } + + @Test + void shortLineIsSkipped() throws IOException { + assertTrue(parse(String.join("\n", + "# Version: v295", + V295_COLUMNS, + "0\te2nmzA1\tTrue")).isEmpty()); + } + + @Test + void emptyFileYieldsNoDomains() throws IOException { + assertTrue(parse("").isEmpty()); + } + } +} diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestHeaderOnly.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestHeaderOnly.java index d3c9568240..f579752d4c 100644 --- a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestHeaderOnly.java +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestHeaderOnly.java @@ -205,8 +205,7 @@ public boolean doSeqResHaveAtoms(Structure s) { * @return true if has any Atom(s) */ public boolean hasAtoms(Group g) { - if (g.getAtoms().size() > 0) return true; - return false; + return !g.getAtoms().isEmpty(); } /** diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestMMcifOrganismParsing.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestMMcifOrganismParsing.java index 8d018f6c0a..2cbdbca679 100644 --- a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestMMcifOrganismParsing.java +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestMMcifOrganismParsing.java @@ -37,7 +37,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; public class TestMMcifOrganismParsing { @@ -90,7 +90,7 @@ private void checkPDB(String pdbId, String organismTaxId) throws IOException, St Structure s = StructureIO.getStructure(pdbId); assertNotNull(s.getEntityInfos()); - assertTrue(s.getEntityInfos().size() > 0); + assertFalse(s.getEntityInfos().isEmpty()); for ( EntityInfo c : s.getEntityInfos()) { if(EntityType.POLYMER.equals(c.getType())) { diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestSiftsParsing.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestSiftsParsing.java index 6a9f6ae93c..2b99d660d8 100644 --- a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestSiftsParsing.java +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/TestSiftsParsing.java @@ -47,9 +47,9 @@ public void test4DIA() throws Exception { for (SiftsEntity e : entities) { //System.out.println(e.getEntityId() + " " +e.getType()); - Assert.assertTrue(e.getSegments().size() > 0); + Assert.assertFalse(e.getSegments().isEmpty()); for (SiftsSegment seg : e.getSegments()) { - Assert.assertTrue(seg.getResidues().size() > 0); + Assert.assertFalse(seg.getResidues().isEmpty()); for (SiftsResidue res : seg.getResidues()) { @@ -78,9 +78,9 @@ public void test4jn3() throws Exception { for (SiftsEntity e : entities) { //System.out.println(e.getEntityId() + " " +e.getType()); - Assert.assertTrue(e.getSegments().size() > 0); + Assert.assertFalse(e.getSegments().isEmpty()); for (SiftsSegment seg : e.getSegments()) { - Assert.assertTrue(seg.getResidues().size() > 0); + Assert.assertFalse(seg.getResidues().isEmpty()); //System.out.println(seg.getResidues().size()); //System.out.println(" Segment: " + seg.getSegId() + " " + seg.getStart() + " " + seg.getEnd()) ; @@ -125,7 +125,7 @@ public void test4DOU() throws Exception { //assertTrue(seg1.getResidues().size() == 17); for (SiftsSegment seg : e.getSegments()) { - Assert.assertTrue(seg.getResidues().size() > 0); + Assert.assertFalse(seg.getResidues().isEmpty()); //System.out.println(" Segment: " + seg.getSegId() + " " + seg.getStart() + " " + seg.getEnd() + " res. size: " + seg.getResidues().size()) ; @@ -175,7 +175,7 @@ public void test4O6W() throws Exception { //System.out.println(" Segment: " + seg1.getSegId() + " " + seg1.getStart() + " " + seg1.getEnd() + " res. size: " + seg1.getResidues().size()); //assertTrue(seg1.getResidues().size() == 17); - Assert.assertTrue(seg.getResidues().size() > 0); + Assert.assertFalse(seg.getResidues().isEmpty()); for (SiftsResidue res : seg.getResidues()) { diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/cif/CifFileConsumerImplTest.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/cif/CifFileConsumerImplTest.java index a8925afa88..3fb733f079 100644 --- a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/cif/CifFileConsumerImplTest.java +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/cif/CifFileConsumerImplTest.java @@ -1,6 +1,10 @@ package org.biojava.nbio.structure.io.cif; +import org.biojava.nbio.structure.Atom; import org.biojava.nbio.structure.Chain; +import org.biojava.nbio.structure.Element; +import org.biojava.nbio.structure.Group; +import org.biojava.nbio.structure.StructureTools; import org.biojava.nbio.structure.EntityInfo; import org.biojava.nbio.structure.EntityType; import org.biojava.nbio.structure.Structure; @@ -17,6 +21,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; @@ -147,7 +152,7 @@ public void testWaterOnlyChainCif() throws IOException { Chain c = s2.getWaterChainByPDB("F"); assertNotNull("Got null when looking for water-only chain with author id F", c); - assertTrue(c.getAtomGroups().size() > 0); + assertFalse(c.getAtomGroups().isEmpty()); // checking that compounds are linked assertNotNull(c.getEntityInfo()); @@ -157,7 +162,7 @@ public void testWaterOnlyChainCif() throws IOException { Chain cAsymId = s2.getWaterChain("E"); assertNotNull("Got null when looking for water-only chain with asym id E", cAsymId); - assertTrue(cAsymId.getAtomGroups().size() > 0); + assertFalse(cAsymId.getAtomGroups().isEmpty()); assertSame(c, cAsymId); } @@ -267,4 +272,68 @@ public void testAtomSiteWithMissingAuthFields() throws IOException { assertEquals(2, s.getPolyChain("A").getAtomGroups().size()); assertEquals(2, s.getPolyChainByPDB("A").getAtomGroups().size()); } -} \ No newline at end of file + + /** + * With parseCAOnly, only C-alpha atoms must be kept: no N/O/S or other non-carbon atoms, + * and no calcium ions (atom name CA, element Ca). + */ + @Test + public void testParseCAOnly() throws IOException { + String resource = "/org/biojava/nbio/structure/io/mmcif/1stp_v5.cif"; + String mmcifStr; + try (InputStream inputStream = getClass().getResourceAsStream(resource)) { + Objects.requireNonNull(inputStream, "could not acquire test resource " + resource); + mmcifStr = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + // turn the last water oxygen into an atom named CA with element Ca, like a calcium ion + String waterRow = "HETATM 1001 O O . HOH C 3 . ? 19.892 14.908 -13.679 0.90 40.00 ? 449 HOH A O 1"; + assertTrue(mmcifStr.contains(waterRow)); + mmcifStr = mmcifStr.replace(waterRow, "HETATM 1001 CA CA . HOH C 3 . ? 19.892 14.908 -13.679 0.90 40.00 ? 449 HOH A CA 1"); + + FileParsingParameters fullParams = new FileParsingParameters(); + fullParams.setCreateAtomBonds(true); + Structure full = CifStructureConverter.fromInputStream( + new ByteArrayInputStream(mmcifStr.getBytes(StandardCharsets.UTF_8)), fullParams); + + FileParsingParameters caParams = new FileParsingParameters(); + caParams.setCreateAtomBonds(true); + caParams.setParseCAOnly(true); + Structure caOnly = CifStructureConverter.fromInputStream( + new ByteArrayInputStream(mmcifStr.getBytes(StandardCharsets.UTF_8)), caParams); + + int expected = 0; + boolean hasCalcium = false; + for (int model = 0; model < full.nrModels(); model++) { + for (Chain chain : full.getChains(model)) { + for (Group group : chain.getAtomGroups()) { + Atom ca = group.getAtom(StructureTools.CA_ATOM_NAME); + if (group.isAminoAcid() && ca != null) { + expected++; + } else if (ca != null && ca.getElement() == Element.Ca) { + hasCalcium = true; + } + } + } + } + assertTrue(expected > 0); + assertTrue("test input should contain a calcium named CA", hasCalcium); + + int count = 0; + for (int model = 0; model < caOnly.nrModels(); model++) { + // the water and ligand chains hold no C-alpha, so they should not have been created + assertEquals(full.getPolyChains(model).size(), caOnly.getChains(model).size()); + for (Chain chain : caOnly.getChains(model)) { + for (Group group : chain.getAtomGroups()) { + // no group without a C-alpha should have been created either + assertEquals(1, group.getAtoms().size()); + for (Atom atom : group.getAtoms()) { + assertEquals(StructureTools.CA_ATOM_NAME, atom.getName()); + assertEquals(Element.C, atom.getElement()); + count++; + } + } + } + } + assertEquals(expected, count); + } +} diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/cif/CifFileSupplierImplTest.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/cif/CifFileSupplierImplTest.java index df227a8669..1d5f496ff5 100644 --- a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/cif/CifFileSupplierImplTest.java +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/cif/CifFileSupplierImplTest.java @@ -1,5 +1,6 @@ package org.biojava.nbio.structure.io.cif; +import org.biojava.nbio.structure.PdbId; import org.biojava.nbio.structure.Structure; import org.biojava.nbio.structure.io.FileParsingParameters; import org.biojava.nbio.structure.io.PDBFileParser; @@ -41,4 +42,43 @@ public void shouldReadRawPdbOutputtingCifWithEntity() throws IOException { } } + + /** + * The identifier must be written as a data item and not only as the name of the data block: consumers read it + * from _entry.id or from _struct.entry_id, so writing the block header alone loses it. See issue #1143. + */ + @Test + public void shouldWriteEntryIdAndSurviveRoundTrip() throws IOException { + Structure s; + try (InputStream inStream = new GZIPInputStream(this.getClass().getResourceAsStream("/4hhb.cif.gz"))) { + s = CifStructureConverter.fromInputStream(inStream); + } + assertEquals(new PdbId("4HHB"), s.getPdbId()); + + String cifText = CifStructureConverter.toText(s); + assertTrue("_entry.id must be written", cifText.contains("_entry.id")); + assertTrue("_struct.entry_id must be written", cifText.contains("_struct.entry_id")); + + Structure readStruct = CifStructureConverter.fromInputStream( + new ByteArrayInputStream(cifText.getBytes())); + + assertEquals(s.getPdbId(), readStruct.getPdbId()); + assertEquals(s.getPdbId(), readStruct.getPDBHeader().getPdbId()); + } + + /** + * Structures without an identifier must not gain empty entry categories. + */ + @Test + public void shouldNotWriteEntryIdWhenPdbIdIsAbsent() throws IOException { + Structure s; + try (InputStream inStream = new GZIPInputStream(this.getClass().getResourceAsStream("/4hhb.cif.gz"))) { + s = CifStructureConverter.fromInputStream(inStream); + } + s.setPdbId(null); + + String cifText = CifStructureConverter.toText(s); + assertFalse(cifText.contains("_entry.id")); + assertFalse(cifText.contains("_struct.entry_id")); + } } diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestCcp4Header.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestCcp4Header.java new file mode 100644 index 0000000000..3c5d25950c --- /dev/null +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestCcp4Header.java @@ -0,0 +1,112 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.zip.GZIPOutputStream; + +import org.biojava.nbio.core.util.FileDownloadUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * The CCP4 header check that keeps a server's error page out of the cache. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class TestCcp4Header { + + private File dir; + + @BeforeEach + public void setUp() throws IOException { + dir = Files.createTempDirectory("bj-ccp4").toFile(); + } + + @AfterEach + public void tearDown() throws IOException { + FileDownloadUtils.deleteDirectory(dir.toPath()); + } + + /** A minimal file carrying the stamp at the offset a real CCP4 header uses. */ + private static byte[] fakeMap() { + byte[] bytes = new byte[2048]; + byte[] stamp = Ccp4Header.MAP_STAMP.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(stamp, 0, bytes, Ccp4Header.MAP_STAMP_OFFSET, stamp.length); + return bytes; + } + + private File write(String name, byte[] content) throws IOException { + File f = new File(dir, name); + Files.write(f.toPath(), content); + return f; + } + + @Test + public void recognisesAMapByItsStamp() throws IOException { + assertTrue(Ccp4Header.isCcp4(write("good.ccp4", fakeMap()))); + } + + @Test + public void recognisesAGzippedMap() throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(buffer)) { + gz.write(fakeMap()); + } + assertTrue(Ccp4Header.isCcp4(write("good.map.gz", buffer.toByteArray())), "EMDB serves its maps gzipped"); + } + + /** + * The case this check exists for: a server answering with an error page and an + * HTTP 200, which nothing else would catch. + */ + @Test + public void rejectsAnHtmlErrorPage() throws IOException { + StringBuilder html = new StringBuilder("404 Not Found"); + while (html.length() < 1500) { + html.append("

The requested resource was not found on this server.

"); + } + html.append(""); + assertFalse(Ccp4Header.isCcp4(write("error.ccp4", html.toString().getBytes(StandardCharsets.UTF_8)))); + } + + @Test + public void rejectsRandomBytesAndShortFiles() throws IOException { + byte[] noise = new byte[2048]; + for (int i = 0; i < noise.length; i++) { + noise[i] = (byte) (i * 31); + } + assertFalse(Ccp4Header.isCcp4(write("noise.ccp4", noise))); + assertFalse(Ccp4Header.isCcp4(write("tiny.ccp4", new byte[10])), + "a file shorter than the header cannot be a map"); + } + + @Test + public void quietVariantSwallowsUnreadableFiles() { + assertFalse(Ccp4Header.isCcp4Quietly(new File(dir, "does-not-exist.ccp4"))); + } +} diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityCacheLayout.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityCacheLayout.java new file mode 100644 index 0000000000..7e5723e67e --- /dev/null +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityCacheLayout.java @@ -0,0 +1,145 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.HashSet; +import java.util.Set; + +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.io.LocalPDBDirectory; +import org.junit.jupiter.api.Test; + +/** + * Cache layout: directory derivation, and that no two source and kind + * combinations can land on the same file. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class TestDensityCacheLayout { + + private static final File ROOT = new File("/tmp/bjcache"); + + /** + * The divided-archive directory is taken from the end of the identifier, not + * the start. That is what makes both spellings of an entry agree: counting from + * the start would file the extended form under "db" instead of "cb". + */ + @Test + public void middleHashIsTakenFromTheEndOfTheIdentifier() { + assertEquals("cb", LocalPDBDirectory.getMiddleHash("1cbs")); + assertEquals("cb", LocalPDBDirectory.getMiddleHash("1CBS")); + assertEquals("cb", LocalPDBDirectory.getMiddleHash("pdb_00001cbs")); + assertEquals("cb", LocalPDBDirectory.getMiddleHash("PDB_00001CBS")); + + // Guard against a regression to the substring(1, 3) form. + assertFalse("db".equals(LocalPDBDirectory.getMiddleHash("pdb_00001cbs"))); + } + + @Test + public void bothSpellingsOfAnEntryShareADirectory() { + File shortForm = DensityCacheLayout.pdbMapFile(ROOT, new PdbId("1cbs"), + DensityMapKind.TWO_FO_FC, DensityMapSource.PDBE_CCP4, DensityFileFormat.CCP4, null); + File extendedForm = DensityCacheLayout.pdbMapFile(ROOT, new PdbId("PDB_00001CBS"), + DensityMapKind.TWO_FO_FC, DensityMapSource.PDBE_CCP4, DensityFileFormat.CCP4, null); + assertEquals(shortForm, extendedForm); + } + + @Test + public void pdbMapFileNamesAreLowerCaseAndDivided() { + File f = DensityCacheLayout.pdbMapFile(ROOT, new PdbId("1CBS"), + DensityMapKind.TWO_FO_FC, DensityMapSource.PDBE_CCP4, DensityFileFormat.CCP4, null); + assertEquals("1cbs_2fofc_pdbe.ccp4", f.getName()); + assertEquals("cb", f.getParentFile().getName()); + assertEquals(DensityCacheLayout.DENSITY_DIR, f.getParentFile().getParentFile().getName()); + } + + @Test + public void qualifierDistinguishesDetailLevels() { + File d0 = DensityCacheLayout.pdbMapFile(ROOT, new PdbId("1cbs"), DensityMapKind.TWO_FO_FC, + DensityMapSource.RCSB_VOLUME_SERVER, DensityFileFormat.BCIF_VOLUME, "d0"); + File d3 = DensityCacheLayout.pdbMapFile(ROOT, new PdbId("1cbs"), DensityMapKind.TWO_FO_FC, + DensityMapSource.RCSB_VOLUME_SERVER, DensityFileFormat.BCIF_VOLUME, "d3"); + assertFalse(d0.equals(d3)); + assertTrue(d0.getName().endsWith("_d0.bcif")); + } + + /** + * Every combination has to map to a distinct file, or one source would serve a + * cache hit belonging to another. + */ + @Test + public void everySourceAndKindCombinationIsDistinct() { + Set seen = new HashSet<>(); + PdbId id = new PdbId("1cbs"); + for (DensityMapSource source : DensityMapSource.values()) { + for (DensityMapKind kind : DensityMapKind.values()) { + if (kind == DensityMapKind.AUTO || kind == DensityMapKind.EM) { + continue; // AUTO is never cached; EM is keyed by EMDB id instead + } + for (DensityFileFormat format : DensityFileFormat.values()) { + File f = DensityCacheLayout.pdbMapFile(ROOT, id, kind, source, format, null); + assertTrue(seen.add(f.getPath()), "duplicate cache path: " + f); + } + } + } + } + + @Test + public void emdbMapsAreKeyedByEmdbIdentifier() { + File f = DensityCacheLayout.emdbMapFile(ROOT, "emd-262", DensityMapSource.EMDB_MAP, + DensityFileFormat.CCP4_GZ, null); + assertEquals("emd_262.map.gz", f.getName()); + assertEquals("EMD-262", f.getParentFile().getName()); + assertEquals(DensityCacheLayout.EMDB_DIR, f.getParentFile().getParentFile().getName()); + } + + @Test + public void emdbIdentifiersAreNormalised() { + assertEquals("EMD-0262", DensityMapRequest.normalizeEmdbId("EMD-0262")); + assertEquals("EMD-0262", DensityMapRequest.normalizeEmdbId("emd-0262")); + assertEquals("EMD-0262", DensityMapRequest.normalizeEmdbId("EMD_0262")); + assertEquals("EMD-0262", DensityMapRequest.normalizeEmdbId("0262")); + assertEquals("0262", DensityMapRequest.emdbNumber("EMD-0262")); + } + + /** + * The marker has to sit inside the file name, before the extension. Jmol reads + * the difference block only when it finds that text in the name itself. + */ + @Test + public void differenceMarkerGoesInsideTheName() { + File plain = new File("/tmp/bjcache/density/cb/1cbs_both_rcsbvs_d3.bcif"); + File marked = DensityCacheLayout.differenceMarkerFile(plain); + assertEquals("1cbs_both_rcsbvs_d3&diff=1.bcif", marked.getName()); + assertEquals(plain.getAbsoluteFile().getParentFile(), marked.getParentFile()); + } + + @Test + public void emdbMappingFileIsDivided() { + File f = DensityCacheLayout.emdbMappingFile(ROOT, new PdbId("6hu9")); + assertEquals("6hu9.emdb.properties", f.getName()); + assertEquals("hu", f.getParentFile().getName()); + assertEquals(DensityCacheLayout.EMDB_MAPPING_DIR, f.getParentFile().getParentFile().getName()); + } +} diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityFallbackChain.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityFallbackChain.java new file mode 100644 index 0000000000..ca33f34063 --- /dev/null +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityFallbackChain.java @@ -0,0 +1,291 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.File; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.biojava.nbio.core.util.HttpStatusException; +import org.biojava.nbio.structure.PdbId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * The fallback chain, exercised with stub providers so that no server is + * contacted. + *

+ * The behaviour that matters most here is the difference between "this source + * has nothing for the entry" and "this source could not be reached". The first + * must move on to the next source; the second must abort, because reporting a + * network outage as "no density exists" would be actively misleading. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class TestDensityFallbackChain { + + private DensityMapCache cache; + private List called; + + @BeforeEach + public void setUp() { + cache = new DensityMapCache(System.getProperty("java.io.tmpdir")); + called = new ArrayList<>(); + } + + /** A provider that behaves however the test needs it to. */ + private class StubProvider implements DensityMapProvider { + + private final DensityMapSource source; + private final DensityFileFormat format; + private final IOException failure; + private final boolean returnsNull; + + StubProvider(DensityMapSource source, DensityFileFormat format, IOException failure, boolean returnsNull) { + this.source = source; + this.format = format; + this.failure = failure; + this.returnsNull = returnsNull; + } + + @Override + public DensityMapSource getSource() { + return source; + } + + @Override + public DensityFileFormat getFormat() { + return format; + } + + @Override + public boolean supports(DensityMapKind kind) { + return kind != DensityMapKind.AUTO; + } + + @Override + public DensityMapResult fetch(DensityMapRequest request) throws IOException { + called.add(source); + if (failure != null) { + throw failure; + } + if (returnsNull) { + return null; + } + return new DensityMapResult(new File("stub.map"), source, format, request.getKind(), + request.getPdbId(), request.getEmdbId(), "stub://" + source, false, null, null); + } + } + + private StubProvider missing(DensityMapSource source) { + return new StubProvider(source, DensityFileFormat.CCP4, + new HttpStatusException(404, "stub://" + source, "Not Found"), false); + } + + private StubProvider succeeds(DensityMapSource source) { + return new StubProvider(source, DensityFileFormat.CCP4, null, false); + } + + private void useOnly(DensityMapSource... sources) { + cache.setSourceChain(DensityMapKind.TWO_FO_FC, Arrays.asList(sources)); + for (DensityMapSource s : DensityMapSource.values()) { + cache.setSourceEnabled(s, Arrays.asList(sources).contains(s)); + } + } + + @Test + public void aMissingSourceFallsThroughToTheNext() throws IOException { + cache.registerProvider(missing(DensityMapSource.RCSB_VOLUME_SERVER)); + cache.registerProvider(succeeds(DensityMapSource.PDBE_CCP4)); + useOnly(DensityMapSource.RCSB_VOLUME_SERVER, DensityMapSource.PDBE_CCP4); + + DensityMapResult result = cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + + assertEquals(DensityMapSource.PDBE_CCP4, result.getSource()); + assertEquals(Arrays.asList(DensityMapSource.RCSB_VOLUME_SERVER, DensityMapSource.PDBE_CCP4), called); + } + + @Test + public void returningNullAlsoFallsThrough() throws IOException { + cache.registerProvider(new StubProvider(DensityMapSource.RCSB_VOLUME_SERVER, + DensityFileFormat.CCP4, null, true)); + cache.registerProvider(succeeds(DensityMapSource.PDBE_CCP4)); + useOnly(DensityMapSource.RCSB_VOLUME_SERVER, DensityMapSource.PDBE_CCP4); + + assertEquals(DensityMapSource.PDBE_CCP4, + cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC).getSource()); + } + + @Test + public void aTooLargeMapFallsThroughToASmallerSource() throws IOException { + cache.registerProvider(new StubProvider(DensityMapSource.EMDB_MAP, DensityFileFormat.CCP4_GZ, + new DensityMapTooLargeException("stub://big", 111426503L, 1024L), false)); + cache.registerProvider(succeeds(DensityMapSource.RCSB_VOLUME_SERVER)); + useOnly(DensityMapSource.EMDB_MAP, DensityMapSource.RCSB_VOLUME_SERVER); + + assertEquals(DensityMapSource.RCSB_VOLUME_SERVER, + cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC).getSource()); + } + + /** + * A dropped connection is not evidence that the entry has no density, so the + * chain must stop rather than quietly try the rest and report "none available". + */ + @Test + public void aTransportFailureAbortsTheChain() { + cache.registerProvider(new StubProvider(DensityMapSource.RCSB_VOLUME_SERVER, DensityFileFormat.CCP4, + new SocketTimeoutException("connection timed out"), false)); + cache.registerProvider(succeeds(DensityMapSource.PDBE_CCP4)); + useOnly(DensityMapSource.RCSB_VOLUME_SERVER, DensityMapSource.PDBE_CCP4); + + try { + cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + fail("a transport failure should not be reported as a missing map"); + } catch (NoDensityMapException e) { + fail("a transport failure must not be reported as NoDensityMapException"); + } catch (IOException expected) { + assertEquals(Arrays.asList(DensityMapSource.RCSB_VOLUME_SERVER), called); + } + } + + /** A 5xx is a server problem, not an absent entry, so it must abort too. */ + @Test + public void aServerErrorAbortsTheChain() { + cache.registerProvider(new StubProvider(DensityMapSource.RCSB_VOLUME_SERVER, DensityFileFormat.CCP4, + new HttpStatusException(503, "stub://x", "Service Unavailable"), false)); + cache.registerProvider(succeeds(DensityMapSource.PDBE_CCP4)); + useOnly(DensityMapSource.RCSB_VOLUME_SERVER, DensityMapSource.PDBE_CCP4); + + try { + cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + fail("HTTP 503 should abort the chain"); + } catch (IOException expected) { + assertTrue(expected instanceof HttpStatusException); + assertEquals(503, ((HttpStatusException) expected).getStatusCode()); + } + } + + @Test + public void exhaustingEverySourceReportsWhatWasTried() { + cache.registerProvider(missing(DensityMapSource.RCSB_VOLUME_SERVER)); + cache.registerProvider(missing(DensityMapSource.PDBE_CCP4)); + useOnly(DensityMapSource.RCSB_VOLUME_SERVER, DensityMapSource.PDBE_CCP4); + + try { + cache.getDensityMap(new PdbId("4hhb"), DensityMapKind.TWO_FO_FC); + fail("expected NoDensityMapException"); + } catch (NoDensityMapException e) { + assertEquals(2, e.getAttempts().size()); + assertTrue(e.getAttempts().get(DensityMapSource.RCSB_VOLUME_SERVER).contains("404")); + assertTrue(e.getMessage().contains("4HHB") || e.getMessage().contains("4hhb")); + } catch (IOException e) { + fail("expected NoDensityMapException but got " + e); + } + } + + @Test + public void aDisabledSourceIsNotCalled() { + cache.registerProvider(succeeds(DensityMapSource.PDBE_CCP4)); + useOnly(DensityMapSource.PDBE_CCP4); + cache.setSourceEnabled(DensityMapSource.PDBE_CCP4, false); + + try { + cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + fail("expected NoDensityMapException"); + } catch (NoDensityMapException e) { + assertTrue(called.isEmpty()); + assertEquals("disabled", e.getAttempts().get(DensityMapSource.PDBE_CCP4)); + } catch (IOException e) { + fail("expected NoDensityMapException but got " + e); + } + } + + /** Map coefficients are archival only, so they are off unless asked for. */ + @Test + public void mapCoefficientsAreDisabledByDefault() { + assertFalse(new DensityMapCache(System.getProperty("java.io.tmpdir")) + .isSourceEnabled(DensityMapSource.WWPDB_MAP_COEFFICIENTS)); + } + + /** + * A viewer asks for renderable formats only; that must exclude the coefficients + * even when the source is enabled. + */ + @Test + public void unrenderableFormatsAreSkippedWhenTheCallerCannotUseThem() { + cache.registerProvider(new StubProvider(DensityMapSource.WWPDB_MAP_COEFFICIENTS, + DensityFileFormat.MAP_COEFFICIENTS_CIF_GZ, null, false)); + useOnly(DensityMapSource.WWPDB_MAP_COEFFICIENTS); + + try { + cache.getDensityMap(DensityMapRequest.builder(new PdbId("1cbs")) + .kind(DensityMapKind.TWO_FO_FC) + .allowNonRenderableFormats(false) + .build()); + fail("expected NoDensityMapException"); + } catch (NoDensityMapException e) { + assertTrue(called.isEmpty()); + assertTrue(e.getAttempts().get(DensityMapSource.WWPDB_MAP_COEFFICIENTS).contains("Fourier")); + } catch (IOException e) { + fail("expected NoDensityMapException but got " + e); + } + } + + @Test + public void coefficientsAreUsedWhenTheCallerAllowsThem() throws IOException { + cache.registerProvider(new StubProvider(DensityMapSource.WWPDB_MAP_COEFFICIENTS, + DensityFileFormat.MAP_COEFFICIENTS_CIF_GZ, null, false)); + useOnly(DensityMapSource.WWPDB_MAP_COEFFICIENTS); + + DensityMapResult result = cache.getDensityMap(DensityMapRequest.builder(new PdbId("1cbs")) + .kind(DensityMapKind.TWO_FO_FC) + .allowNonRenderableFormats(true) + .build()); + assertFalse(result.isRenderable()); + } + + /** AUTO tries the X-ray map first, then falls through to the EM one. */ + @Test + public void autoFallsFromXrayToEm() { + assertEquals(Arrays.asList(DensityMapKind.TWO_FO_FC, DensityMapKind.EM), + DensityMapKind.AUTO.resolve()); + assertEquals(Arrays.asList(DensityMapKind.FO_FC), DensityMapKind.FO_FC.resolve()); + } + + /** X-ray and EM entries are served by deliberately different orders. */ + @Test + public void defaultChainsPreferTheSmallestSource() { + DensityMapCache fresh = new DensityMapCache(System.getProperty("java.io.tmpdir")); + assertEquals(DensityMapSource.RCSB_VOLUME_SERVER, + fresh.getSourceChain(DensityMapKind.TWO_FO_FC).get(0)); + assertEquals(DensityMapSource.RCSB_VOLUME_SERVER, + fresh.getSourceChain(DensityMapKind.EM).get(0)); + // the full-resolution archive is the last resort for EM + List em = fresh.getSourceChain(DensityMapKind.EM); + assertEquals(DensityMapSource.EMDB_MAP, em.get(em.size() - 1)); + } +} diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityMapUrlTemplates.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityMapUrlTemplates.java new file mode 100644 index 0000000000..716b02b632 --- /dev/null +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestDensityMapUrlTemplates.java @@ -0,0 +1,235 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; + +import org.biojava.nbio.structure.PdbId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * URL construction for each provider. These run offline: only the strings are + * built, nothing is fetched. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class TestDensityMapUrlTemplates { + + private static final File ROOT = new File("/tmp/bjcache"); + + @AfterEach + public void restoreDefaults() { + PdbeCcp4MapProvider.resetToDefaults(); + WwpdbMapCoefficientsProvider.resetToDefaults(); + EmdbMapProvider.resetToDefaults(); + } + + /** + * PDBe answers only to the lower-case four-character spelling; an upper-case or + * extended one returns HTTP 404. + */ + @Test + public void pdbeUrlsAreLowerCase() { + PdbeCcp4MapProvider p = new PdbeCcp4MapProvider(ROOT); + assertEquals("https://www.ebi.ac.uk/pdbe/coordinates/files/1cbs.ccp4", + p.buildUrl(new PdbId("1CBS"), DensityMapKind.TWO_FO_FC)); + assertEquals("https://www.ebi.ac.uk/pdbe/coordinates/files/1cbs_diff.ccp4", + p.buildUrl(new PdbId("1cbs"), DensityMapKind.FO_FC)); + } + + @Test + public void pdbeServerIsConfigurable() { + PdbeCcp4MapProvider.setServerBaseUrl("http://localhost:1/maps"); + PdbeCcp4MapProvider p = new PdbeCcp4MapProvider(ROOT); + assertEquals("http://localhost:1/maps/1cbs.ccp4", + p.buildUrl(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC)); + } + + /** + * The coefficients are addressed by file name, not by a constructed directory + * path, so that the July 2027 move to the per-entry archive does not invalidate + * the URLs. + */ + @Test + public void wwpdbUrlsResolveByName() { + WwpdbMapCoefficientsProvider p = new WwpdbMapCoefficientsProvider(ROOT); + assertEquals("https://files.wwpdb.org/validation/download/" + + "1cbs_validation_2fo-fc_map_coef.cif.gz", + p.buildUrl(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC)); + assertEquals("https://files.wwpdb.org/validation/download/" + + "1cbs_validation_fo-fc_map_coef.cif.gz", + p.buildUrl(new PdbId("1cbs"), DensityMapKind.FO_FC)); + } + + /** + * The identifier spelling is not hard-coded either way. An entry with a short + * form is asked for by it; one without — every entry deposited once the + * four-character space is exhausted — is asked for by its extended form. + * Both spellings resolve at the endpoint, so nothing here has to change in 2027. + */ + @Test + public void wwpdbUrlsFollowWhicheverSpellingTheEntryHas() { + WwpdbMapCoefficientsProvider p = new WwpdbMapCoefficientsProvider(ROOT); + assertTrue(p.buildUrl(new PdbId("pdb_00001cbs"), DensityMapKind.TWO_FO_FC) + .endsWith("/1cbs_validation_2fo-fc_map_coef.cif.gz"), + "a shortable entry is asked for by its short name"); + assertTrue(p.buildUrl(new PdbId("pdb_00012abc"), DensityMapKind.TWO_FO_FC) + .endsWith("/pdb_00012abc_validation_2fo-fc_map_coef.cif.gz"), + "an entry with no short form is asked for by its extended name"); + } + + /** + * The beta host is a host swap and nothing more: the same endpoint, the same + * file name. That is what makes it a test target rather than a default - it + * holds the post-2027 content, so a request against it exercises the archive as + * it will be, while the default host is the one that survives the cutover. + */ + @Test + public void betaHostChangesNothingButTheHost() { + WwpdbMapCoefficientsProvider p = new WwpdbMapCoefficientsProvider(ROOT); + String viaDefault = p.buildUrl(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + WwpdbMapCoefficientsProvider.setServerBaseUrl(WwpdbMapCoefficientsProvider.BETA_SERVER_URL); + String viaBeta = p.buildUrl(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + assertEquals(viaDefault.substring(viaDefault.lastIndexOf('/')), + viaBeta.substring(viaBeta.lastIndexOf('/'))); + assertTrue(viaBeta.startsWith("https://files-beta.wwpdb.org/validation/download/")); + } + + /** EBI publishes directories rather than an endpoint, so it needs the divided templates. */ + @Test + public void dividedTemplatesStillReachMirrors() { + WwpdbMapCoefficientsProvider.setServerBaseUrl(WwpdbMapCoefficientsProvider.EBI_MIRROR_URL); + WwpdbMapCoefficientsProvider.setPathUrlTemplate(DensityMapKind.TWO_FO_FC, + WwpdbMapCoefficientsProvider.DIVIDED_TWO_FO_FC_TEMPLATE); + WwpdbMapCoefficientsProvider p = new WwpdbMapCoefficientsProvider(ROOT); + assertEquals("https://ftp.ebi.ac.uk/pub/databases/pdb/validation_reports/" + + "cb/1cbs/1cbs_validation_2fo-fc_map_coef.cif.gz", + p.buildUrl(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC)); + } + + /** + * The archive layout that arrives in July 2027 is expressible as a template, + * so a mirror of it needs configuration rather than a new release. + */ + @Test + public void perEntryTemplateUsesExtendedIdentifiers() { + WwpdbMapCoefficientsProvider.setServerBaseUrl("https://files-beta.wwpdb.org/pub/wwpdb/pdb/data/"); + WwpdbMapCoefficientsProvider.setPathUrlTemplate(DensityMapKind.TWO_FO_FC, + WwpdbMapCoefficientsProvider.ENTRIES_TWO_FO_FC_TEMPLATE); + WwpdbMapCoefficientsProvider p = new WwpdbMapCoefficientsProvider(ROOT); + assertEquals("https://files-beta.wwpdb.org/pub/wwpdb/pdb/data/" + + "entries/cb/pdb_00001cbs/validation_reports/" + + "pdb_00001cbs_validation_2fo-fc_map_coef.cif.gz", + p.buildUrl(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC)); + } + + /** The extended spelling is available to any template, in the case the archive uses. */ + @Test + public void extendedIdentifierPlaceholder() { + assertEquals("pdb_00001cbs", UrlTemplates.values("1cbs", null, -1).get("extid")); + assertEquals("pdb_00001cbs", UrlTemplates.values("pdb_00001cbs", null, -1).get("extid")); + assertEquals("{extid}", + UrlTemplates.expand("{extid}", UrlTemplates.values("not an id", null, -1)), + "an unusable identifier leaves the placeholder alone rather than guessing"); + } + + /** + * The two-character directory is counted from the right hand end, so it is the + * same for both spellings. This is what lets the cache layout stay as it is + * across the 2027 transition, and it is the rule the wwPDB documents for the + * new archive. + */ + @Test + public void middleHashIsTheSameForBothSpellings() { + assertEquals("cb", UrlTemplates.values("1cbs", null, -1).get("mid")); + assertEquals("cb", UrlTemplates.values("pdb_00001cbs", null, -1).get("mid")); + } + + @Test + public void volumeServerUrlsCarryDetailAndEncoding() { + VolumeServerProvider rcsb = new VolumeServerProvider(ROOT, VolumeServerProvider.Host.RCSB); + rcsb.setDetail(0); + String url = rcsb.buildUrl(DensityMapRequest.builder(new PdbId("1cbs")) + .kind(DensityMapKind.TWO_FO_FC).build()); + assertEquals("https://maps.rcsb.org/x-ray/1cbs/cell?detail=0&encoding=bcif", url); + + VolumeServerProvider pdbe = new VolumeServerProvider(ROOT, VolumeServerProvider.Host.PDBE); + pdbe.setDetail(6); + assertEquals("https://www.ebi.ac.uk/pdbe/volume-server/x-ray/1cbs/cell?detail=6&encoding=bcif", + pdbe.buildUrl(DensityMapRequest.builder(new PdbId("1cbs")) + .kind(DensityMapKind.TWO_FO_FC).build())); + } + + @Test + public void volumeServerEmUrlUsesTheEmdbNumber() { + VolumeServerProvider rcsb = new VolumeServerProvider(ROOT, VolumeServerProvider.Host.RCSB); + rcsb.setDetail(3); + String url = rcsb.buildUrl(DensityMapRequest.builder("EMD-0262").build()); + assertEquals("https://maps.rcsb.org/em/emd-0262/cell?detail=3&encoding=bcif", url); + } + + @Test + public void encodingSelectsTheFormat() { + VolumeServerProvider p = new VolumeServerProvider(ROOT, VolumeServerProvider.Host.RCSB); + assertEquals(DensityFileFormat.BCIF_VOLUME, p.getFormat()); + p.setEncoding("cif"); + assertEquals(DensityFileFormat.CIF_VOLUME, p.getFormat()); + assertTrue(p.buildUrl(DensityMapRequest.builder(new PdbId("1cbs")) + .kind(DensityMapKind.TWO_FO_FC).build()).endsWith("encoding=cif")); + } + + @Test + public void emdbMapUrl() { + EmdbMapProvider p = new EmdbMapProvider(ROOT, null); + assertEquals("https://ftp.ebi.ac.uk/pub/databases/emdb/structures/EMD-0262/map/emd_0262.map.gz", + p.buildUrl("EMD-0262")); + assertEquals("https://ftp.ebi.ac.uk/pub/databases/emdb/structures/EMD-0262/map/emd_0262.map.gz", + p.buildUrl("emd_0262")); + } + + /** An unknown placeholder must survive verbatim, so a bad template is obvious. */ + @Test + public void unknownPlaceholdersAreLeftAlone() { + assertEquals("a/{nosuch}/b", + UrlTemplates.expand("a/{nosuch}/b", UrlTemplates.values("1cbs", null, -1))); + } + + @Test + public void providersDeclareWhatTheyCanServe() { + assertTrue(new PdbeCcp4MapProvider(ROOT).supports(DensityMapKind.TWO_FO_FC)); + assertTrue(!new PdbeCcp4MapProvider(ROOT).supports(DensityMapKind.EM)); + assertTrue(new EmdbMapProvider(ROOT, null).supports(DensityMapKind.EM)); + assertTrue(!new EmdbMapProvider(ROOT, null).supports(DensityMapKind.TWO_FO_FC)); + assertTrue(new VolumeServerProvider(ROOT, VolumeServerProvider.Host.RCSB).supports(DensityMapKind.EM)); + } + + /** Only the coefficients are unrenderable; every sampled grid format is fine. */ + @Test + public void onlyCoefficientsAreUnrenderable() { + assertTrue(!DensityFileFormat.MAP_COEFFICIENTS_CIF_GZ.isJmolLoadable()); + assertTrue(DensityFileFormat.CCP4.isJmolLoadable()); + assertTrue(DensityFileFormat.CCP4_GZ.isJmolLoadable()); + assertTrue(DensityFileFormat.BCIF_VOLUME.isJmolLoadable()); + assertTrue(DensityFileFormat.CIF_VOLUME.isJmolLoadable()); + } +} diff --git a/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestEmdbAndLocalOnly.java b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestEmdbAndLocalOnly.java new file mode 100644 index 0000000000..97cef78926 --- /dev/null +++ b/biojava-structure/src/test/java/org/biojava/nbio/structure/io/density/TestEmdbAndLocalOnly.java @@ -0,0 +1,221 @@ +/** + * BioJava development code + * + * This code may be freely distributed and modified under the terms of the GNU + * Lesser General Public Licence. This should be distributed with the code. If + * you do not have a copy, see: + * + * http://www.gnu.org/copyleft/lesser.html + * + * Copyright for this code is held jointly by the individual authors. These + * should be listed in @author doc comments. + * + * For more information on the BioJava project and its aims, or to join the + * biojava-l mailing list, visit the home page at: + * + * http://www.biojava.org/ + */ +package org.biojava.nbio.structure.io.density; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +import org.biojava.nbio.core.util.FileDownloadUtils; +import org.biojava.nbio.structure.PdbId; +import org.biojava.nbio.structure.io.LocalPDBDirectory.FetchBehavior; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * EMDB metadata parsing against captured responses, and the guarantee that + * LOCAL_ONLY never opens a connection. + * + * @author Amr ALHOSSARY + * @since 7.3.0 + */ +public class TestEmdbAndLocalOnly { + + /** + * Nothing listens here, so any attempt to reach a server fails immediately + * rather than hanging or, worse, quietly succeeding. + */ + private static final String DEAD_SERVER = "http://localhost:1/"; + + private File cacheRoot; + + @BeforeEach + public void setUp() throws IOException { + cacheRoot = Files.createTempDirectory("bj-density").toFile(); + } + + @AfterEach + public void tearDown() throws IOException { + FileDownloadUtils.deleteDirectory(cacheRoot.toPath()); + PdbeCcp4MapProvider.resetToDefaults(); + WwpdbMapCoefficientsProvider.resetToDefaults(); + EmdbMapProvider.resetToDefaults(); + EmdbEntryResolver.resetToDefaults(); + } + + private void copyResource(String resource, File target) throws IOException { + target.getParentFile().mkdirs(); + try (InputStream in = getClass().getResourceAsStream(resource)) { + assertNotNull(in, "missing test resource " + resource); + Files.copy(in, target.toPath()); + } + } + + /** + * The map metadata carries the two numbers a viewer needs: the level the + * depositors recommend and the RMS deviation that converts it to sigma. + */ + @Test + public void parsesEmdbMapMetadata() throws IOException { + copyResource("emdb-map-EMD-0262.json", + DensityCacheLayout.emdbMapInfoFile(cacheRoot, "EMD-0262")); + + EmdbEntryResolver resolver = new EmdbEntryResolver(cacheRoot); + resolver.setFetchBehavior(FetchBehavior.LOCAL_ONLY); + EmdbEntryInfo info = resolver.getEntryInfo("EMD-0262"); + + assertNotNull(info); + assertEquals("EMD-0262", info.getEmdbId()); + assertEquals(0.0263, info.getRecommendedContourLevel(), 1e-9); + assertNotNull(info.getSigma(), "sigma is needed to express the contour in sigma units"); + // 119165 kB, which is well past the default download ceiling + assertEquals(119165L * 1024L, info.getMapSizeBytes().longValue()); + } + + /** + * The size of a real EM map, and why the source order rather than the size + * guard is what keeps it from being fetched. + *

+ * EMD-0262 is about 116 MB, which sits comfortably under the 256 MiB default + * ceiling: left to the ceiling alone it would be downloaded in full. What + * avoids that is putting the density servers ahead of the archive, which serve + * a few megabytes for the same entry. The ceiling is the backstop for the maps + * that run to gigabytes. + */ + @Test + public void theFullEmMapIsLargeButWithinTheDefaultCeiling() throws IOException { + copyResource("emdb-map-EMD-0262.json", + DensityCacheLayout.emdbMapInfoFile(cacheRoot, "EMD-0262")); + EmdbEntryResolver resolver = new EmdbEntryResolver(cacheRoot); + resolver.setFetchBehavior(FetchBehavior.LOCAL_ONLY); + + long bytes = resolver.getEntryInfo("EMD-0262").getMapSizeBytes(); + assertTrue(bytes > 100L * 1024 * 1024, "expected a map of order 100 MB, got " + bytes); + assertTrue(bytes < DensityMapCache.DEFAULT_MAX_DOWNLOAD_BYTES, + "the ceiling alone would not stop this download"); + + DensityMapCache cache = new DensityMapCache(cacheRoot.getAbsolutePath()); + List em = cache.getSourceChain(DensityMapKind.EM); + assertTrue(em.indexOf(DensityMapSource.RCSB_VOLUME_SERVER) < em.indexOf(DensityMapSource.EMDB_MAP), + "a density server must be tried before the full archive"); + } + + @Test + public void contourConvertsToSigma() throws IOException { + copyResource("emdb-map-EMD-0262.json", + DensityCacheLayout.emdbMapInfoFile(cacheRoot, "EMD-0262")); + EmdbEntryResolver resolver = new EmdbEntryResolver(cacheRoot); + resolver.setFetchBehavior(FetchBehavior.LOCAL_ONLY); + EmdbEntryInfo info = resolver.getEntryInfo("EMD-0262"); + + DensityMapResult result = new DensityMapResult(new File("x.map"), DensityMapSource.EMDB_MAP, + DensityFileFormat.CCP4_GZ, DensityMapKind.EM, new PdbId("6hu9"), "EMD-0262", "u", false, + info.getRecommendedContourLevel(), info.getSigma()); + + assertEquals(info.getRecommendedContourLevel() / info.getSigma(), result.getContourInSigma(), 1e-9); + } + + /** A cached mapping is used whatever its age when downloads are off. */ + @Test + public void localOnlyUsesACachedMappingWithoutAskingAnyServer() throws IOException { + File mappingFile = DensityCacheLayout.emdbMappingFile(cacheRoot, new PdbId("6hu9")); + mappingFile.getParentFile().mkdirs(); + Files.write(mappingFile.toPath(), + ("pdbId=6hu9\nemdbIds=EMD-0262\ncontourLevel=0.0263\nretrieved=1999-01-01T00:00:00Z\n") + .getBytes(StandardCharsets.UTF_8)); + + EmdbEntryResolver resolver = new EmdbEntryResolver(cacheRoot); + resolver.setFetchBehavior(FetchBehavior.LOCAL_ONLY); + // point every template at a dead port, so a lookup would fail loudly + EmdbEntryResolver.setSearchUrlTemplate(DEAD_SERVER + "{pdbid_lc}"); + EmdbEntryResolver.setRcsbEntryUrlTemplate(DEAD_SERVER + "{pdbid_lc}"); + + List ids = resolver.getEmdbIds(new PdbId("6hu9")); + assertEquals(1, ids.size()); + assertEquals("EMD-0262", ids.get(0)); + } + + @Test + public void localOnlyReturnsNothingWhenNothingIsCached() { + EmdbEntryResolver resolver = new EmdbEntryResolver(cacheRoot); + resolver.setFetchBehavior(FetchBehavior.LOCAL_ONLY); + EmdbEntryResolver.setSearchUrlTemplate(DEAD_SERVER + "{pdbid_lc}"); + EmdbEntryResolver.setRcsbEntryUrlTemplate(DEAD_SERVER + "{pdbid_lc}"); + + assertTrue(resolver.getEmdbIds(new PdbId("6hu9")).isEmpty()); + } + + /** + * A cached map is served without any network access at all, and its metadata is + * rebuilt from the sidecar rather than from a server. + */ + @Test + public void localOnlyServesACachedMapOffline() throws IOException { + DensityMapCache cache = new DensityMapCache(cacheRoot.getAbsolutePath()); + cache.setFetchBehavior(FetchBehavior.LOCAL_ONLY); + PdbeCcp4MapProvider.setServerBaseUrl(DEAD_SERVER); + cache.setSourceEnabled(DensityMapSource.RCSB_VOLUME_SERVER, false); + cache.setSourceEnabled(DensityMapSource.PDBE_VOLUME_SERVER, false); + + File cached = DensityCacheLayout.pdbMapFile(cacheRoot, new PdbId("1cbs"), + DensityMapKind.TWO_FO_FC, DensityMapSource.PDBE_CCP4, DensityFileFormat.CCP4, null); + cached.getParentFile().mkdirs(); + Files.write(cached.toPath(), fakeCcp4()); + + DensityMapResult result = cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + + assertEquals(DensityMapSource.PDBE_CCP4, result.getSource()); + assertTrue(result.isFromCache()); + assertEquals(cached, result.getFile()); + assertTrue(DensityMapResult.metaFileFor(cached).isFile(), + "a sidecar should have been written for the recovered file"); + } + + @Test + public void localOnlyRefusesWhenNothingIsCached() { + DensityMapCache cache = new DensityMapCache(cacheRoot.getAbsolutePath()); + cache.setFetchBehavior(FetchBehavior.LOCAL_ONLY); + PdbeCcp4MapProvider.setServerBaseUrl(DEAD_SERVER); + + try { + cache.getDensityMap(new PdbId("1cbs"), DensityMapKind.TWO_FO_FC); + fail("expected NoDensityMapException"); + } catch (NoDensityMapException e) { + assertFalse(e.getAttempts().isEmpty()); + } catch (IOException e) { + fail("LOCAL_ONLY must not attempt any connection, but got " + e); + } + } + + private static byte[] fakeCcp4() { + byte[] bytes = new byte[4096]; + byte[] stamp = Ccp4Header.MAP_STAMP.getBytes(StandardCharsets.US_ASCII); + System.arraycopy(stamp, 0, bytes, Ccp4Header.MAP_STAMP_OFFSET, stamp.length); + return bytes; + } +} diff --git a/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/emdb-map-EMD-0262.json b/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/emdb-map-EMD-0262.json new file mode 100644 index 0000000000..3067838be7 --- /dev/null +++ b/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/emdb-map-EMD-0262.json @@ -0,0 +1 @@ +{"emdb_id": "EMD-0262", "map": {"format": "CCP4", "size_kbytes": 119165, "file": "emd_0262.map.gz", "symmetry": {"space_group": "1"}, "data_type": "IMAGE STORED AS FLOATING POINT NUMBER (4 BYTES)", "dimensions": {"col": 310, "row": 310, "sec": 310}, "origin": {"col": 0, "row": 0, "sec": 0}, "spacing": {"x": 310, "y": 310, "z": 310}, "cell": {"a": {"units": "\u212b", "valueOf_": "429.691"}, "b": {"units": "\u212b", "valueOf_": "429.691"}, "c": {"units": "\u212b", "valueOf_": "429.691"}, "alpha": {"units": "deg", "valueOf_": "90.0"}, "beta": {"units": "deg", "valueOf_": "90.0"}, "gamma": {"units": "deg", "valueOf_": "90.0"}}, "axis_order": {"fast": "X", "medium": "Y", "slow": "Z"}, "statistics": {"minimum": -0.14692926, "maximum": 0.2550798, "average": 0.0005225369, "std": 0.009231779}, "pixel_spacing": {"x": {"units": "\u212b", "valueOf_": "1.3861"}, "y": {"units": "\u212b", "valueOf_": "1.3861"}, "z": {"units": "\u212b", "valueOf_": "1.3861"}}, "contour_list": {"contour": [{"primary": true, "level": 0.0263, "source": "AUTHOR", "instance_type": "contour"}]}, "label": "::::EMDATABANK.org::::EMD-0262::::", "annotation_details": "The sharpened map of the III2IV2 supercomplex"}} \ No newline at end of file diff --git a/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/emdb-search-6hu9.csv b/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/emdb-search-6hu9.csv new file mode 100644 index 0000000000..8046fd8b78 --- /dev/null +++ b/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/emdb-search-6hu9.csv @@ -0,0 +1,2 @@ +emdb_id,map_contour_level_value +EMD-0262,0.0263 diff --git a/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/rcsb-entry-6hu9.json b/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/rcsb-entry-6hu9.json new file mode 100644 index 0000000000..b200c1817f --- /dev/null +++ b/biojava-structure/src/test/resources/org/biojava/nbio/structure/io/density/rcsb-entry-6hu9.json @@ -0,0 +1 @@ +{"audit_author":[{"identifier_ORCID":"0000-0001-7986-6736","name":"Hartley, A.M.","pdbx_ordinal":1},{"identifier_ORCID":"0000-0002-5096-257X","name":"Pinotsis, N.","pdbx_ordinal":2},{"identifier_ORCID":"0000-0003-3460-3806","name":"Marechal, A.","pdbx_ordinal":3}],"citation":[{"country":"US","id":"primary","journal_abbrev":"Nat. Struct. Mol. Biol.","journal_id_ISSN":"1545-9985","journal_volume":"26","page_first":"78","page_last":"83","pdbx_database_id_DOI":"10.1038/s41594-018-0172-z","pdbx_database_id_PubMed":30598554,"rcsb_authors":["Hartley, A.M.","Lukoyanova, N.","Zhang, Y.","Cabrera-Orefice, A.","Arnold, S.","Meunier, B.","Pinotsis, N.","Marechal, A."],"rcsb_is_primary":"Y","rcsb_journal_abbrev":"Nat Struct Mol Biol","title":"Structure of yeast cytochrome c oxidase in a supercomplex with cytochrome bc1.","year":2019}],"database_2":[{"database_code":"6HU9","database_id":"PDB","pdbx_DOI":"10.2210/pdb6hu9/pdb","pdbx_database_accession":"pdb_00006hu9"},{"database_code":"D_1200012277","database_id":"WWPDB"}],"em_3d_fitting":[{"id":"1","ref_protocol":"RIGID BODY FIT","ref_space":"REAL"}],"em_3d_fitting_list":[{"3d_fitting_id":"1","id":"1","pdb_entry_id":"1KYO"},{"3d_fitting_id":"1","id":"2","pdb_entry_id":"1V54"}],"em_3d_reconstruction":[{"id":"1","image_processing_id":"1","num_particles":44915,"resolution":3.35,"resolution_method":"FSC 0.5 CUT-OFF","symmetry_type":"POINT"}],"em_ctf_correction":[{"em_image_processing_id":"1","id":"1","type":"NONE"}],"em_entity_assembly":[{"entity_id_list":["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22"],"id":"1","name":"III2-IV2 mitochondrial respiratory supercomplex","parent_id":0,"source":"NATURAL","type":"COMPLEX"}],"em_experiment":{"aggregation_state":"PARTICLE","entity_assembly_id":"1","id":"1","reconstruction_method":"SINGLE PARTICLE"},"em_image_recording":[{"average_exposure_time":8.0,"avg_electron_dose_per_image":1.645,"detector_mode":"COUNTING","film_or_detector_model":"GATAN K2 QUANTUM (4k x 4k)","id":"1","imaging_id":"1"}],"em_imaging":[{"accelerating_voltage":300,"c2_aperture_diameter":70.0,"cryogen":"NITROGEN","electron_source":"FIELD EMISSION GUN","id":"1","illumination_mode":"FLOOD BEAM","microscope_model":"FEI TITAN KRIOS","mode":"BRIGHT FIELD","nominal_magnification":130000,"specimen_id":"1"}],"em_single_particle_entity":[{"id":1,"image_processing_id":"1","point_symmetry":"C1"}],"em_software":[{"category":"PARTICLE SELECTION","id":"1","image_processing_id":"1","name":"Gautomatch","version":"0.53"},{"category":"IMAGE ACQUISITION","id":"2","imaging_id":"1","name":"EPU"},{"category":"MASKING","id":"3"},{"category":"CTF CORRECTION","id":"4","image_processing_id":"1","name":"CTFFIND"},{"category":"LAYERLINE INDEXING","id":"5"},{"category":"DIFFRACTION INDEXING","id":"6"},{"category":"MODEL FITTING","fitting_id":"1","id":"7","name":"UCSF Chimera"},{"category":"OTHER","id":"8"},{"category":"MODEL REFINEMENT","fitting_id":"1","id":"9","name":"PHENIX","version":"1.13_2998"},{"category":"INITIAL EULER ASSIGNMENT","id":"10","image_processing_id":"1","name":"RELION","version":"3.0"},{"category":"FINAL EULER ASSIGNMENT","id":"11","image_processing_id":"1","name":"RELION","version":"3.0"},{"category":"CLASSIFICATION","id":"12","image_processing_id":"1","name":"RELION","version":"3.0"},{"category":"RECONSTRUCTION","id":"13","image_processing_id":"1","name":"RELION","version":"3.0"}],"em_specimen":[{"embedding_applied":"NO","experiment_id":"1","id":"1","shadowing_applied":"NO","staining_applied":"NO","vitrification_applied":"YES"}],"em_vitrification":[{"chamber_temperature":277.15,"cryogen_name":"ETHANE","details":"3 microL of sample applied to negatively glow discharged grid, blot force -10; blotting time 8.5 sec","humidity":92.0,"id":"1","instrument":"FEI VITROBOT MARK IV","specimen_id":"1"}],"entry":{"id":"6HU9"},"exptl":[{"method":"ELECTRON MICROSCOPY"}],"pdbx_audit_revision_category":[{"category":"citation","data_content_type":"Structure model","ordinal":1,"revision_ordinal":2},{"category":"citation_author","data_content_type":"Structure model","ordinal":2,"revision_ordinal":2},{"category":"pdbx_database_proc","data_content_type":"Structure model","ordinal":3,"revision_ordinal":2},{"category":"citation","data_content_type":"Structure model","ordinal":4,"revision_ordinal":3},{"category":"citation_author","data_content_type":"Structure model","ordinal":5,"revision_ordinal":3},{"category":"pdbx_database_proc","data_content_type":"Structure model","ordinal":6,"revision_ordinal":3},{"category":"pdbx_validate_close_contact","data_content_type":"Structure model","ordinal":7,"revision_ordinal":4},{"category":"struct_conn","data_content_type":"Structure model","ordinal":8,"revision_ordinal":4},{"category":"struct_site","data_content_type":"Structure model","ordinal":9,"revision_ordinal":4},{"category":"struct_site_gen","data_content_type":"Structure model","ordinal":10,"revision_ordinal":4},{"category":"atom_sites","data_content_type":"Structure model","ordinal":11,"revision_ordinal":5},{"category":"cell","data_content_type":"Structure model","ordinal":12,"revision_ordinal":5},{"category":"chem_comp_atom","data_content_type":"Structure model","ordinal":13,"revision_ordinal":6},{"category":"chem_comp_bond","data_content_type":"Structure model","ordinal":14,"revision_ordinal":6},{"category":"database_2","data_content_type":"Structure model","ordinal":15,"revision_ordinal":6},{"category":"em_3d_fitting_list","data_content_type":"Structure model","ordinal":16,"revision_ordinal":6},{"category":"pdbx_entry_details","data_content_type":"Structure model","ordinal":17,"revision_ordinal":6},{"category":"pdbx_initial_refinement_model","data_content_type":"Structure model","ordinal":18,"revision_ordinal":6},{"category":"pdbx_modification_feature","data_content_type":"Structure model","ordinal":19,"revision_ordinal":6},{"category":"pdbx_validate_chiral","data_content_type":"Structure model","ordinal":20,"revision_ordinal":6},{"category":"struct_conn","data_content_type":"Structure model","ordinal":21,"revision_ordinal":6},{"category":"struct_conn_type","data_content_type":"Structure model","ordinal":22,"revision_ordinal":6}],"pdbx_audit_revision_details":[{"data_content_type":"Structure model","ordinal":1,"provider":"repository","revision_ordinal":1,"type":"Initial release"}],"pdbx_audit_revision_group":[{"data_content_type":"Structure model","group":"Data collection","ordinal":1,"revision_ordinal":2},{"data_content_type":"Structure model","group":"Database references","ordinal":2,"revision_ordinal":2},{"data_content_type":"Structure model","group":"Data collection","ordinal":3,"revision_ordinal":3},{"data_content_type":"Structure model","group":"Database references","ordinal":4,"revision_ordinal":3},{"data_content_type":"Structure model","group":"Advisory","ordinal":5,"revision_ordinal":4},{"data_content_type":"Structure model","group":"Data collection","ordinal":6,"revision_ordinal":4},{"data_content_type":"Structure model","group":"Derived calculations","ordinal":7,"revision_ordinal":4},{"data_content_type":"Structure model","group":"Other","ordinal":8,"revision_ordinal":5},{"data_content_type":"Structure model","group":"Data collection","ordinal":9,"revision_ordinal":6},{"data_content_type":"Structure model","group":"Database references","ordinal":10,"revision_ordinal":6},{"data_content_type":"Structure model","group":"Derived calculations","ordinal":11,"revision_ordinal":6},{"data_content_type":"Structure model","group":"Refinement description","ordinal":12,"revision_ordinal":6},{"data_content_type":"Structure model","group":"Structure summary","ordinal":13,"revision_ordinal":6}],"pdbx_audit_revision_history":[{"data_content_type":"Structure model","major_revision":1,"minor_revision":0,"ordinal":1,"revision_date":"2018-12-26T00:00:00.000+00:00"},{"data_content_type":"Structure model","major_revision":1,"minor_revision":1,"ordinal":2,"revision_date":"2019-01-09T00:00:00.000+00:00"},{"data_content_type":"Structure model","major_revision":1,"minor_revision":2,"ordinal":3,"revision_date":"2019-01-16T00:00:00.000+00:00"},{"data_content_type":"Structure model","major_revision":1,"minor_revision":3,"ordinal":4,"revision_date":"2019-01-23T00:00:00.000+00:00"},{"data_content_type":"Structure model","major_revision":1,"minor_revision":4,"ordinal":5,"revision_date":"2019-12-11T00:00:00.000+00:00"},{"data_content_type":"Structure model","major_revision":1,"minor_revision":5,"ordinal":6,"revision_date":"2024-11-20T00:00:00.000+00:00"}],"pdbx_audit_revision_item":[{"data_content_type":"Structure model","item":"_citation.journal_abbrev","ordinal":1,"revision_ordinal":2},{"data_content_type":"Structure model","item":"_citation.pdbx_database_id_PubMed","ordinal":2,"revision_ordinal":2},{"data_content_type":"Structure model","item":"_citation.title","ordinal":3,"revision_ordinal":2},{"data_content_type":"Structure model","item":"_citation_author.identifier_ORCID","ordinal":4,"revision_ordinal":2},{"data_content_type":"Structure model","item":"_citation_author.name","ordinal":5,"revision_ordinal":2},{"data_content_type":"Structure model","item":"_citation.journal_volume","ordinal":6,"revision_ordinal":3},{"data_content_type":"Structure model","item":"_citation.page_first","ordinal":7,"revision_ordinal":3},{"data_content_type":"Structure model","item":"_citation.page_last","ordinal":8,"revision_ordinal":3},{"data_content_type":"Structure model","item":"_citation.year","ordinal":9,"revision_ordinal":3},{"data_content_type":"Structure model","item":"_citation_author.identifier_ORCID","ordinal":10,"revision_ordinal":3},{"data_content_type":"Structure model","item":"_atom_sites.fract_transf_matrix[1][1]","ordinal":11,"revision_ordinal":5},{"data_content_type":"Structure model","item":"_atom_sites.fract_transf_matrix[2][2]","ordinal":12,"revision_ordinal":5},{"data_content_type":"Structure model","item":"_atom_sites.fract_transf_matrix[3][3]","ordinal":13,"revision_ordinal":5},{"data_content_type":"Structure model","item":"_cell.Z_PDB","ordinal":14,"revision_ordinal":5},{"data_content_type":"Structure model","item":"_database_2.pdbx_DOI","ordinal":15,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_database_2.pdbx_database_accession","ordinal":16,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_em_3d_fitting_list.accession_code","ordinal":17,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_em_3d_fitting_list.initial_refinement_model_id","ordinal":18,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_em_3d_fitting_list.source_name","ordinal":19,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_em_3d_fitting_list.type","ordinal":20,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.conn_type_id","ordinal":21,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.id","ordinal":22,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.pdbx_dist_value","ordinal":23,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.pdbx_leaving_atom_flag","ordinal":24,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr1_auth_asym_id","ordinal":25,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr1_auth_comp_id","ordinal":26,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr1_auth_seq_id","ordinal":27,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr1_label_asym_id","ordinal":28,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr1_label_atom_id","ordinal":29,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr1_label_comp_id","ordinal":30,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr1_label_seq_id","ordinal":31,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr2_auth_asym_id","ordinal":32,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr2_auth_comp_id","ordinal":33,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr2_auth_seq_id","ordinal":34,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr2_label_asym_id","ordinal":35,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr2_label_atom_id","ordinal":36,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr2_label_comp_id","ordinal":37,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn.ptnr2_label_seq_id","ordinal":38,"revision_ordinal":6},{"data_content_type":"Structure model","item":"_struct_conn_type.id","ordinal":39,"revision_ordinal":6}],"pdbx_audit_support":[{"country":"United Kingdom","funding_organization":"Medical Research Council (United Kingdom)","grant_number":"MR/M00936X/1","ordinal":1},{"country":"United Kingdom","funding_organization":"Wellcome Trust","grant_number":"105628/Z/14/Z","ordinal":2}],"pdbx_database_related":[{"content_type":"associated EM volume","db_id":"EMD-0262","db_name":"EMDB","details":"BC1 (CHAINS A,B,C,D,E,F,G,H,I,J,L,M,N,O,P,Q,R,S,T,U)"},{"content_type":"other EM volume","db_id":"EMD-0269","db_name":"EMDB","details":"CIV 1 (chains a,b,c,d,e,f,g,h,i,j,k,l,y)"},{"content_type":"other EM volume","db_id":"EMD-0268","db_name":"EMDB","details":"CIV 2 (chains m,n,o,p,q,r,s,t,u,v,w,z)"}],"pdbx_database_status":{"SG_entry":"N","deposit_site":"PDBE","pdb_format_compatible":"Y","process_site":"PDBE","recvd_initial_deposition_date":"2018-10-05T00:00:00.000+00:00","status_code":"REL"},"pdbx_initial_refinement_model":[{"accession_code":"1KYO","id":1,"source_name":"PDB","type":"experimental model"},{"accession_code":"1V54","id":2,"source_name":"PDB","type":"experimental model"}],"pdbx_vrpt_summary":{"attempted_validation_steps":"visualanalysis,mogul,buster-report,molprobity,validation-pack,validation_schema,percentiles,writexml,writecif,writepdf","ligands_for_buster_report":"Y","report_creation_date":"2026-03-05T21:53:00.000+00:00"},"pdbx_vrpt_summary_em":[{"Q_score":0.431,"atom_inclusion_all_atoms":0.76,"atom_inclusion_backbone":0.817,"author_provided_fsc_resolution_by_cutoff_halfbit":3.32,"author_provided_fsc_resolution_by_cutoff_onebit":3.55,"author_provided_fsc_resolution_by_cutoff_pt_143":3.28,"author_provided_fsc_resolution_by_cutoff_pt_333":3.54,"author_provided_fsc_resolution_by_cutoff_pt_5":3.79,"contour_level_primary_map":0.0263,"exp_method":"electron microscopy"}],"pdbx_vrpt_summary_geometry":[{"angles_RMSZ":0.55,"bonds_RMSZ":0.36,"clashscore":5.7,"num_H_reduce":63043,"num_angles_RMSZ":84548,"num_bonds_RMSZ":62220,"percent_ramachandran_outliers":0.04,"percent_rotamer_outliers":8.49}],"rcsb_accession_info":{"deposit_date":"2018-10-05T00:00:00.000+00:00","has_released_experimental_data":"Y","initial_release_date":"2018-12-26T00:00:00.000+00:00","major_revision":1,"minor_revision":5,"revision_date":"2024-11-20T00:00:00.000+00:00","status_code":"REL"},"rcsb_entry_container_identifiers":{"assembly_ids":["1"],"emdb_ids":["EMD-0262"],"entity_ids":["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25","26","27","28","29","30","31","32","33","34","35"],"entry_id":"6HU9","model_ids":[1],"non_polymer_entity_ids":["23","24","25","26","27","28","29","30","31","32","33","34","35"],"polymer_entity_ids":["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22"],"pubmed_id":30598554,"rcsb_id":"6HU9","related_emdb_ids":["EMD-0269","EMD-0268"]},"rcsb_entry_info":{"assembly_count":1,"branched_entity_count":0,"cis_peptide_count":4,"deposited_atom_count":63031,"deposited_deuterated_water_count":0,"deposited_hydrogen_atom_count":0,"deposited_model_count":1,"deposited_modeled_polymer_monomer_count":7636,"deposited_nonpolymer_entity_instance_count":69,"deposited_polymer_entity_instance_count":44,"deposited_polymer_monomer_count":7922,"deposited_solvent_atom_count":0,"deposited_unmodeled_polymer_monomer_count":286,"disulfide_bond_count":10,"entity_count":35,"experimental_method":"EM","experimental_method_count":1,"inter_mol_covalent_bond_count":4,"inter_mol_metalic_bond_count":70,"molecular_weight":936.43,"na_polymer_entity_types":"Other","nonpolymer_bound_components":["CA","CU","CUA","FES","HEA","HEC","HEM","MG","ZN"],"nonpolymer_entity_count":13,"nonpolymer_molecular_weight_maximum":1.46,"nonpolymer_molecular_weight_minimum":0.02,"polymer_composition":"heteromeric protein","polymer_entity_count":22,"polymer_entity_count_DNA":0,"polymer_entity_count_RNA":0,"polymer_entity_count_nucleic_acid":0,"polymer_entity_count_nucleic_acid_hybrid":0,"polymer_entity_count_protein":22,"polymer_entity_taxonomy_count":22,"polymer_molecular_weight_maximum":58.83,"polymer_molecular_weight_minimum":5.38,"polymer_monomer_count_maximum":534,"polymer_monomer_count_minimum":47,"resolution_combined":[3.35],"selected_polymer_entity_types":"Protein (only)","software_programs_combined":["CTFFIND","EPU","GAUTOMATCH","PHENIX","RELION","UCSF CHIMERA"],"solvent_entity_count":0,"structure_determination_methodology":"experimental","structure_determination_methodology_priority":10},"rcsb_external_references":[{"id":"EMD-0262","link":"https://www.emdataresource.org/EMD-0262","type":"EM DATA RESOURCE"}],"rcsb_primary_citation":{"country":"US","id":"primary","journal_abbrev":"Nat. Struct. Mol. Biol.","journal_id_ISSN":"1545-9985","journal_volume":"26","page_first":"78","page_last":"83","pdbx_database_id_DOI":"10.1038/s41594-018-0172-z","pdbx_database_id_PubMed":30598554,"rcsb_ORCID_identifiers":["?","?","?","?","?","?","?","?"],"rcsb_authors":["Hartley, A.M.","Lukoyanova, N.","Zhang, Y.","Cabrera-Orefice, A.","Arnold, S.","Meunier, B.","Pinotsis, N.","Marechal, A."],"rcsb_journal_abbrev":"Nat Struct Mol Biol","title":"Structure of yeast cytochrome c oxidase in a supercomplex with cytochrome bc1.","year":2019},"refine_ls_restr":[{"dev_ideal":0.008,"number":64705,"pdbx_refine_id":"ELECTRON MICROSCOPY","type":"f_bond_d"},{"dev_ideal":1.069,"number":87763,"pdbx_refine_id":"ELECTRON MICROSCOPY","type":"f_angle_d"},{"dev_ideal":17.856,"number":38221,"pdbx_refine_id":"ELECTRON MICROSCOPY","type":"f_dihedral_angle_d"},{"dev_ideal":0.057,"number":9460,"pdbx_refine_id":"ELECTRON MICROSCOPY","type":"f_chiral_restr"},{"dev_ideal":0.008,"number":10892,"pdbx_refine_id":"ELECTRON MICROSCOPY","type":"f_plane_restr"}],"struct":{"pdbx_CASP_flag":"N","title":"III2-IV2 mitochondrial respiratory supercomplex from S. cerevisiae"},"struct_keywords":{"pdbx_keywords":"OXIDOREDUCTASE/ELECTRON TRANSPORT","text":"Cytochrome c oxidase Cytochrome bc1 Mitochondria Respiratory chain Supercomplex, OXIDOREDUCTASE, ELECTRON TRANSPORT, OXIDOREDUCTASE-ELECTRON TRANSPORT complex"},"rcsb_id":"6HU9"} \ No newline at end of file diff --git a/biojava-survival/pom.xml b/biojava-survival/pom.xml index a1facab532..8115693dfc 100644 --- a/biojava-survival/pom.xml +++ b/biojava-survival/pom.xml @@ -4,7 +4,7 @@ org.biojava biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-survival diff --git a/biojava-survival/src/main/java/org/biojava/nbio/survival/cox/CoxInfo.java b/biojava-survival/src/main/java/org/biojava/nbio/survival/cox/CoxInfo.java index c9f4f18056..eebdaf86ba 100644 --- a/biojava-survival/src/main/java/org/biojava/nbio/survival/cox/CoxInfo.java +++ b/biojava-survival/src/main/java/org/biojava/nbio/survival/cox/CoxInfo.java @@ -32,7 +32,7 @@ /** * Holds the results of a cox analysis where calling dump(), toString() will give an output similar to R - * @author Scooter Willis + * @author Scooter Willis */ public class CoxInfo { @@ -505,7 +505,7 @@ public String toString(String beginLine, String del, String endLine) { o = o + beginLine + endLine; - if (baselineSurvivorFunction.size() > 0) { + if (!baselineSurvivorFunction.isEmpty()) { o = o + beginLine + "Baseline Survivor Function (at predictor means)" + endLine; for (Double time : baselineSurvivorFunction.keySet()) { Double mean = baselineSurvivorFunction.get(time); diff --git a/biojava-survival/src/main/java/org/biojava/nbio/survival/cox/ResidualsCoxph.java b/biojava-survival/src/main/java/org/biojava/nbio/survival/cox/ResidualsCoxph.java index 42b34905cc..955a7c1f6d 100644 --- a/biojava-survival/src/main/java/org/biojava/nbio/survival/cox/ResidualsCoxph.java +++ b/biojava-survival/src/main/java/org/biojava/nbio/survival/cox/ResidualsCoxph.java @@ -29,7 +29,7 @@ /** * - * @author Scooter Willis + * @author Scooter Willis */ public class ResidualsCoxph { @@ -108,7 +108,7 @@ public static double[][] process(CoxInfo ci, Type type, boolean useWeighted, Arr double[] weighted = ci.getWeighted(); rr = Matrix.scale(rr, weighted); } - if (cluster != null && cluster.size() > 0) { + if (cluster != null && !cluster.isEmpty()) { rr = rowsum(rr, cluster); } diff --git a/biojava-survival/src/main/java/org/biojava/nbio/survival/data/WorkSheet.java b/biojava-survival/src/main/java/org/biojava/nbio/survival/data/WorkSheet.java index 542085942e..17ed18419a 100644 --- a/biojava-survival/src/main/java/org/biojava/nbio/survival/data/WorkSheet.java +++ b/biojava-survival/src/main/java/org/biojava/nbio/survival/data/WorkSheet.java @@ -27,7 +27,7 @@ * Need to handle very large spreadsheets of expression data so keep memory * footprint low * - * @author Scooter Willis + * @author Scooter Willis */ public class WorkSheet { @@ -1391,7 +1391,7 @@ static public WorkSheet unionWorkSheetsRowJoin(WorkSheet w1, WorkSheet w2, boole ArrayList joinedColumns = new ArrayList<>(); joinedColumns.addAll(w1DataColumns); joinedColumns.addAll(w2DataColumns); - if (!joinedColumns.contains("META_DATA") && (w1MetaDataColumns.size() > 0 || w2MetaDataColumns.size() > 0)) { + if (!joinedColumns.contains("META_DATA") && (!w1MetaDataColumns.isEmpty() || !w2MetaDataColumns.isEmpty())) { joinedColumns.add("META_DATA"); } for (String column : w1MetaDataColumns) { diff --git a/biojava-survival/src/main/java/org/biojava/nbio/survival/kaplanmeier/figure/NumbersAtRiskPanel.java b/biojava-survival/src/main/java/org/biojava/nbio/survival/kaplanmeier/figure/NumbersAtRiskPanel.java index c4578f1c8b..144942a809 100644 --- a/biojava-survival/src/main/java/org/biojava/nbio/survival/kaplanmeier/figure/NumbersAtRiskPanel.java +++ b/biojava-survival/src/main/java/org/biojava/nbio/survival/kaplanmeier/figure/NumbersAtRiskPanel.java @@ -76,7 +76,7 @@ private void paintTable(Graphics g) { sfiHashMap = sfi.getStrataInfoHashMap(); } - if(sfiHashMap.size() == 0) + if(sfiHashMap.isEmpty()) return; //int height = this.getHeight(); diff --git a/biojava-ws/pom.xml b/biojava-ws/pom.xml index 23866ccbb1..ebc18949ed 100644 --- a/biojava-ws/pom.xml +++ b/biojava-ws/pom.xml @@ -3,7 +3,7 @@ biojava org.biojava - 7.2.3 + 7.3.0-SNAPSHOT biojava-ws biojava-ws @@ -19,7 +19,7 @@ org.biojava biojava-core - 7.2.3 + 7.3.0-SNAPSHOT compile diff --git a/biojava-ws/src/main/java/org/biojava/nbio/ws/hmmer/HmmerResult.java b/biojava-ws/src/main/java/org/biojava/nbio/ws/hmmer/HmmerResult.java index 3304e78d4f..373b78cd0a 100644 --- a/biojava-ws/src/main/java/org/biojava/nbio/ws/hmmer/HmmerResult.java +++ b/biojava-ws/src/main/java/org/biojava/nbio/ws/hmmer/HmmerResult.java @@ -140,7 +140,7 @@ public int compareTo(HmmerResult o) { return(me.getSqFrom().compareTo(other.getSqFrom())); } private boolean emptyDomains(HmmerResult o) { - if ( o.getDomains() == null || o.getDomains().size() == 0) + if ( o.getDomains() == null || o.getDomains().isEmpty()) return true; return false; } diff --git a/pom.xml b/pom.xml index c4dfdd3b74..dbed771382 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ org.biojava biojava pom - 7.2.3 + 7.3.0-SNAPSHOT biojava BioJava is an open-source project dedicated to providing a Java framework for processing biological data. It provides analytical and statistical routines, parsers for common file formats and allows the @@ -41,7 +41,7 @@ 512M 1.0.11 2.0.12 - 2.23.1 + 2.25.5 5.10.1 ciftools-java 7.0.1 @@ -51,7 +51,7 @@ scm:git:git@github.com:biojava/biojava.git https://github.com/biojava/biojava - biojava-7.2.3 + HEAD @@ -325,12 +326,6 @@ 3.1.3 - - org.apache.maven.plugins - maven-javadoc-plugin - 3.11.2 - - org.apache.maven.plugins maven-site-plugin @@ -486,7 +481,7 @@ com.google.guava guava - 33.4.0-jre + 33.6.0-jre