packages feed

dataframe-csv-2.3.1.1: src/DataFrame/IO/CSV.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Strict single-pass RFC 4180 CSV reading and writing for dataframes.
module DataFrame.IO.CSV (
    -- * Reading
    readCsv,
    readTsv,
    readCsvWithOpts,
    readSeparated,
    readCsvWithSchema,
    CsvReader,
    CsvBytesReader,
    schemaReadOptions,
    decodeSeparated,
    decodeSeparatedStrict,
    fromCsv,
    fromCsvBytes,

    -- * Options
    module DataFrame.IO.CSV.Internal.Options,

    -- * Writing
    writeCsv,
    writeTsv,
    writeSeparated,

    -- * Helpers
    stripQuotes,
) where

import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import qualified Data.Map.Strict as M
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO.Utf8 as TIO

import Control.Exception (SomeException, catch)
import Data.Maybe (fromMaybe)
import DataFrame.IO.CSV.Internal.Options
import DataFrame.IO.CSV.Internal.Read (decodeCsvStrict)
import DataFrame.Internal.DataFrame (DataFrame (..), toSeparated)
import DataFrame.Schema (Schema, elements)

-- | Read a CSV file from a path into a dataframe.
readCsv :: FilePath -> IO DataFrame
readCsv = readSeparated defaultReadOptions

-- | A reader the lazy scan can drive, such as 'readSeparated'.
type CsvReader = ReadOptions -> FilePath -> IO DataFrame

type CsvBytesReader = ReadOptions -> BS.ByteString -> IO DataFrame

-- | Read options derived from a 'Schema', which sets types and columns.
schemaReadOptions :: Schema -> ReadOptions
schemaReadOptions schema =
    defaultReadOptions
        { typeSpec =
            SpecifyTypes (M.toList (elements schema)) (typeSpec defaultReadOptions)
        , readColumns = Just (M.keys (elements schema))
        }

-- | Read a CSV, coercing columns to the types declared in a 'Schema'.
readCsvWithSchema :: Schema -> FilePath -> IO DataFrame
readCsvWithSchema schema =
    readSeparated
        defaultReadOptions
            { typeSpec =
                SpecifyTypes
                    (M.toList (elements schema))
                    (typeSpec defaultReadOptions)
            }

-- | Read a CSV file from a path using the given 'ReadOptions'.
readCsvWithOpts :: ReadOptions -> FilePath -> IO DataFrame
readCsvWithOpts = readSeparated

-- | Read a tab-separated file into a dataframe.
readTsv :: FilePath -> IO DataFrame
readTsv = readSeparated (defaultReadOptions{columnSeparator = '\t'})

-- | Read a text file with a chosen delimiter into a dataframe.
readSeparated :: ReadOptions -> FilePath -> IO DataFrame
readSeparated opts path = do
    validateReadOptions opts
    let stripUtf8Bom b = fromMaybe b (BS.stripPrefix "\xEF\xBB\xBF" b)
    csvData <- stripUtf8Bom <$> BS.readFile path
    decodeSeparatedStrict opts csvData

-- | Decode in-memory CSV bytes into a fully forced dataframe.
decodeSeparated :: ReadOptions -> BL.ByteString -> IO DataFrame
decodeSeparated opts csvData = decodeSeparatedStrict opts (BL.toStrict csvData)

-- | Decode a strict in-memory CSV buffer into a fully forced dataframe.
decodeSeparatedStrict :: CsvBytesReader
decodeSeparatedStrict = decodeCsvStrict

-- | Write a dataframe to a comma-separated file.
writeCsv :: FilePath -> DataFrame -> IO ()
writeCsv = writeSeparated ','

-- | Write a dataframe to a tab-separated file.
writeTsv :: FilePath -> DataFrame -> IO ()
writeTsv = writeSeparated '\t'

-- | Write a dataframe to a file using the given field separator.
writeSeparated ::
    -- | Separator
    Char ->
    -- | Path to write to
    FilePath ->
    DataFrame ->
    IO ()
writeSeparated c filepath df = TIO.writeFile filepath (toSeparated c df)

-- | Parse a CSV string into a DataFrame using default options.
fromCsv :: String -> IO (Either String DataFrame)
fromCsv s = do
    let bs = BL.fromStrict (TE.encodeUtf8 (T.pack s))
    (Right <$> decodeSeparated defaultReadOptions bs)
        `catch` (\(e :: SomeException) -> pure (Left (show e)))

-- | Parse a lazy 'ByteString' of CSV data using default options.
fromCsvBytes :: BL.ByteString -> IO DataFrame
fromCsvBytes = decodeSeparated defaultReadOptions

-- | Strip one layer of double-quote delimiters from a field, if present.
stripQuotes :: T.Text -> T.Text
stripQuotes txt =
    case T.uncons txt of
        Just ('"', rest) ->
            case T.unsnoc rest of
                Just (middle, '"') -> middle
                _ -> txt
        _ -> txt