Repository: apache/iceberg-go (go)

Issue:
Config File Handling
### Feature Request / Improvement

Similar to `pyiceberg`'s [config file](https://py.iceberg.apache.org/configuration/) we should add handling for a config file to make it easier to manipulate the available options for the CLI and library usage.

Pull request description:
Config File Handling
Handle config file `.iceberg-go.yaml` in the home directory.

Closes #62 

@zeroshade I noticed that `--catalog` has different functionality compare to pyiceberg cli, where it defines catalog name to be loaded from configuration file.

iceberg-go - `--catalog` - catalog type.
pyiceberg `--catalog` - catalog name to load from configuration file.

Because of this reason, currently it can load only `default` catalog.

Another note is that `docopt` throw's exception when struct has some non default values.
https://github.com/docopt/docopt.go/issues/55
Because of this limitation, I can't first parse configuration file and then provide the same struct to `docopt` to ensure the correct precedence. instead I added this manual mapping:
```
func mergeConf(fileConf *config.CatalogConfig, resConfig *Config) {
	if len(resConfig.Catalog) == 0 {
		resConfig.Catalog = fileConf.Catalog
	}
	...
}
```

Patch:
diff --git a/cmd/iceberg/main.go b/cmd/iceberg/main.go
index fb25618..7ec4a8e 100644
--- a/cmd/iceberg/main.go
+++ b/cmd/iceberg/main.go
@@ -27,6 +27,7 @@ import (
 
 	"github.com/apache/iceberg-go"
 	"github.com/apache/iceberg-go/catalog"
+	"github.com/apache/iceberg-go/config"
 	"github.com/apache/iceberg-go/table"
 	"github.com/docopt/docopt-go"
 )
@@ -58,7 +59,45 @@ Options:
   --uri TEXT         specify the catalog URI
   --output TYPE      output type (json/text) [default: text]
   --credential TEXT  specify credentials for the catalog
-  --warehouse TEXT   specify the warehouse to use`
+  --warehouse TEXT   specify the warehouse to use
+  --config TEXT      specify the path to the configuration file`
+
+type Config struct {
+	List     bool `docopt:"list"`
+	Describe bool `docopt:"describe"`
+	Schema   bool `docopt:"schema"`
+	Spec     bool `docopt:"spec"`
+	Uuid     bool `docopt:"uuid"`
+	Location bool `docopt:"location"`
+	Props    bool `docopt:"properties"`
+	Drop     bool `docopt:"drop"`
+	Files    bool `docopt:"files"`
+	Rename   bool `docopt:"rename"`
+
+	Get    bool `docopt:"get"`
+	Set    bool `docopt:"set"`
+	Remove bool `docopt:"remove"`
+
+	Namespace bool `docopt:"namespace"`
+	Table     bool `docopt:"table"`
+
+	RenameFrom string `docopt:"<from>"`
+	RenameTo   string `docopt:"<to>"`
+
+	Parent   string `docopt:"PARENT"`
+	Ident    string `docopt:"IDENTIFIER"`
+	TableID  string `docopt:"TABLE_ID"`
+	PropName string `docopt:"PROPNAME"`
+	Value    string `docopt:"VALUE"`
+
+	Catalog   string `docopt:"--catalog"`
+	URI       string `docopt:"--uri"`
+	Output    string `docopt:"--output"`
+	History   bool   `docopt:"--history"`
+	Cred      string `docopt:"--credential"`
+	Warehouse string `docopt:"--warehouse"`
+	Config    string `docopt:"--config"`
+}
 
 func main() {
 	args, err := docopt.ParseArgs(usage, os.Args[1:], iceberg.Version())
@@ -66,46 +105,17 @@ func main() {
 		log.Fatal(err)
 	}
 
-	cfg := struct {
-		List     bool `docopt:"list"`
-		Describe bool `docopt:"describe"`
-		Schema   bool `docopt:"schema"`
-		Spec     bool `docopt:"spec"`
-		Uuid     bool `docopt:"uuid"`
-		Location bool `docopt:"location"`
-		Props    bool `docopt:"properties"`
-		Drop     bool `docopt:"drop"`
-		Files    bool `docopt:"files"`
-		Rename   bool `docopt:"rename"`
-
-		Get    bool `docopt:"get"`
-		Set    bool `docopt:"set"`
-		Remove bool `docopt:"remove"`
-
-		Namespace bool `docopt:"namespace"`
-		Table     bool `docopt:"table"`
-
-		RenameFrom string `docopt:"<from>"`
-		RenameTo   string `docopt:"<to>"`
-
-		Parent   string `docopt:"PARENT"`
-		Ident    string `docopt:"IDENTIFIER"`
-		TableID  string `docopt:"TABLE_ID"`
-		PropName string `docopt:"PROPNAME"`
-		Value    string `docopt:"VALUE"`
-
-		Catalog   string `docopt:"--catalog"`
-		URI       string `docopt:"--uri"`
-		Output    string `docopt:"--output"`
-		History   bool   `docopt:"--history"`
-		Cred      string `docopt:"--credential"`
-		Warehouse string `docopt:"--warehouse"`
-	}{}
+	cfg := Config{}
 
 	if err := args.Bind(&cfg); err != nil {
 		log.Fatal(err)
 	}
 
+	fileCfg := config.ParseConfig(config.LoadConfig(cfg.Config), "default")
+	if fileCfg != nil {
+		mergeConf(fileCfg, &cfg)
+	}
+
 	var output Output
 	switch strings.ToLower(cfg.Output) {
 	case "text":
@@ -341,3 +351,21 @@ func properties(output Output, cat catalog.Catalog, args propCmd) {
 		}
 	}
 }
+
+func mergeConf(fileConf *config.CatalogConfig, resConfig *Config) {
+	if len(resConfig.Catalog) == 0 {
+		resConfig.Catalog = fileConf.Catalog
+	}
+	if len(resConfig.URI) == 0 {
+		resConfig.URI = fileConf.URI
+	}
+	if len(resConfig.Output) == 0 {
+		resConfig.Output = fileConf.Output
+	}
+	if len(resConfig.Cred) == 0 {
+		resConfig.Cred = fileConf.Credential
+	}
+	if len(resConfig.Warehouse) == 0 {
+		resConfig.Warehouse = fileConf.Warehouse
+	}
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..4af7654
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,71 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package config
+
+import (
+	"os"
+	"path/filepath"
+
+	"gopkg.in/yaml.v3"
+)
+
+const cfgFile = ".iceberg-go.yaml"
+
+type Config struct {
+	Catalogs map[string]CatalogConfig `yaml:"catalog"`
+}
+
+type CatalogConfig struct {
+	Catalog    string `yaml:"catalog"`
+	URI        string `yaml:"uri"`
+	Output     string `yaml:"output"`
+	Credential string `yaml:"credential"`
+	Warehouse  string `yaml:"warehouse"`
+}
+
+func LoadConfig(configPath string) []byte {
+	var path string
+	if len(configPath) > 0 {
+		path = configPath
+	} else {
+		homeDir, err := os.UserHomeDir()
+		if err != nil {
+			return nil
+
+		}
+		path = filepath.Join(homeDir, cfgFile)
+	}
+	file, err := os.ReadFile(path)
+	if err != nil {
+		return nil
+	}
+	return file
+}
+
+func ParseConfig(file []byte, catalogName string) *CatalogConfig {
+	var config Config
+	err := yaml.Unmarshal(file, &config)
+	if err != nil {
+		return nil
+	}
+	res, ok := config.Catalogs[catalogName]
+	if !ok {
+		return nil
+	}
+	return &res
+}
diff --git a/go.mod b/go.mod
index 453158e..baff29b 100644
--- a/go.mod
+++ b/go.mod
@@ -35,6 +35,7 @@ require (
 	github.com/twmb/murmur3 v1.1.8
 	github.com/wolfeidau/s3iofs v1.5.2
 	golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
+	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
@@ -87,5 +88,4 @@ require (
 	golang.org/x/text v0.18.0 // indirect
 	golang.org/x/tools v0.25.0 // indirect
 	golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 )


Test patch:
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 0000000..3e177d5
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,85 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+var testArgs = []struct {
+	file     []byte
+	catName  string
+	expected *CatalogConfig
+}{
+	// config file does not exist
+	{nil, "default", nil},
+	// config does not have default catalog
+	{[]byte(`
+catalog:
+  custom-catalog:
+    catalog: rest
+    uri: http://localhost:8181/
+    output: text
+    credential: client-id:client-secret
+    warehouse: catalog_name
+`), "default", nil},
+	// default catalog
+	{[]byte(`
+catalog:
+  default:
+    catalog: rest
+    uri: http://localhost:8181/
+    output: text
+    credential: client-id:client-secret
+    warehouse: catalog_name
+`), "default",
+		&CatalogConfig{
+			Catalog:    "rest",
+			URI:        "http://localhost:8181/",
+			Output:     "text",
+			Credential: "client-id:client-secret",
+			Warehouse:  "catalog_name",
+		}},
+	// custom catalog
+	{[]byte(`
+catalog:
+  custom-catalog:
+    catalog: rest
+    uri: http://localhost:8181/
+    output: text
+    credential: client-id:client-secret
+    warehouse: catalog_name
+`), "custom-catalog",
+		&CatalogConfig{
+			Catalog:    "rest",
+			URI:        "http://localhost:8181/",
+			Output:     "text",
+			Credential: "client-id:client-secret",
+			Warehouse:  "catalog_name",
+		}},
+}
+
+func TestParseConfig(t *testing.T) {
+	for _, tt := range testArgs {
+		actual := ParseConfig([]byte(tt.file), tt.catName)
+
+		assert.Equal(t, tt.expected, actual)
+	}
+}