polysemy-research / polysemy

:gemini: higher-order, no-boilerplate monads
BSD 3-Clause "New" or "Revised" License
1.04k stars 72 forks source link

Help architecting simple shell script replacement with polysemy #353

Closed codygman closed 3 years ago

codygman commented 4 years ago

Edit: I tried to simplify this so it's easier to make suggestions.

I have a shell script I want to replace with polysemy, and it does the following:

  1. Clone my codygman/hci repo (emacs config, nixos config) locally to ~/hci
  2. If it doesn't exist, create a symbolic link from ~/hci/nixpkgs to ~/.config/nixpkgs.
  3. install home-manager if it's not found by which
  4. run home-manager switch to sync my nix configuration to this computer

I originally had code here that covered steps 1-4, this code has been simplified to only cover step 3 since it's the simplest example of my confusion.

My questions:

  1. Can I somehow make the share an installHomeManager function between homeManagerPure and homeManagerIO interpreters so when I test homeManagerPure the logic of homeManagerIO is also tested?
  2. Is this even a good approach to this simple problem? In the past I've heard it's good to rely on pre-defined effects and the Polysemy zoo.
  3. How would you have written this?
  4. Once I have many programs like say maybeInstallHomeManager, maybeSymlinkHCI, and homeManagerSwitch how could I compose them together or alternatively sequence them all at once? Or is it best just to have a runM for each one?

Here is the code:

{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE BlockArguments #-}
import Polysemy
import Polysemy.Trace
import           Turtle

data HomeManagerInstallStatus = AlreadyInstalledHomeManager | NeedToInstallHomeManager deriving (Ord, Eq, Show)
data HomeManager m a where
  HomeManagerInstall :: HomeManager m ()
  HomeManagerInstalled :: HomeManager m HomeManagerInstallStatus
  HomeManagerSwitch :: HomeManager m ()
makeSem ''HomeManager

-- interpreter
homeManagerPure :: (Member Trace r) => HomeManagerInstallStatus -> Sem (HomeManager ': r) a -> Sem r a
homeManagerPure installState = interpret \case
  HomeManagerInstall -> trace "installed home-manager"
  HomeManagerInstalled -> pure installState
  HomeManagerSwitch -> do
    trace "home manager switch"
    pure ()

homeManagerIO :: (Member (Embed Shell) r, Member Trace r) => HomeManagerInstallStatus -> Sem (HomeManager ': r) a -> Sem r a
homeManagerIO installState = interpret \case
  HomeManagerInstall -> embed installHomeManagerTurtle
  HomeManagerInstalled -> embed $
    maybe NeedToInstallHomeManager (const AlreadyInstalledHomeManager) <$>
      which (fromString "home-manager")
  HomeManagerSwitch -> embed $
    sh $ homeManager ["switch"]

-- program
maybeInstallHomeManager :: (Member HomeManager r, Member Trace r) => Sem r ()
maybeInstallHomeManager = do
  installStatus <- homeManagerInstalled
  case installStatus of
    AlreadyInstalledHomeManager -> do
      pure ()
    NeedToInstallHomeManager -> do
      homeManagerInstall

main :: IO ()
main = do
  putStrLn "pure"
  maybeInstallHomeManager & homeManagerPure NeedToInstallHomeManager &
    runTraceList & run & print

homeManager opts = inproc "home-manager" opts empty

installHomeManagerTurtle :: Shell ()
installHomeManagerTurtle = do
  echo "installing home manager"
  proc
      "nix-channel"
      [ "--add"
      , "https://github.com/rycee/home-manager/archive/master.tar.gz"
      , "home-manager"
      ]
    empty
  proc "nix-channel" ["--update"] empty
  view $ shell "nix-shell '<home-manager>' -A install" empty

I'm aware of #28, #232, PolysemyCleanArchitecture, and sir4ur0n's intro to polysemy. I'm currently re-reading those resources and trying to understand them, reflect on the not quite working implementation I've got, and attempting to glean the "this is how you architect polysemy applications, this is too little granularity, this is too much granularity".

Edit: I reflected some more on the above resources I was aware of, nothing as of yet.

Thanks!

googleson78 commented 4 years ago
  1. If want to share the logic of installHomeManagerTurtle you would have to make your effect more granular, e.g. have your base actions be something like NixChannel <some-args>, NixShell <some-args> (I'm not familiar with nix) As it is you only have the abstract action of "install the manager", and the logic in installHomeManagerTurtle deals with nix almost entirely, so I don't see how you can share this logic with a pure interpreter

  2. In my opinion you can rely on builtin effects while "prototyping" or when they exactly fit what you need (e.g. Error or Input usually do). You can replace them later with something more domain-specific for clarity (just like you have an "installed status" instead of using Bool directly). Another very common case is when you have your custom effect but write your interpreter using one of the builtin effects, e.g. if you want to test your program specifically you can have an interpreter which turns it into some kind of State.

  3. (this is actually responding to 4., but md doesn't care about the numbers) Is there some issue with putting them in a do-block together?

sorki commented 4 years ago

I can't see the second comment for some reason (only via e-mail) but if you have an interpreter like the one you mention

hciPure :: (Member Trace r, Member HCI r) => HCIInstallStatus -> Sem (HCI ': r) a -> Sem r a

It means it interprets Sem (HCI ': r) a -> Sem r a but also introduces another HCI effect due to Member HCI constraint and you need to handle that one as well.

Overall your approach is fine, ad 4 you can just compose Sem r as without runM that would reduce them into e.g. IO - like instead of composing IO as you can compose Sem r as which you get by providing all the interpreters required. One example

spawn :: IO (Maybe (String, ExitCode))
spawn =
    runFinal
  . runTimeoutToIO @Seconds
  . runProcessIOFinal
  . runProcessOverSSH "localhost"
  $ timeout 2 $ do
    (i, o, e, h) <- createProcess "sleep" ["1"]

    x <-
        runOutputStream   (Streaming.Prelude.toHandle i)
      . runInputViaStream (Streaming.Prelude.fromHandle e)
      . untag @"err"
      . runInputViaStream (Streaming.Prelude.fromHandle o)
      . untag @"out"
      . stdStreamsAsInputOutput @String @"out" @"err"
      . teletypeAsStdStreams
      $ readTTY

    e <- waitProcess h
    return (x, e)

Like @googleson78 suggests its probably better to desing higher level effects and implement these using lower-level ones. Meanwhile I'm working on hnix & hnix-store and trying to introduce polysemy to it so at some point you could possibly use the effects we provide (if it goes well and we settle on polysemy :)).

Btw you can use home-manager as a NixOS module which could make it even easier but I like that you're experimenting with Polysemy for that!

isovector commented 3 years ago

I'm sorry that this got ignored for a year. If it's still relevant, I'm happy to review and respond. Closing now in an assumption that this is ancient stuff, but feel free to reopen --- I promise I'll respond!