sequence-formats 1.11.0.2 → 1.12.1.0
raw patch · 16 files changed
+265/−90 lines, 16 filesbinary-addedPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
+ SequenceFormats.Utils: decompressMultiMember :: forall (m :: Type -> Type) r. MonadIO m => Producer ByteString m r -> Producer ByteString m r
- SequenceFormats.VCF: vcfToFreqSumEntry :: MonadThrow m => VCFentry -> m FreqSumEntry
+ SequenceFormats.VCF: vcfToFreqSumEntry :: MonadThrow m => VCFentry -> m (Maybe FreqSumEntry)
Files
- Changelog.md +4/−21
- sequence-formats.cabal +7/−2
- src/SequenceFormats/Eigenstrat.hs +8/−4
- src/SequenceFormats/Plink.hs +63/−32
- src/SequenceFormats/Utils.hs +57/−5
- src/SequenceFormats/VCF.hs +15/−17
- test/SequenceFormats/EigenstratSpec.hs +13/−2
- test/SequenceFormats/PlinkSpec.hs +33/−2
- test/SequenceFormats/UtilsSpec.hs +32/−2
- test/SequenceFormats/VCFSpec.hs +30/−3
- testDat/example.gzipped.plink.bed binary
- testDat/example.multimember.vcf.gz binary
- testDat/example.short.plink.bed +1/−0
- testDat/example.truncated.plink.bed +1/−0
- testDat/example.truncated.plink.bed.gz binary
- testDat/example.wrongmagic.plink.bed +1/−0
Changelog.md view
@@ -1,5 +1,8 @@ # Changelog-+- V 1.12.1.0: Clearer errors for broken input files. Reading a Plink bed file now throws a `SeqFormatException` naming the file if it is too short for the header, has wrong magic bytes (with a hint if it looks gzip-compressed), is not in SNP-major mode, or ends with an incomplete SNP record (e.g. a fam file with the wrong number of individuals). Previously this gave cryptic attoparsec errors such as `Failed reading: satisfy`. Reading gzip files now throws a `SeqFormatException` for invalid gzip data (previously `ZlibException (-3)`), and for truncated gzip files, which were previously accepted without error, possibly silently losing data.+- V 1.12.0.0: Fixed reading of multi-member gzip files, such as BGZF-compressed VCFs written by bgzip, bcftools or GATK. Previously only the first gzip member was read, leading to parsing errors or silently truncated data. Breaking change: `vcfToFreqSumEntry` now returns `Maybe FreqSumEntry`, with `Nothing` for indels, multi-allelic sites and non-nucleotide alleles (instead of throwing an exception at the first indel). Writing Eigenstrat SNP and Plink BIM files now throws an exception on empty SNP IDs or IDs containing whitespace, as these cannot be read back. Clients converting from VCF or FreqSum need to provide IDs for sites without one (e.g. `Chrom_Pos`).+- V 1.11.0.4: improved parsing error messages.+- V 1.11.0.3: added dots to allowed characters in Plink files - V 1.11.0.2: exposed parseSex from Eigenstrat - V 1.11.0.1: Allowing missing alternative alleles when converting VCF to FreqSum, and improved error messaging. - V 1.11.0.0: Added support for writing of VCF files, including gzipping. Made some breaking API changes on top, for example@@ -50,23 +53,3 @@ - V 1.1.5: Fixed VCF parser: Now breaks if lines end prematurely - V 1.1.4.2: Exporting readVCFfromProd - V 1.1.4.1: First entry in the Changelog. Added Haddock documentation to all modules and prepare for releasing on Hackage.--------------------
sequence-formats.cabal view
@@ -1,6 +1,6 @@ cabal-version: >=1.10 name: sequence-formats-version: 1.11.0.2+version: 1.12.1.0 license: GPL-3 license-file: LICENSE maintainer: stephan.schiffels@mac.com@@ -32,10 +32,16 @@ testDat/example.plink.bim testDat/example.plink.bim.gz testDat/example.plink.fam+ testDat/example.short.plink.bed+ testDat/example.wrongmagic.plink.bed+ testDat/example.gzipped.plink.bed+ testDat/example.truncated.plink.bed+ testDat/example.truncated.plink.bed.gz testDat/example.snp testDat/example.snp.gz testDat/example.vcf testDat/example.vcf.gz+ testDat/example.multimember.vcf.gz library exposed-modules:@@ -67,7 +73,6 @@ pipes-safe >=2.3.5, pipes-attoparsec >=0.6.0, vector >=0.13.1.0,- pipes-zlib >=0.4.4.2, streaming-commons >=0.2.2.6 test-suite sequenceFormatTests
src/SequenceFormats/Eigenstrat.hs view
@@ -20,12 +20,13 @@ import Control.Applicative ((<|>)) import Control.Exception (throw)-import Control.Monad (forM_, void)-import Control.Monad.Catch (MonadThrow)+import Control.Monad (forM_, void, when)+import Control.Monad.Catch (MonadThrow, throwM) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.Trans.Class (lift) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B+import Data.Char (isSpace) import Data.List (isSuffixOf) import qualified Data.Streaming.Zlib as Z import Data.Vector (Vector, fromList, toList)@@ -177,10 +178,13 @@ return $ gzipConsumer def snpFileH else return $ PB.toHandle snpFileH- let toTextPipe = P.map (\(EigenstratSnpEntry chrom pos gpos gid ref alt) ->+ let toTextPipe = P.mapM (\(EigenstratSnpEntry chrom pos gpos gid ref alt) -> do+ when (B.null gid || B.any isSpace gid) . throwM . SeqFormatException $+ "invalid SNP ID " ++ show gid ++ " at " ++ show chrom ++ ":" ++ show pos +++ ". SNP IDs must be non-empty and must not contain whitespace" let snpLine = B.intercalate "\t" [gid, unChrom chrom, B.pack (show gpos), B.pack (show pos), B.singleton ref, B.singleton alt]- in snpLine <> "\n")+ return $ snpLine <> "\n") toTextPipe >-> snpOutTextConsumer -- |Function to write an Eigentrat Geno File. Returns a consumer expecting Eigenstrat Genolines.
src/SequenceFormats/Plink.hs view
@@ -16,35 +16,39 @@ EigenstratSnpEntry (..), GenoEntry (..), GenoLine, Sex (..))-import SequenceFormats.Utils (Chrom (..), consumeProducer,+import SequenceFormats.Utils (Chrom (..),+ SeqFormatException (..),+ consumeProducer, deflateFinaliser, gzipConsumer, readFileProdCheckCompress, word, writeFromPopper) import Control.Applicative ((<|>))-import Control.Monad (forM_, void)+import Control.Monad (forM_, void, when) import Control.Monad.Catch (MonadThrow, throwM) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.Trans.Class (lift)-import Control.Monad.Trans.State.Strict (runStateT) import qualified Data.Attoparsec.ByteString as AB import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bits (shiftL, shiftR, (.&.), (.|.)) import qualified Data.ByteString as BB import qualified Data.ByteString.Char8 as B+import Data.Char (isSpace) import Data.List (isSuffixOf) import qualified Data.Streaming.Zlib as Z import Data.Vector (fromList, toList) import Data.Word (Word8) import Pipes (Consumer, Producer, (>->))-import Pipes.Attoparsec (ParsingError (..), parse)+import Lens.Family2 (view)+import Pipes.Attoparsec (parsed) import qualified Pipes.ByteString as PB import qualified Pipes.Prelude as P import Pipes.Safe (MonadSafe, register) import qualified Pipes.Safe.Prelude as PS import System.IO (IOMode (..), withFile)+import Text.Printf (printf) -- see https://www.cog-genomics.org/plink/2.0/formats#fam data PlinkFamEntry = PlinkFamEntry {@@ -64,19 +68,21 @@ snpId_ <- A.skipMany1 A.space >> word geneticPos <- A.skipMany1 A.space >> A.double pos <- A.skipMany1 A.space >> A.decimal- ref <- A.skipMany1 A.space >> A.satisfy (A.inClass "ACTGNX01234")- alt <- A.skipMany1 A.space >> A.satisfy (A.inClass "ACTGNX01234")+ ref <- A.skipMany1 A.space >> A.satisfy (A.inClass "ACTGNX01234.")+ alt <- A.skipMany1 A.space >> A.satisfy (A.inClass "ACTGNX01234.") void A.endOfLine- let refConvert = convertNum ref- altConvert = convertNum alt+ let refConvert = convertChar ref+ altConvert = convertChar alt return $ EigenstratSnpEntry (Chrom chrom) pos geneticPos snpId_ refConvert altConvert where- convertNum '0' = 'N'- convertNum '1' = 'A'- convertNum '2' = 'C'- convertNum '3' = 'G'- convertNum '4' = 'T'- convertNum x = x+ convertChar '0' = 'N'+ convertChar '1' = 'A'+ convertChar '2' = 'C'+ convertChar '3' = 'G'+ convertChar '4' = 'T'+ convertChar 'X' = 'N'+ convertChar '.' = 'N'+ convertChar x = x famParser :: A.Parser PlinkFamEntry famParser = do@@ -111,16 +117,33 @@ PlinkPopNameAsPhenotype -> PlinkFamEntry "DummyFamily" indId "0" "0" sex popName PlinkPopNameAsBoth -> PlinkFamEntry popName indId "0" "0" sex popName -bedHeaderParser :: AB.Parser ()-bedHeaderParser = do- void $ AB.word8 0b01101100 -- magic number I for BED files- void $ AB.word8 0b00011011 -- magic number II for BED files- void $ AB.word8 0b00000001 -- we can only parse SNP-major order+-- |Checks the three magic bytes at the start of a bed file, see+-- https://www.cog-genomics.org/plink/1.9/formats#bed+checkBedHeader :: (MonadThrow m) => FilePath -> B.ByteString -> m ()+checkBedHeader file header+ | BB.length header < 3 = throwM . SeqFormatException $+ "Plink bed file " ++ file ++ " is too short (" ++ show (BB.length header) +++ " bytes) to contain the 3-byte bed header. The file seems to be empty or truncated"+ | BB.take 2 header /= BB.pack [0b01101100, 0b00011011] = throwM . SeqFormatException $+ "Plink bed file " ++ file ++ " does not start with the magic bytes 0x6c 0x1b (found " +++ showBytes (BB.take 2 header) ++ "). It does not seem to be a Plink bed file" ++ gzipHint+ | BB.index header 2 /= 0b00000001 = throwM . SeqFormatException $+ "Plink bed file " ++ file ++ " is not in SNP-major mode (third byte is " +++ showBytes (BB.drop 2 header) ++ " instead of 0x01). Only SNP-major bed files are supported"+ | otherwise = return ()+ where+ showBytes = unwords . map (\b -> printf "0x%02x" b) . BB.unpack+ gzipHint = if BB.take 2 header == BB.pack [0x1f, 0x8b] && not (".gz" `isSuffixOf` file)+ then ". It looks gzip-compressed, but its name does not end in .gz"+ else "" +-- |The number of bytes per SNP in a bed file, with 2 bits per individual.+bedRecordBytes :: Int -> Int+bedRecordBytes nrInds = (nrInds + 3) `quot` 4+ bedGenotypeParser :: Int -> AB.Parser GenoLine bedGenotypeParser nrInds = do- let nrBytes = if nrInds `rem` 4 == 0 then nrInds `quot` 4 else (nrInds `quot` 4) + 1- bytes <- BB.unpack <$> AB.take nrBytes+ bytes <- BB.unpack <$> AB.take (bedRecordBytes nrInds) let indBitPairs = concatMap getBitPairs bytes return . fromList . take nrInds . map bitPairToGenotype $ indBitPairs where@@ -131,18 +154,23 @@ bitPairToGenotype 0b00000001 = Missing bitPairToGenotype _ = error "This should never happen" -readPlinkBedProd :: (MonadThrow m) => Int -> Producer B.ByteString m () -> m (Producer GenoLine m ())-readPlinkBedProd nrInds prod = do- (res, rest) <- runStateT (parse bedHeaderParser) prod- _ <- case res of- Nothing -> throwM $ ParsingError [] "Bed file exhausted prematurely"- Just (Left e) -> throwM e- Just (Right h) -> return h- return $ consumeProducer (bedGenotypeParser nrInds) rest+readPlinkBedProd :: (MonadThrow m) => FilePath -> Int -> Producer B.ByteString m () -> m (Producer GenoLine m ())+readPlinkBedProd file nrInds prod = do+ (headerChunks, rest) <- P.toListM' $ view (PB.splitAt (3 :: Int)) prod+ checkBedHeader file (BB.concat headerChunks)+ return $ parsed (bedGenotypeParser nrInds) rest >>= either reportTruncated return+ where+ -- the genotype parser can only fail if the input ends in the middle of a SNP record+ reportTruncated (_, leftovers) = do+ nrLeftoverBytes <- lift $ P.fold (\n c -> n + BB.length c) 0 id leftovers+ throwM . SeqFormatException $ "Plink bed file " ++ file ++ " ends with an incomplete SNP " +++ "record (" ++ show nrLeftoverBytes ++ " bytes left over, but each SNP takes " +++ show (bedRecordBytes nrInds) ++ " bytes for " ++ show nrInds ++ " individuals). " +++ "Either the bed file is truncated or it does not match the number of individuals in the fam file" -- |A function to read a bed file from a file. Returns a Producer over all lines. readPlinkBedFile :: (MonadSafe m) => FilePath -> Int -> m (Producer GenoLine m ())-readPlinkBedFile file nrInds = readPlinkBedProd nrInds . readFileProdCheckCompress $ file+readPlinkBedFile file nrInds = readPlinkBedProd file nrInds . readFileProdCheckCompress $ file -- |Function to read a Bim File from StdIn. Returns a Pipes-Producer over the EigenstratSnpEntries. readBimStdIn :: (MonadThrow m, MonadIO m) => Producer EigenstratSnpEntry m ()@@ -181,10 +209,13 @@ return $ gzipConsumer def bimFileH else return $ PB.toHandle bimFileH- let toTextPipe = P.map (\(EigenstratSnpEntry chrom pos gpos gid ref alt) ->+ let toTextPipe = P.mapM (\(EigenstratSnpEntry chrom pos gpos gid ref alt) -> do+ when (B.null gid || B.any isSpace gid) . throwM . SeqFormatException $+ "invalid SNP ID " ++ show gid ++ " at " ++ show chrom ++ ":" ++ show pos +++ ". SNP IDs must be non-empty and must not contain whitespace" let bimLine = B.intercalate "\t" [unChrom chrom, gid, B.pack (show gpos), B.pack (show pos), B.singleton ref, B.singleton alt]- in bimLine <> "\n")+ return $ bimLine <> "\n") toTextPipe >-> bimOutTextConsumer -- |Function to write a Plink Fam file.
src/SequenceFormats/Utils.hs view
@@ -4,11 +4,13 @@ module SequenceFormats.Utils (liftParsingErrors, consumeProducer, readFileProd, readFileProdCheckCompress,+ decompressMultiMember, SeqFormatException(..), deflateFinaliser, Chrom(..), word, gzipConsumer, writeFromPopper, Z.Deflate) where import Control.Error (readErr) import Control.Exception (Exception, throw, throwIO)+import Control.Monad (unless) import Control.Monad.Catch (MonadThrow, throwM) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.Trans.Class (lift)@@ -18,18 +20,20 @@ import Data.List (isSuffixOf) import qualified Data.Streaming.Zlib as Z import Pipes (Consumer, Producer, await,- next)+ next, yield) import Pipes.Attoparsec (ParsingError (..), parsed) import qualified Pipes.ByteString as PB-import Pipes.GZip (decompress) import qualified Pipes.Safe as PS import qualified Pipes.Safe.Prelude as PS import System.IO (Handle, IOMode (..)) -- |An exception type for parsing BioInformatic file formats. data SeqFormatException = SeqFormatException String- deriving (Show, Eq)+ deriving (Eq) +instance Show SeqFormatException where+ show (SeqFormatException msg) = "SeqFormatException: " ++ msg+ instance Exception SeqFormatException -- |A wrapper datatype for Chromosome names.@@ -68,7 +72,8 @@ x <- lift $ next restProd case x of Right (chunk, _) -> do- let msg' = "Error while parsing: " <> msg <> ". Error occurred when trying to parse this chunk: " ++ show chunk+ let firstLine = B.unpack . B.takeWhile (/= '\n') $ chunk+ msg' = "Error while parsing: " <> msg <> ". Offending line: " ++ firstLine throwM $ SeqFormatException msg' Left _ -> error "should not happen" Right () -> return ()@@ -82,8 +87,55 @@ readFileProdCheckCompress :: (PS.MonadSafe m) => FilePath -> Producer B.ByteString m () readFileProdCheckCompress f =- let decompressFunc = if ".gz" `isSuffixOf` f then decompress else id+ let decompressFunc = if ".gz" `isSuffixOf` f then decompressGzip ("gzip file " ++ f) else id in decompressFunc $ PS.withFile f ReadMode PB.fromHandle++-- |Decompresses a gzip stream that may consist of multiple concatenated gzip members, as is the+-- case for BGZF files written by bgzip, bcftools or GATK. Pipes.GZip.decompress on its own stops+-- after the first member and silently drops the rest of the input. Throws a SeqFormatException+-- if the input is not valid gzip data, or if it ends in the middle of a gzip member (e.g. a+-- truncated file), which Pipes.GZip.decompress would silently accept.+decompressMultiMember :: (MonadIO m) => Producer B.ByteString m r -> Producer B.ByteString m r+decompressMultiMember = decompressGzip "gzip stream"++-- |Like decompressMultiMember, but takes a description of the input (e.g. the file name) for+-- error messages.+decompressGzip :: (MonadIO m) => String -> Producer B.ByteString m r -> Producer B.ByteString m r+decompressGzip descr = newMember True+ where+ newMember isFirst prod = liftIO (Z.initInflate (Z.WindowBits 31)) >>= go isFirst False prod+ -- hasInput tracks whether the current member has received any bytes yet, so that we can+ -- tell a clean end of input (after a completed member) from a truncated member.+ go isFirst hasInput prod inf = do+ res <- lift (next prod)+ case res of+ Left r -> do+ complete <- liftIO (Z.isCompleteInflate inf)+ if complete || (not hasInput && not isFirst) then return r else+ liftIO . throwIO . SeqFormatException $ if hasInput+ then descr ++ " ended unexpectedly. The file seems to be truncated"+ else descr ++ " is empty, which is not valid gzip"+ Right (bs, prod')+ | B.null bs -> go isFirst hasInput prod' inf+ | otherwise -> do+ popper <- liftIO (Z.feedInflate inf bs)+ yieldPopper popper+ rest <- liftIO (Z.flushInflate inf)+ unless (B.null rest) (yield rest)+ complete <- liftIO (Z.isCompleteInflate inf)+ if complete then do+ leftover <- liftIO (Z.getUnusedInflate inf)+ newMember False (yield leftover >> prod')+ else+ go isFirst True prod' inf+ yieldPopper popper = do+ popRes <- liftIO popper+ case popRes of+ Z.PRDone -> return ()+ Z.PRNext bs -> yield bs >> yieldPopper popper+ Z.PRError (Z.ZlibException code) -> liftIO . throwIO . SeqFormatException $+ "could not decompress " ++ descr ++ " (zlib error code " ++ show code +++ "). The file seems to be corrupt or not gzip-compressed" word :: A.Parser B.ByteString word = A.takeTill isSpace
src/SequenceFormats/VCF.hs view
@@ -28,13 +28,14 @@ import Control.Applicative ((<|>)) import Control.Error (atErr)-import Control.Monad (forM, unless, void)+import Control.Monad (forM, void) import Control.Monad.Catch (MonadThrow, throwM) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.State.Strict (runStateT) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B+import Data.Char (isAlpha) import Data.List (isSuffixOf) import Data.Maybe (fromMaybe) import qualified Data.Streaming.Zlib as Z@@ -118,7 +119,7 @@ parseFilter = (parseDot *> pure Nothing) <|> (Just <$> word) parseInfoFields = (parseDot *> pure []) <|> (parseInfoField `A.sepBy1` A.char ';') parseInfoField = A.takeTill (\c -> c == ';' || c == '\t')- parseFormatStringsAndGenotypes = (\f g -> Just (f, g)) <$> parseFormatStrings <* sp <*> parseGenotypeInfos + parseFormatStringsAndGenotypes = (\f g -> Just (f, g)) <$> parseFormatStrings <* sp <*> parseGenotypeInfos parseFormatStrings = parseFormatString `A.sepBy1` A.char ':' parseFormatString = A.takeTill (\c -> c == ':' || c == '\t') parseGenotypeInfos = parseGenotype `A.sepBy1` sp@@ -173,21 +174,18 @@ ["1", "1"] -> return $ Just (2, 2) _ -> return Nothing --- |Converts a VCFentry to the simpler FreqSum format-vcfToFreqSumEntry :: (MonadThrow m) => VCFentry -> m FreqSumEntry-vcfToFreqSumEntry vcfEntry = do- unless (B.length (vcfRef vcfEntry) == 1) . throwM . SeqFormatException $- "multi-site reference allele at " ++ show vcfEntry- alt <- case vcfAlt vcfEntry of- [] -> return 'N'- (a:_) -> if B.length a /= 1- then- throwM . SeqFormatException $ "multi-site alternative allele at " ++ show vcfEntry- else- return $ B.head a- let ref = B.head (vcfRef vcfEntry)- dosages <- getDosages vcfEntry- return $ FreqSumEntry (vcfChrom vcfEntry) (vcfPos vcfEntry) (vcfId vcfEntry) Nothing ref alt dosages+-- |Converts a VCFentry to the simpler FreqSum format. Returns Nothing for sites that cannot be represented+-- as a biallelic SNP: indels and other multi-base alleles, multi-allelic sites and non-nucleotide alleles such+-- as the spanning deletion allele *. Sites without an alternative allele are kept, with alternative allele N.+vcfToFreqSumEntry :: (MonadThrow m) => VCFentry -> m (Maybe FreqSumEntry)+vcfToFreqSumEntry vcfEntry = case (vcfRef vcfEntry, vcfAlt vcfEntry) of+ (ref, []) | isSingleBase ref -> Just <$> makeEntry (B.head ref) 'N'+ (ref, [alt]) | isSingleBase ref && isSingleBase alt -> Just <$> makeEntry (B.head ref) (B.head alt)+ _ -> return Nothing+ where+ isSingleBase a = B.length a == 1 && isAlpha (B.head a)+ makeEntry ref alt = FreqSumEntry (vcfChrom vcfEntry) (vcfPos vcfEntry) (vcfId vcfEntry) Nothing ref alt <$>+ getDosages vcfEntry printVCFtoStdOut :: (MonadIO m) => VCFheader -> Consumer VCFentry m () printVCFtoStdOut vcfh = do
test/SequenceFormats/EigenstratSpec.hs view
@@ -3,6 +3,7 @@ import Control.Foldl (list, purely) import Control.Monad.IO.Class (liftIO)+import Data.List (isPrefixOf) import Data.Vector (fromList) import Pipes (each, runEffect, (>->)) import qualified Pipes.Prelude as P@@ -10,8 +11,10 @@ import SequenceFormats.Eigenstrat (EigenstratIndEntry (..), EigenstratSnpEntry (..), GenoEntry (..), GenoLine, Sex (..),- readEigenstrat, writeEigenstrat)-import SequenceFormats.Utils (Chrom (..))+ readEigenstrat, writeEigenstrat,+ writeEigenstratSnp)+import SequenceFormats.Utils (Chrom (..),+ SeqFormatException (..)) import Test.Hspec spec :: Spec@@ -20,6 +23,7 @@ testReadEigenstratCompressed testWriteEigenstrat testWriteEigenstratCompressed+ testWriteEigenstratSnpInvalidId mockDatEigenstratSnp :: [EigenstratSnpEntry] mockDatEigenstratSnp = [@@ -107,3 +111,10 @@ snpGenoEntries <- liftIO . runSafeT $ purely P.fold list esProd (map fst snpGenoEntries) `shouldBe` mockDatEigenstratSnp (map snd snpGenoEntries) `shouldBe` mockDatEigenstratGeno++testWriteEigenstratSnpInvalidId :: Spec+testWriteEigenstratSnpInvalidId = describe "writeEigenstratSnp" $+ it "should throw on empty SNP IDs" $ do+ let badSnps = [EigenstratSnpEntry (Chrom "11") 0 0.0 "rs0000" 'A' 'C', EigenstratSnpEntry (Chrom "11") 100000 0.001 "" 'A' 'G']+ runSafeT (runEffect (each badSnps >-> writeEigenstratSnp "/tmp/invalidIdTest.snp")) `shouldThrow`+ (\(SeqFormatException msg) -> "invalid SNP ID \"\" at 11:100000" `isPrefixOf` msg)
test/SequenceFormats/PlinkSpec.hs view
@@ -10,11 +10,13 @@ plinkFam2EigenstratInd, readBimFile, readFamFile, readPlink, readPlinkBedFile,- writePlink)-import SequenceFormats.Utils (Chrom (..))+ writeBim, writePlink)+import SequenceFormats.Utils (Chrom (..),+ SeqFormatException (..)) import Control.Foldl (list, purely) import Control.Monad.IO.Class (liftIO)+import Data.List (isInfixOf, isPrefixOf) import Data.Vector (fromList) import Pipes (each, runEffect, (>->)) import qualified Pipes.Prelude as P@@ -28,8 +30,10 @@ testReadFamFile testReadBedFile testReadBedFileCompressed+ testReadBedFileInvalid testReadPlink testWritePlink+ testWriteBimInvalidId testWritePlinkCompressed testFam2Ind testInd2Fam@@ -97,6 +101,26 @@ purely P.fold list bedProd bedDat `shouldBe` mockDatPlinkBed +testReadBedFileInvalid :: Spec+testReadBedFileInvalid = describe "readBedFile with invalid files" $ do+ let readBed fn = runSafeT $ readPlinkBedFile fn 5 >>= P.length+ throwsWith substr (SeqFormatException msg) = substr `isInfixOf` msg+ it "should report a file too short for the header" $+ readBed "testDat/example.short.plink.bed" `shouldThrow`+ throwsWith "is too short (2 bytes) to contain the 3-byte bed header"+ it "should report wrong magic bytes" $+ readBed "testDat/example.wrongmagic.plink.bed" `shouldThrow`+ throwsWith "does not start with the magic bytes 0x6c 0x1b (found 0x74 0x68)"+ it "should report a gzipped file without .gz ending" $+ readBed "testDat/example.gzipped.plink.bed" `shouldThrow`+ throwsWith "It looks gzip-compressed, but its name does not end in .gz"+ it "should report an incomplete SNP record" $+ readBed "testDat/example.truncated.plink.bed" `shouldThrow`+ throwsWith "ends with an incomplete SNP record (1 bytes left over, but each SNP takes 2 bytes for 5 individuals)"+ it "should report a truncated gzip file" $+ readBed "testDat/example.truncated.plink.bed.gz" `shouldThrow`+ throwsWith "gzip file testDat/example.truncated.plink.bed.gz ended unexpectedly"+ testReadPlink :: Spec testReadPlink = describe "readPlink" $ do it "should read the correct Plink files" $ do@@ -172,3 +196,10 @@ let es = EigenstratIndEntry "SAMPLE0" Female "Pop1" fam = PlinkFamEntry "Pop1" "SAMPLE0" "0" "0" Female "Pop1" eigenstratInd2PlinkFam PlinkPopNameAsBoth es `shouldBe` fam++testWriteBimInvalidId :: Spec+testWriteBimInvalidId = describe "writeBim" $+ it "should throw on SNP IDs with whitespace" $ do+ let badSnps = [EigenstratSnpEntry (Chrom "11") 0 0.0 "rs0000" 'A' 'C', EigenstratSnpEntry (Chrom "11") 100000 0.001 "rs 1111" 'A' 'G']+ runSafeT (runEffect (each badSnps >-> writeBim "/tmp/invalidIdTest.bim")) `shouldThrow`+ (\(SeqFormatException msg) -> "invalid SNP ID \"rs 1111\" at 11:100000" `isPrefixOf` msg)
test/SequenceFormats/UtilsSpec.hs view
@@ -1,13 +1,20 @@ {-# LANGUAGE OverloadedStrings #-} module SequenceFormats.UtilsSpec (spec) where -import SequenceFormats.Utils (Chrom (..), SeqFormatException (..))+import SequenceFormats.Utils (Chrom (..), SeqFormatException (..),+ decompressMultiMember) import Control.Exception (evaluate)+import qualified Data.ByteString.Char8 as B+import Pipes (each, yield)+import Pipes.GZip (compress, defaultCompression)+import qualified Pipes.Prelude as P import Test.Hspec spec :: Spec-spec = testChrom+spec = do+ testChrom+ testDecompressMultiMember testChrom :: Spec testChrom = describe "Chrom" $ do@@ -33,3 +40,26 @@ evaluate (Chrom "chrSSS" < Chrom "chrMT") `shouldThrow` (==SeqFormatException "cannot parse chromosome SSS") ++testDecompressMultiMember :: Spec+testDecompressMultiMember = describe "decompressMultiMember" $ do+ let gz bs = P.fold (<>) B.empty id (compress defaultCompression (yield bs))+ expected = "first line\nsecond line\nthird line\n"+ members <- runIO $ mapM gz ["first line\n", "second line\n", "third line\n"]+ it "decompresses all members if chunks align with member boundaries" $ do+ out <- P.fold (<>) B.empty id (decompressMultiMember (each members))+ out `shouldBe` expected+ it "decompresses all members if chunks do not align with member boundaries" $ do+ let (a, b) = B.splitAt 25 (B.concat members)+ out <- P.fold (<>) B.empty id (decompressMultiMember (each [a, b]))+ out `shouldBe` expected+ it "throws on input that is not gzip-compressed" $+ P.fold (<>) B.empty id (decompressMultiMember (yield "this is not gzip\n")) `shouldThrow`+ (== SeqFormatException "could not decompress gzip stream (zlib error code -3). The file seems to be corrupt or not gzip-compressed")+ it "throws on a truncated gzip member" $ do+ let truncated = B.take (B.length (B.concat members) - 5) (B.concat members)+ P.fold (<>) B.empty id (decompressMultiMember (yield truncated)) `shouldThrow`+ (== SeqFormatException "gzip stream ended unexpectedly. The file seems to be truncated")+ it "throws on empty input" $+ P.fold (<>) B.empty id (decompressMultiMember (yield B.empty)) `shouldThrow`+ (== SeqFormatException "gzip stream is empty, which is not valid gzip")
test/SequenceFormats/VCFSpec.hs view
@@ -24,6 +24,7 @@ testParseVCFheader testReadVCFfromFile testReadVCFfromFileCompressed+ testReadVCFfromFileMultiMember testGetGenotypes testGetDosages testIsTransversionSnp@@ -69,6 +70,19 @@ vcfRows !! 0 `shouldBe` vcf1 vcfRows !! 6 `shouldBe` vcf7 +testReadVCFfromFileMultiMember :: Spec+testReadVCFfromFileMultiMember = describe "readVCFfromFile with multi-member gzip (as in BGZF)" $ do+ (vcfH, vcfRows) <- runIO . runSafeT $ do+ (vcfH_, vcfProd_) <- readVCFfromFile "testDat/example.multimember.vcf.gz"+ vcfRows_ <- purely P.fold list vcfProd_+ return (vcfH_, vcfRows_)+ it "reads the correct sample names" $+ vcfSampleNames vcfH `shouldBe` ["12880A", "12881A", "12883A", "12884A", "12885A"]+ it "reads all vcf genotype rows" $ do+ length vcfRows `shouldBe` 7+ vcfRows !! 0 `shouldBe` vcf1+ vcfRows !! 6 `shouldBe` vcf7+ vcf1 :: VCFentry vcf1 = let gfields = Just (["GT", "PL"], [["0/0", "0,3,37"], ["0/0", "0,6,67"], ["0/1", "51,0,28"], ["0/0", "0,54,255"], ["0/0", "0,9,83"]])@@ -112,10 +126,23 @@ isTransversionSnp "C" ["G"] `shouldBe` True testVcfToFreqsumEntry :: Spec-testVcfToFreqsumEntry = describe "vcfToFreqsumEntry" $- it "should convert correctly" $ do+testVcfToFreqsumEntry = describe "vcfToFreqSumEntry" $ do+ it "should convert biallelic SNPs" $ do let r = FreqSumEntry (Chrom "1") 10492 (Just "testId") Nothing 'C' 'T' [Just (0, 2), Just (0, 2), Just (1, 2), Just (0, 2), Just (0, 2)]- vcfToFreqSumEntry vcf1 `shouldReturn` r+ vcfToFreqSumEntry vcf1 `shouldReturn` Just r+ it "should convert sites without alternative allele and keep missing SNP IDs as Nothing" $ do+ let r = FreqSumEntry (Chrom "2") 30923 Nothing Nothing 'G' 'N' [Just (2, 2), Just (2, 2), Just (2, 2), Just (2, 2), Just (2, 2)]+ vcfToFreqSumEntry vcf7 `shouldReturn` Just r+ it "should skip deletions" $+ vcfToFreqSumEntry vcf1 {vcfRef = "CT"} `shouldReturn` Nothing+ it "should skip insertions" $+ vcfToFreqSumEntry vcf1 {vcfAlt = ["CA"]} `shouldReturn` Nothing+ it "should skip multi-allelic sites" $+ vcfToFreqSumEntry vcf1 {vcfAlt = ["T", "G"]} `shouldReturn` Nothing+ it "should skip spanning deletion alleles" $+ vcfToFreqSumEntry vcf1 {vcfAlt = ["*"]} `shouldReturn` Nothing+ it "should still throw if genotypes are missing" $+ vcfToFreqSumEntry vcf1bad `shouldThrow` (== SeqFormatException "GT format field not found") testIsBiallelicSnp :: Spec testIsBiallelicSnp = describe "isBiallelicSnp" $ do
+ testDat/example.gzipped.plink.bed view
binary file changed (absent → 56 bytes)
+ testDat/example.multimember.vcf.gz view
binary file changed (absent → 2328 bytes)
+ testDat/example.short.plink.bed view
@@ -0,0 +1,1 @@+l
+ testDat/example.truncated.plink.bed view
@@ -0,0 +1,1 @@+l
+ testDat/example.truncated.plink.bed.gz view
binary file changed (absent → 52 bytes)
+ testDat/example.wrongmagic.plink.bed view
@@ -0,0 +1,1 @@+this is not a bed file