Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

resolve #107 IO module out outLn etc #120

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ available [on GitHub][2].
<!-- Add new changes here -->

### Added

[#107] (https://github.com/chshersh/iris/issues/107):
Implement 'out', 'outLn', 'err' and 'errLn' functions for outputting 'Text' to corresponding handlers

* Add `out`, `outLn`, `err` and `errLn` functions for outputting 'Text' to corresponding handlers

(by [@martinhelmer])

[#9](https://github.com/chshersh/iris/issues/9):
Implement Yes/No reading functions:

Expand Down
5 changes: 4 additions & 1 deletion iris.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ library
Iris.Tool
Iris.Interactive
Iris.Interactive.Question
Iris.IO


build-depends:
Expand Down Expand Up @@ -126,13 +127,15 @@ test-suite iris-test
Test.Iris.Tool
Test.Iris.Interactive
Test.Iris.Interactive.Question
Test.Iris.IO

build-depends:
, iris
, hspec >= 2.9.7 && < 2.11
, text
, optparse-applicative

, silently

ghc-options:
-threaded
-rtsopts
Expand Down
7 changes: 7 additions & 0 deletions src/Iris.hs
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,16 @@ module Iris (
-- $tool
module Iris.Tool,
module Iris.Interactive,
module Iris.IO,
-- $io
) where

import Iris.App
import Iris.Browse
import Iris.Cli
import Iris.Colour
import Iris.Env
import Iris.IO
import Iris.Interactive
import Iris.Settings
import Iris.Tool
Expand All @@ -80,6 +83,10 @@ Functions to detect terminal support for colouring and print coloured output.
Global environment for a CLI application and CLI app settings.
-}

{- $io
Output Text to stdout/ stderr
-}

{- $settings
Settings for the environment.
-}
Expand Down
13 changes: 5 additions & 8 deletions src/Iris/Colour/Formatting.hs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,10 @@ module Iris.Colour.Formatting (
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.Reader (MonadReader)
import Data.Text (Text)
import qualified Data.Text.IO as T
import System.IO (stderr)

import Iris.Colour.Mode (ColourMode (..))
import Iris.Env (CliEnv (..), asksCliEnv)

import qualified Data.Text.IO as TIO
import qualified Iris.IO as IO

{- | Print 'Text' to 'System.IO.stdout' by providing a custom
formatting function.
Expand All @@ -52,7 +49,7 @@ putStdoutColouredLn
-> m ()
putStdoutColouredLn formatWithColour str = do
colourMode <- asksCliEnv cliEnvStdoutColourMode
liftIO $ T.putStrLn $ case colourMode of
liftIO $ IO.outLn $ case colourMode of
DisableColour -> str
EnableColour -> formatWithColour str

Expand All @@ -78,7 +75,7 @@ putStderrColouredLn
-> m ()
putStderrColouredLn formatWithColour str = do
colourMode <- asksCliEnv cliEnvStderrColourMode
liftIO $ T.hPutStrLn stderr $ case colourMode of
liftIO $ IO.errLn $ case colourMode of
DisableColour -> str
EnableColour -> formatWithColour str

Expand All @@ -105,7 +102,7 @@ putStdoutColoured
-> m ()
putStdoutColoured formatWithColour str = do
colourMode <- asksCliEnv cliEnvStdoutColourMode
liftIO $ TIO.putStr $ case colourMode of
liftIO $ IO.out $ case colourMode of
DisableColour -> str
EnableColour -> formatWithColour str

Expand All @@ -132,6 +129,6 @@ putStderrColoured
-> m ()
putStderrColoured formatWithColour str = do
colourMode <- asksCliEnv cliEnvStderrColourMode
liftIO $ TIO.hPutStr stderr $ case colourMode of
liftIO $ IO.err $ case colourMode of
DisableColour -> str
EnableColour -> formatWithColour str
95 changes: 95 additions & 0 deletions src/Iris/IO.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{- |
Module : Iris.IO
Copyright : (c) 2022 Dmitrii Kovanikov
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this was copy-pasted from another module, so we can update the year 😅

Suggested change
Copyright : (c) 2022 Dmitrii Kovanikov
Copyright : (c) 2023 Dmitrii Kovanikov

SPDX-License-Identifier : MPL-2.0
Maintainer : Dmitrii Kovanikov <[email protected]>
Stability : Experimental
Portability : Portable

Functions for IO, such as writing Text to stdout and stderr.


Usage example:

@
import qualified Iris

main = do
Iris.outLn "This goes to stdout"
Iris.errLn "This goes to stderr"
@

Results in:

@
\$ ./app
This goes to stdout
This goes to stderr
@

@since x.x.x.x
-}
module Iris.IO (
out,
outLn,
err,
errLn,
) where

import Data.Text (Text)
import qualified Data.Text.IO as Text
import System.IO (stderr, stdout)

{- | Write the given Text to stdout.
No linefeed at the end.

@
ghci> do Iris.out "foo" >> Iris.out "bar"
foobarghci>

@
@since x.x.x.x
-}
out :: Text -> IO ()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's actually make this function work in a polymoprhic MonadIO, so people won't need to write liftIO. And you actually remove liftIO $ ... in all places where you use out, outLn and etc.

Suggested change
out :: Text -> IO ()
out :: MonadIO m => Text -> m ()

out = Text.hPutStr stdout

{- | Write the given Text to stdout with linefeed at the end.

@
ghci> Iris.outLn "foo" >> Iris.outLn "bar"
foo
bar
ghci>

@
@since x.x.x.x
-}
outLn :: Text -> IO ()
outLn = Text.hPutStrLn stdout

{- | Write the given Text to stderr.
No linefeed at the end.

@
ghci> Iris.err "foo" >> Iris.err "bar"
foobarghci>

@
@since x.x.x.x
-}
err :: Text -> IO ()
err = Text.hPutStr stderr

{- | Write the given Text to stderr with linefeed at the end.

@
ghci> Iris.errLn "foo" >> Iris.errLn "bar"
foo
bar
ghci>

@
@since x.x.x.x
-}
errLn :: Text -> IO ()
errLn = Text.hPutStrLn stderr
7 changes: 4 additions & 3 deletions src/Iris/Interactive/Question.hs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import System.IO (hFlush, stdout)

import Iris.Cli.Interactive (InteractiveMode (..))
import Iris.Env (CliEnv (..), asksCliEnv)
import qualified Iris.IO as IO

{-
@since x.x.x.x
Expand Down Expand Up @@ -107,12 +108,12 @@ yesno question defaultAnswer = do
where
loop :: IO YesNo
loop = do
Text.putStr $ question <> " (yes/no) "
IO.out $ question <> " (yes/no) "
hFlush stdout
input <- Text.getLine
case parseYesNo input of
Just answer -> pure answer
Nothing -> do
Text.putStrLn $ "I don't understand your answer: '" <> input <> "'"
Text.putStrLn "Please, answer yes or no (or y, or n)"
IO.out $ "I don't understand your answer: '" <> input <> "'"
IO.out "Please, answer yes or no (or y, or n)"
Comment on lines +117 to +118
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be outLn and not just out.

Ideally, if we could fully test this function, the tests could've caught this error. But I don't see a good way to test it. I was thinking about mocking stdin somehow to provide input. But this looks impossible (or maybe I just don't know how to do this). Tricks like silently allow to capture only stdout and stderr but they don't allow to substitute stdin which is a pity.

A proper test for this function would be to write a separate executable and use some scripting-scenario framework where you can specify input and expected output. But this is a lot of work for this small test, but maybe we'll add it later eventually anyway when things stabilise.

P.S. I remember already writing this comment but I don't see it in the review. Maybe I forgot to submit it 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered a future improvement which would be "take interactive input from file instead of stdin"? (Useful for batch installation scripts etc)

If we were to support such an improvement we'd need to store the input handle in the environment. And that would also allow for unit testing this part easier.

Thoughts?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote multiple CLI tools but I never had the need to test in batch mode from a file 🤔

You don't need to mock stdin in such cases because you can always pipe the content of the file into your program:

cat my-commands | my-program

For full E2E testing of CLI tools, one can use an external solution like this one:

I can see how storing stdin in the environment can make testing easier but it can also make the architecture of a CLI app more complicated, confusing and potentially introduce errors when you read not from the environment stdin but from the global one

So I propose just changing IO.out to IO.outLn here and invest if full integration testing later.

loop
2 changes: 2 additions & 0 deletions test/Test/Iris.hs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Test.Hspec (Spec, describe)

import Test.Iris.Cli (cliSpec, cliSpecParserConflicts)
import Test.Iris.Colour (colourSpec)
import Test.Iris.IO (ioSpec)
import Test.Iris.Interactive (interactiveSpec)
import Test.Iris.Tool (toolSpec)

Expand All @@ -14,3 +15,4 @@ irisSpec = describe "Iris" $ do
colourSpec
toolSpec
interactiveSpec
ioSpec
34 changes: 34 additions & 0 deletions test/Test/Iris/IO.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module Test.Iris.IO (ioSpec)
where

import Test.Hspec (Spec, describe, it, shouldBe)

-- Silently has side effect: writes file to tmp (or current dir) and then deletes it.

import System.IO (stderr, stdout)
import System.IO.Silently (hCapture_, hSilence)

import qualified Iris.IO as IO (err, errLn, out, outLn)

checkStdErr :: IO a -> IO String
checkStdErr = hCapture_ [stderr] . hSilence [stdout]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the hSilence part should be removed:

Suggested change
checkStdErr = hCapture_ [stderr] . hSilence [stdout]
checkStdErr = hCapture_ [stderr]

Otherwise, the test it "does not write to stderr " $ checkStdErr (IO.out "TEXT") >>= flip shouldBe "" doesn't really check that the function doesn't output anything to stderr. Because stderr was explicitly silenced.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to silence the handle we're not checking. otherwise erroneous behaviour will cause "TEXT" to leak into the console, messing up the test progress report.

I believe the implementation is correct as it is. checkStdErr does not silence stderr. it silences stdout.

i can also confirm that the tests do seem to work; hacking IO.out to print to both stdout and stderr gives ths following result:

   IO
    out
      writes to stdout, no LF  [✔]
      does not write to stderr  [✘]
    outLn
      writes to stdout, LF  [✔]
      does not write to stderr  [✔]
    err
      writes to sterr, no LF  [✔]
      does not write to stdout  [✔]
    errLn
      writes to stderr, LF  [✔]
      does not write to stdout  [✔]

Failures:

  test/Test/Iris/IO.hs:25:83:
  1) Iris.IO.out does not write to stderr
       expected: ""
        but got: "TEXT"

but maybe i'm still missing something...

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see now. I was confused by silence but I can see that both functions checkStdOut and checkStdErr are used in a pair to test the output to both stdout and stderr. A single test is not enough to make sure that the function doesn't print somewhere else, but two tests together make everything work perfectly 👍🏻

No need to change these helper functions 😌


checkStdOut :: IO a -> IO String
checkStdOut = hCapture_ [stdout] . hSilence [stderr]

ioSpec :: Spec
ioSpec =
describe "IO" $ do
describe "out" $ do
-- we need `flip` to get the expectation reported correctly in case of a failure
it "writes to stdout, no LF " $ checkStdOut (IO.out "TEXT") >>= flip shouldBe "TEXT"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use shouldReturn from hspec to avoid >>= flip

Suggested change
it "writes to stdout, no LF " $ checkStdOut (IO.out "TEXT") >>= flip shouldBe "TEXT"
it "writes to stdout, no LF " $ checkStdOut (IO.out "TEXT") `shouldReturn` "TEXT"

it "does not write to stderr " $ checkStdErr (IO.out "TEXT") >>= flip shouldBe ""
describe "outLn" $ do
it "writes to stdout, LF " $ checkStdOut (IO.outLn "TEXT") >>= flip shouldBe "TEXT\n"
it "does not write to stderr " $ checkStdErr (IO.outLn "TEXT") >>= flip shouldBe ""
describe "err" $ do
it "writes to sterr, no LF " $ checkStdErr (IO.err "TEXT") >>= flip shouldBe "TEXT"
it "does not write to stdout " $ checkStdOut (IO.err "TEXT") >>= flip shouldBe ""
describe "errLn" $ do
it "writes to stderr, LF " $ checkStdErr (IO.errLn "TEXT") >>= flip shouldBe "TEXT\n"
it "does not write to stdout " $ checkStdOut (IO.errLn "TEXT") >>= flip shouldBe ""