どれほどのHackageにChangelogがないのか調べてみたHaskellで

Haskellを勉強し始めて驚いたのが、あまりにも多くのHackage(Haskellにおけるライブラリパッケージ)にChangelogが存在しないことだ。

当たり前だが、Changelogがなければモジュールのバージョン間の変化を俯瞰することができない。Haskellの文化では互換性を破壊するモジュールの変更がある程度許容されるそうなので、このことは著しい不便を生むことがある。(ただし、Cabalパッケージ間の変化を調査するprecisというプログラムもあるようだ)

しかし、「ChangelogのないHackageが多い」というのはあくまで主観的な印象であり、そのようなHackageが正確にいくつあるのかは分からなかった。そこで、Haskellの練習がてらこれを調べてみた。

調査方法

Hackageの公式レポジトリはhackage.haskell.orgであり、現在公開されているHackageの全リストは http://hackage.haskell.org/packages/ から取得できる。このエンドポイントはリクエストヘッダに"Accept: application/json"をつけるとJSON形式でリストを返すので、今回はこれを利用した。

hackage.haskell.orgにてインデクスされるChangelogを取得するには、 http://hackage.haskell.org/package/パッケージ名/changelog にアクセスすればよい。changelogが存在すればその内容が、なければ404 Not Foundが返される。今回はChangelogの有無を確認すればよいだけなので、GETリクエストではなくHEADリクエストを使った。

なお、hackage.haskell.orgのAPIhttp://hackage.haskell.org/api で公開されている。

もちろん、この調査方法は完璧ではない。パッケージ内に(haddockなどで)変更履歴がきちんと記載されているのにChangelogファイルがない場合もあるだろうし、逆にChangelogファイルはあるが中身が空の場合もあるだろう。細かな数字はあくまで参考程度にすべきである。

結果

2015/02/16現在の調査結果が以下の通り。この時点での全Hackage数は7670個だった。

比較

参考までに、Perlのモジュールパッケージ(「ディストリビューション」と呼ばれる)でもChangelogの有無を調べてみた。これはわざわざプログラムを書くまでもなく、以下のサイトで統計データを閲覧できる。

ただし、集計方法は上記のHackageに対する方法と異なる。このサービスでは、年ごとにアップロードされたディストリビューションについてChangelogの有無を集計している。ここで、アップロードされたディストリビューションとは、全くの新規ディストリビューションの場合もあるし、既存のもののアップデート版の場合もある。

また、"Fail"の項目が3種類ほどあるが、"Total Uploads = Pass + Fail (BackPAN)"という関係になっているようだ。

つまり年ごとにアップロードされたディストリビューションについてChangelogがないものの比率は"Fail (BackPAN)"のカラムを見ればよい。すると、だいたい5.0% 〜 7.3%程度で推移しているようだ。

上述のように、集計方法が異なるため現在累積されているディストリビューション全体についてのデータはないが、ほぼ同じ程度の値であると推測される。

考察

「半々くらいならしょうがないかなあ」と考えていたが、想像以上にChangelogのないHackageが多い結果となった。Perlモジュールと比べると実に10倍以上である。

なぜこれほどChangelogがないのだろうか?思うに、cabal initコマンドがChangelogのテンプレを生成しないのが良くないのではないだろうか。cabal-installの最新バージョン(1.22.0.0)で試してみたが、Changelogは生成されなかった。

後でcabal-installのソースコードを見て、この点を修正できないか調べてみようと思う。

調査プログラム

以下、今回調査に使ったプログラムを示す。

{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-}
import Prelude ()
import BasicPrelude
import qualified Data.Aeson as Aeson
import qualified Network.HTTP.Conduit as HTTP
import qualified Data.Map as M
import qualified Formatting as F

data PackageInfo = PackageInfo {
  piName :: Text,
  piChangelogExist :: Bool
} deriving (Eq,Ord)

showPInfo :: PackageInfo -> Text
showPInfo p = piName p <> ": " <> show (piChangelogExist p)

reqBase :: ByteString -> Text -> HTTP.Request
reqBase method api_path = let url = "http://hackage.haskell.org" <> api_path
                              req = either (error . textToString . show) id $ HTTP.parseUrl (textToString url)
                          in req { HTTP.method = method }

reqJson :: HTTP.Request -> HTTP.Request
reqJson req = req { HTTP.requestHeaders = (("Accept", "application/json") : HTTP.requestHeaders req) }

reqGetJson :: Text -> HTTP.Request
reqGetJson = reqJson . reqBase "GET"

reqHead :: Text -> HTTP.Request
reqHead = reqBase "HEAD"


getPackageNames :: (Functor m, MonadIO m) => HTTP.Manager -> m [Text]
getPackageNames httpman = do
  res_body <-  HTTP.responseBody <$> HTTP.httpLbs (reqGetJson "/packages/") httpman
  let key :: Text
      key = "packageName"
      e_names = (maybe (Left $ "missing packageName") Right . mapM (M.lookup key)) =<< Aeson.eitherDecode res_body
  case e_names of
    Right names -> return names
    Left err -> fail err

checkChangelog :: MonadIO m => HTTP.Manager -> Text -> m Bool
checkChangelog httpman package = liftIO $ do
    HTTP.httpLbs (reqHead ("/package/" <> package <> "/changelog")) httpman
    return True
  `catch` (\e -> case e of
              HTTP.StatusCodeException _ _ _ -> return False)

getNameFilter :: [Text] -> [Text] -> [Text]
getNameFilter [] = id
getNameFilter (num :_) = take $ read num

main = do
  name_filter <- getNameFilter <$> getArgs
  HTTP.withManager $ \httpman -> do
    names <- name_filter <$> getPackageNames httpman
    pinfos <- forM names $ \name -> do
      pinfo <- PackageInfo name <$> checkChangelog httpman name
      putStrLn $ showPInfo pinfo
      return pinfo
    let total = length pinfos
        (oks, ngs) = partition piChangelogExist pinfos
        ok = length oks
        ng = length ngs
        (ok_perc :: Double) = (fromIntegral ok) * 100 / (fromIntegral total)
        (ng_perc :: Double) = (fromIntegral ng) * 100 / (fromIntegral total)
    liftIO $ F.fprint format "OK" ok total ok_perc
    liftIO $ F.fprint format "NG" ng total ng_perc
  where format = F.stext .": ". F.int ." / ". F.int ." (". F.fixed 2 ." %)\n"

練習のため、今回は自分にとっていろいろ新しい試みをしてみた。

basic-prelude

"better Prelude"であるbasic-preludeを使ってみた。使ってみてよかったのは、

  • Control.Monad, Control.Applicative, Control.Monad.IO.Classあたりをごっそりexposeしてくれている。このへんの語彙は日常的に使うのでやはりありがたい。
  • StringではなくTextベースのAPIをexposeしてくれている。こいつとOverloadedStringsのおかげでだいぶ文字列の扱いがラクになったと思う。
http-conduit

HTTP通信にはhttp-conduitを使った。

最初はHTTPを使ったが、標準のAPIがStringベースだったり、ちょっとイケてないところがあるように思う。http-conduitは非2XXレスポンスをデフォルトでIO例外にするという思い切った作りだが、そっちのほうが使いやすいと思う。

今度機会があればwreqも試してみたい。

formatting

"better printf"であるformattingを試してみた。

Text.Printfは引数の型や数が合わないと実行時例外を投げたり、StringベースAPIだったりいろいろキツいところのあるモジュールである。formattingは引数仕様に型チェックが効き、結果をTextで返してくれる。

それはいいんだけどフォーマット文字列の表記はお世辞にも読みやすいとは言えない。もうちょっとなんとかならんだろうか。