packages feed

settei-formats (empty) → 0.2.0.0

raw patch · 12 files changed

+566/−0 lines, 12 filesdep +basedep +containersdep +generic-lens

Dependencies added: base, containers, generic-lens, optparse-applicative, settei, settei-dhall, settei-formats, settei-kdl, settei-optparse-applicative, settei-yaml, tasty, tasty-hunit, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,7 @@+# Changelog for settei-formats++## 0.2.0.0 — 2026-07-19++- Initial experimental release.+- Add tagged `FORMAT:PATH` configuration inputs, a shared loader dispatching to the+  YAML, KDL, and Dhall adapters, and a reusable `--config FORMAT:PATH` option.
+ LICENSE view
@@ -0,0 +1,28 @@+Copyright (c) 2026, shinzui+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+   this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE+POSSIBILITY OF SUCH DAMAGE.
+ settei-formats.cabal view
@@ -0,0 +1,81 @@+cabal-version:   3.8+name:            settei-formats+version:         0.2.0.0+synopsis:        Tagged multi-format configuration loading for Settei+description:+  Parse tagged FORMAT:PATH configuration inputs and load them through the+  Settei YAML, KDL, and Dhall adapters with one shared dispatcher, so+  multi-format applications stop hand-rolling the same reader and loader.++homepage:        https://github.com/shinzui/settei+bug-reports:     https://github.com/shinzui/settei/issues+license:         BSD-3-Clause+license-file:    LICENSE+author:          shinzui+maintainer:      shinzui+category:        Configuration+build-type:      Simple+tested-with:     GHC ==9.12.4+extra-doc-files: CHANGELOG.md+data-files:+  test/fixtures/*.dhall+  test/fixtures/*.kdl+  test/fixtures/*.yaml++source-repository head+  type:     git+  location: https://github.com/shinzui/settei.git++common common+  default-language:   GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++  ghc-options:        -Wall -Wcompat++library+  import:          common+  hs-source-dirs:  src+  exposed-modules:+    Settei.Formats+    Settei.Formats.Optparse++  build-depends:+    , base                         >=4.21    && <5+    , containers                   >=0.6.8   && <0.8+    , generic-lens                 >=2.2     && <2.4+    , optparse-applicative         >=0.19    && <0.20+    , settei                       ==0.2.0.0+    , settei-dhall                 ==0.2.0.0+    , settei-kdl                   ==0.2.0.0+    , settei-optparse-applicative  ==0.2.0.0+    , settei-yaml                  ==0.2.0.0+    , text                         >=2.1     && <2.2++test-suite settei-formats-tests+  import:          common+  type:            exitcode-stdio-1.0+  hs-source-dirs:  test+  main-is:         Main.hs+  other-modules:+    Paths_settei_formats+    Settei.FormatsOptparseTest+    Settei.FormatsTest++  autogen-modules: Paths_settei_formats+  build-depends:+    , base                  >=4.21    && <5+    , containers            >=0.6.8   && <0.8+    , generic-lens          >=2.2     && <2.4+    , optparse-applicative  >=0.19    && <0.20+    , settei                ==0.2.0.0+    , settei-dhall          ==0.2.0.0+    , settei-formats        ==0.2.0.0+    , settei-kdl            ==0.2.0.0+    , settei-yaml           ==0.2.0.0+    , tasty                 >=1.5     && <1.6+    , tasty-hunit           >=0.10.2  && <0.11+    , text                  >=2.1     && <2.2
+ src/Settei/Formats.hs view
@@ -0,0 +1,179 @@+{-# LANGUAGE ImportQualifiedPost #-}++-- |+-- Module: Settei.Formats+-- Description: Tagged multi-format configuration inputs and a shared adapter loader.+module Settei.Formats+  ( ConfigFormat (..),+    ConfigInput,+    FormatLoadError (..),+    LoadOptions,+    annotateLoadOptions,+    configInput,+    configInputFormat,+    configInputPath,+    defaultLoadOptions,+    fromKubernetesMountedFile,+    loadConfigInput,+    loadOptionsAnnotations,+    loadOptionsDhallImportPolicy,+    loadOptionsKubernetesRef,+    parseConfigInput,+    renderFormatLoadErrorText,+    withDhallImportPolicy,+  )+where++import Data.Generics.Labels ()+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Settei+import Settei.Dhall qualified as Dhall+import Settei.Kdl qualified as Kdl+import Settei.Prelude+import Settei.Yaml qualified as Yaml++-- | Explicit format tag required for each configuration input.+data ConfigFormat = YamlFormat | KdlFormat | DhallFormat+  deriving stock (Generic, Eq, Ord, Show)++-- | One ordered, explicitly tagged configuration file.+data ConfigInput = ConfigInput+  { format :: !ConfigFormat,+    path :: !FilePath+  }+  deriving stock (Generic, Eq, Show)++-- | Construct a tagged input programmatically.+configInput :: ConfigFormat -> FilePath -> ConfigInput+configInput format path = ConfigInput {format, path}++-- | Return an input's explicit adapter tag.+configInputFormat :: ConfigInput -> ConfigFormat+configInputFormat value = value ^. #format++-- | Return an input's filesystem path.+configInputPath :: ConfigInput -> FilePath+configInputPath value = value ^. #path++-- | Parse the @FORMAT:PATH@ grammar: @yaml:PATH@, @kdl:PATH@, or @dhall:PATH@.+--+-- A missing colon or an empty path fails with @expected FORMAT:PATH@. Any other+-- format name, including an empty one as in @:app.yaml@, fails with+-- @FORMAT must be yaml, kdl, or dhall@.+parseConfigInput :: String -> Either String ConfigInput+parseConfigInput input =+  let (formatName, separatorAndPath) = break (== ':') input+      filePath = drop 1 separatorAndPath+   in if null separatorAndPath || null filePath+        then Left "expected FORMAT:PATH"+        else case formatName of+          "yaml" -> Right (configInput YamlFormat filePath)+          "kdl" -> Right (configInput KdlFormat filePath)+          "dhall" -> Right (configInput DhallFormat filePath)+          _ -> Left "FORMAT must be yaml, kdl, or dhall"++-- | How loaded sources should be annotated and how Dhall imports are policed.+data LoadOptions = LoadOptions+  { kubernetesReference :: !(Maybe KubernetesRef),+    annotations :: !(Map Text Text),+    dhallImportPolicy :: !Dhall.DhallImportPolicy+  }+  deriving stock (Generic)++-- | No Kubernetes reference, no extra annotations, and @NoImports@ for Dhall.+--+-- The Dhall default is deliberately the most restrictive policy: a loader must+-- never follow filesystem imports unless the caller explicitly names an allowed+-- directory with 'withDhallImportPolicy'.+defaultLoadOptions :: LoadOptions+defaultLoadOptions =+  LoadOptions+    { kubernetesReference = Nothing,+      annotations = Map.empty,+      dhallImportPolicy = Dhall.NoImports+    }++-- | Attach a trusted Kubernetes reference for a mounted file. No cluster lookup occurs.+fromKubernetesMountedFile :: KubernetesRef -> LoadOptions -> LoadOptions+fromKubernetesMountedFile reference options =+  options & #kubernetesReference ?~ reference++-- | Add trusted, secret-safe annotations to every loaded source.+annotateLoadOptions :: Map Text Text -> LoadOptions -> LoadOptions+annotateLoadOptions extra options = options & #annotations %~ (extra <>)++-- | Replace the Dhall import policy (default 'Dhall.NoImports').+withDhallImportPolicy :: Dhall.DhallImportPolicy -> LoadOptions -> LoadOptions+withDhallImportPolicy policy options = options & #dhallImportPolicy .~ policy++-- | Return the optional mounted-file Kubernetes reference.+loadOptionsKubernetesRef :: LoadOptions -> Maybe KubernetesRef+loadOptionsKubernetesRef value = value ^. #kubernetesReference++-- | Return caller-supplied trusted annotations.+loadOptionsAnnotations :: LoadOptions -> Map Text Text+loadOptionsAnnotations value = value ^. #annotations++-- | Return the enforced Dhall import policy.+loadOptionsDhallImportPolicy :: LoadOptions -> Dhall.DhallImportPolicy+loadOptionsDhallImportPolicy value = value ^. #dhallImportPolicy++-- | One failed tagged load, wrapping the originating adapter's full error list.+data FormatLoadError+  = YamlLoadError !(NonEmpty Yaml.YamlSourceError)+  | KdlLoadError !(NonEmpty Kdl.KdlSourceError)+  | DhallLoadError !(NonEmpty Dhall.DhallSourceError)+  deriving stock (Generic, Eq, Show)++-- | Load one tagged input through the adapter its format names.+loadConfigInput ::+  LoadOptions ->+  ConfigInput ->+  IO (Either (NonEmpty FormatLoadError) Source)+loadConfigInput options input =+  let sourceLabel = Text.pack (input ^. #path)+      wrap wrapError = either (Left . NonEmpty.singleton . wrapError) Right+      kubernetesAnnotationsOf =+        maybe Map.empty kubernetesAnnotations (options ^. #kubernetesReference)+   in case input ^. #format of+        YamlFormat ->+          let yamlOptions =+                Yaml.annotateYamlSourceOptions+                  (options ^. #annotations)+                  ( maybe+                      id+                      Yaml.fromKubernetesMountedFile+                      (options ^. #kubernetesReference)+                      (Yaml.yamlSourceOptions sourceLabel)+                  )+           in wrap YamlLoadError <$> Yaml.readYamlSource yamlOptions (input ^. #path)+        KdlFormat ->+          let kdlOptions =+                Kdl.annotateKdlSourceOptions+                  (options ^. #annotations)+                  ( maybe+                      id+                      Kdl.fromKubernetesMountedFile+                      (options ^. #kubernetesReference)+                      (Kdl.kdlSourceOptions sourceLabel)+                  )+           in wrap KdlLoadError <$> Kdl.readKdlSource kdlOptions (input ^. #path)+        DhallFormat ->+          let dhallOptions =+                Dhall.annotateDhallSourceOptions+                  (kubernetesAnnotationsOf <> options ^. #annotations)+                  ( Dhall.dhallSourceOptions+                      sourceLabel+                      (options ^. #dhallImportPolicy)+                  )+           in wrap DhallLoadError+                <$> Dhall.loadDhallSource dhallOptions (Dhall.DhallFile (input ^. #path))++-- | Render one adapter load failure for operator-facing output.+renderFormatLoadErrorText :: FormatLoadError -> Text+renderFormatLoadErrorText = \case+  YamlLoadError problems -> Yaml.renderYamlErrorsText problems+  KdlLoadError problems -> Kdl.renderKdlErrorsText problems+  DhallLoadError problems -> Dhall.renderDhallErrorsText problems
+ src/Settei/Formats/Optparse.hs view
@@ -0,0 +1,34 @@+{-# LANGUAGE ImportQualifiedPost #-}++-- |+-- Module: Settei.Formats.Optparse+-- Description: Reusable @--config FORMAT:PATH@ options for tagged multi-format inputs.+module Settei.Formats.Optparse+  ( configInputOption,+    configInputOptions,+    configInputReader,+  )+where++import Control.Applicative qualified as Applicative+import Options.Applicative (Mod, OptionFields, Parser)+import Options.Applicative qualified as Options+import Settei.Formats++-- | 'Options.ReadM' for the @FORMAT:PATH@ grammar of 'parseConfigInput'.+configInputReader :: Options.ReadM ConfigInput+configInputReader = Options.eitherReader parseConfigInput++-- | Parse zero or more tagged inputs using caller-supplied option metadata.+configInputOption :: Mod OptionFields ConfigInput -> Parser [ConfigInput]+configInputOption modifiers =+  Applicative.many (Options.option configInputReader modifiers)++-- | Parse zero or more default @--config FORMAT:PATH@ occurrences.+configInputOptions :: Parser [ConfigInput]+configInputOptions =+  configInputOption+    ( Options.long "config"+        <> Options.metavar "FORMAT:PATH"+        <> Options.help "Load yaml:PATH, kdl:PATH, or dhall:PATH in occurrence order"+    )
+ test/Main.hs view
@@ -0,0 +1,15 @@+module Main (main) where++import Settei.FormatsOptparseTest qualified+import Settei.FormatsTest qualified+import Test.Tasty (defaultMain, testGroup)++main :: IO ()+main =+  defaultMain+    ( testGroup+        "settei-formats"+        [ Settei.FormatsTest.tests,+          Settei.FormatsOptparseTest.tests+        ]+    )
+ test/Settei/FormatsOptparseTest.hs view
@@ -0,0 +1,47 @@+{-# LANGUAGE ImportQualifiedPost #-}++module Settei.FormatsOptparseTest (tests) where++import Options.Applicative qualified as Options+import Settei.Formats+import Settei.Formats.Optparse+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Settei.Formats.Optparse"+    [ testCase "repeated --config inputs preserve occurrence order" $ do+        parsed <-+          expectParse+            configInputOptions+            ["--config", "yaml:a.yaml", "--config", "kdl:b.kdl"]+        parsed+          @?= [ configInput YamlFormat "a.yaml",+                configInput KdlFormat "b.kdl"+              ],+      testCase "no --config occurrence produces an empty list" $ do+        parsed <- expectParse configInputOptions []+        parsed @?= [],+      testCase "an unsupported format fails option parsing" $ do+        let parsed = parse configInputOptions ["--config", "toml:x"]+        assertBool+          "unsupported format unexpectedly parsed"+          (Options.getParseResult parsed == Nothing),+      testCase "caller-supplied option metadata is honored" $ do+        parsed <-+          expectParse+            (configInputOption (Options.long "input" <> Options.metavar "FORMAT:PATH"))+            ["--input", "dhall:c.dhall"]+        parsed @?= [configInput DhallFormat "c.dhall"]+    ]++parse :: Options.Parser a -> [String] -> Options.ParserResult a+parse parser arguments =+  Options.execParserPure Options.defaultPrefs (Options.info parser mempty) arguments++expectParse :: Options.Parser a -> [String] -> IO a+expectParse parser arguments = case parse parser arguments of+  Options.Success value -> pure value+  _ -> fail "expected command-line parsing to succeed"
+ test/Settei/FormatsTest.hs view
@@ -0,0 +1,168 @@+{-# LANGUAGE ImportQualifiedPost #-}++module Settei.FormatsTest (tests) where++import Data.Generics.Labels ()+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Paths_settei_formats qualified as Paths+import Settei+import Settei.Dhall qualified as Dhall+import Settei.Formats+import Settei.Kdl qualified as Kdl+import Settei.Prelude+import Settei.Yaml qualified as Yaml+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Settei.Formats"+    [ testGroup+        "FORMAT:PATH grammar"+        [ testCase "accepts yaml, kdl, and dhall tags" $ do+            assertParsed "yaml:app.yaml" YamlFormat "app.yaml"+            assertParsed "kdl:app.kdl" KdlFormat "app.kdl"+            assertParsed "dhall:app.dhall" DhallFormat "app.dhall",+          testCase "splits only at the first colon" $+            assertParsed+              "yaml:dir:with:colons.yaml"+              YamlFormat+              "dir:with:colons.yaml",+          testCase "rejects missing separators and empty paths" $ do+            parseConfigInput "app.yaml" @?= Left "expected FORMAT:PATH"+            parseConfigInput "yaml:" @?= Left "expected FORMAT:PATH",+          testCase "rejects unsupported and empty format names" $ do+            parseConfigInput "toml:app.toml"+              @?= Left "FORMAT must be yaml, kdl, or dhall"+            parseConfigInput ":app.yaml"+              @?= Left "FORMAT must be yaml, kdl, or dhall"+        ],+      testGroup+        "adapter dispatch"+        [ testCase "loads YAML and resolves a typed value" $+            assertFixtureLoads YamlFormat "test/fixtures/app.yaml",+          testCase "loads KDL and resolves a typed value" $+            assertFixtureLoads KdlFormat "test/fixtures/app.kdl",+          testCase "loads Dhall and resolves a typed value" $+            assertFixtureLoads DhallFormat "test/fixtures/app.dhall"+        ],+      testCase "Kubernetes and caller annotations reach YAML and Dhall sources" $ do+        yamlPath <- Paths.getDataFileName "test/fixtures/app.yaml"+        dhallPath <- Paths.getDataFileName "test/fixtures/app.dhall"+        let reference =+              kubernetesRef+                ConfigMapObject+                Nothing+                "settei-formats-test"+                (Just "app.yaml")+            options =+              fromKubernetesMountedFile+                reference+                ( annotateLoadOptions+                    (Map.singleton "team" "platform")+                    defaultLoadOptions+                )+        yamlSource <- expectLoaded options (configInput YamlFormat yamlPath)+        assertAnnotations yamlSource+        dhallSource <- expectLoaded options (configInput DhallFormat dhallPath)+        assertAnnotations dhallSource,+      testCase "Dhall imports are denied by default and allowed explicitly" $ do+        importPath <- Paths.getDataFileName "test/fixtures/imports.dhall"+        fixturesDirectory <- Paths.getDataFileName "test/fixtures"+        loadOptionsDhallImportPolicy defaultLoadOptions @?= Dhall.NoImports+        denied <- loadConfigInput defaultLoadOptions (configInput DhallFormat importPath)+        case denied of+          Left outerErrors -> case NonEmpty.toList outerErrors of+            [DhallLoadError problems] ->+              fmap Dhall.dhallErrorCategory (NonEmpty.toList problems)+                @?= [Dhall.DhallImportPolicyError]+            _ -> fail "expected one Dhall load error"+          Right _ -> fail "expected the default Dhall policy to reject imports"+        imported <-+          expectLoaded+            ( withDhallImportPolicy+                (Dhall.LocalImportsWithin fixturesDirectory)+                defaultLoadOptions+            )+            (configInput DhallFormat importPath)+        assertResolvedEndpoint imported,+      testCase "a missing YAML file is a structured load error" $ do+        missing <-+          loadConfigInput+            defaultLoadOptions+            (configInput YamlFormat "settei-formats-missing.yaml")+        case missing of+          Left outerErrors -> case NonEmpty.toList outerErrors of+            [YamlLoadError problems] ->+              fmap Yaml.yamlErrorCategory (NonEmpty.toList problems)+                @?= [Yaml.YamlIoError]+            _ -> fail "expected one YAML load error"+          Right _ -> fail "expected a missing YAML file to fail",+      testCase "format load errors delegate to adapter renderers" $ do+        yamlError <- expectLoadError YamlFormat "settei-formats-missing.yaml"+        case yamlError of+          YamlLoadError problems ->+            renderFormatLoadErrorText yamlError @?= Yaml.renderYamlErrorsText problems+          _ -> fail "expected a YAML load error"+        kdlError <- expectLoadError KdlFormat "settei-formats-missing.kdl"+        case kdlError of+          KdlLoadError problems ->+            renderFormatLoadErrorText kdlError @?= Kdl.renderKdlErrorsText problems+          _ -> fail "expected a KDL load error"+        dhallError <- expectLoadError DhallFormat "settei-formats-missing.dhall"+        case dhallError of+          DhallLoadError problems ->+            renderFormatLoadErrorText dhallError @?= Dhall.renderDhallErrorsText problems+          _ -> fail "expected a Dhall load error"+    ]++assertParsed :: String -> ConfigFormat -> FilePath -> IO ()+assertParsed input expectedFormat expectedPath = case parseConfigInput input of+  Left problem -> fail problem+  Right parsed -> do+    configInputFormat parsed @?= expectedFormat+    configInputPath parsed @?= expectedPath++assertFixtureLoads :: ConfigFormat -> FilePath -> IO ()+assertFixtureLoads format relativePath = do+  fixturePath <- Paths.getDataFileName relativePath+  loaded <- expectLoaded defaultLoadOptions (configInput format fixturePath)+  assertResolvedEndpoint loaded++expectLoaded :: LoadOptions -> ConfigInput -> IO Source+expectLoaded options input = do+  result <- loadConfigInput options input+  case result of+    Left problems -> fail (show problems)+    Right loaded -> pure loaded++expectLoadError :: ConfigFormat -> FilePath -> IO FormatLoadError+expectLoadError format path = do+  result <- loadConfigInput defaultLoadOptions (configInput format path)+  case result of+    Left problems -> case NonEmpty.toList problems of+      [problem] -> pure problem+      _ -> fail "expected one format load error"+    Right _ -> fail "expected format loading to fail"++assertResolvedEndpoint :: Source -> IO ()+assertResolvedEndpoint input =+  case (resolve defaultResolveOptions [input] endpointConfig) ^. #answer of+    Left problems -> fail (show problems)+    Right endpoint -> endpoint @?= "https://example.test"++assertAnnotations :: Source -> IO ()+assertAnnotations input = do+  Map.lookup "kubernetes.object-kind" (sourceAnnotations input) @?= Just "ConfigMap"+  Map.lookup "kubernetes.object-name" (sourceAnnotations input)+    @?= Just "settei-formats-test"+  Map.lookup "team" (sourceAnnotations input) @?= Just "platform"++endpointConfig :: Config Text+endpointConfig =+  required (publicSetting endpointKey "Service endpoint" textDecoder)++endpointKey :: Key+endpointKey = either (error . show) id (parseKey "service.endpoint")
+ test/fixtures/app.dhall view
@@ -0,0 +1,1 @@+{ service = { endpoint = "https://example.test" } }
+ test/fixtures/app.kdl view
@@ -0,0 +1,3 @@+service {+  endpoint "https://example.test"+}
+ test/fixtures/app.yaml view
@@ -0,0 +1,2 @@+service:+  endpoint: https://example.test
+ test/fixtures/imports.dhall view
@@ -0,0 +1,1 @@+./app.dhall