Repository: planetlabs/gpq (go)

Issue:
Better warnings / info in `describe` on non-compliant GeoParquet
The new describe is awesome, but if I put in non-compliant geoparquet there's little messaging that I have a file that's not quite right:

```
 gpq describe taxi.parquet 
╭────────────┬────────┬─────────────────────────────────┬────────────┬─────────────╮
│ COLUMN     │ TYPE   │ ANNOTATION                      │ REPETITION │ COMPRESSION │
├────────────┼────────┼─────────────────────────────────┼────────────┼─────────────┤
│ OBJECTID   │ int32  │ int(bitwidth=32, issigned=true) │ 0..1       │ snappy      │
│ Shape_Leng │ double │                                 │ 0..1       │ snappy      │
│ Shape_Area │ double │                                 │ 0..1       │ snappy      │
│ zone       │ binary │ string                          │ 0..1       │ snappy      │
│ LocationID │ int32  │ int(bitwidth=32, issigned=true) │ 0..1       │ snappy      │
│ borough    │ binary │ string                          │ 0..1       │ snappy      │
│ geom       │ binary │                                 │ 0..1       │ snappy      │
├────────────┼────────┼─────────────────────────────────┴────────────┴─────────────┤
│ ROWS       │ 262    │                                                            │
╰────────────┴────────┴────────────────────────────────────────────────────────────╯
```

If I convert it then I get an additional row:
```
├──────────┼────────┼────────────┴────────────┴─────────────┴──────────┴────────────────┴────────┴────────┤
│ ROWS     │ 3233   │                                                                                     │
│ VERSION  │ 1.0.0  │                                                                                     │
╰──────────┴────────┴─────────────────────────────────────────────────────────────────────────────────────╯
```

I think it'd be nice to try to always put something about the compliance. Like maybe always have VERSION, but if it's not compliant than say `non-compliant` (might also be nice to call it 'geoparquet version' or something). It could also be nice to say if it's a 'compatible parquet' file, like it's geom and data looks like 4326, and recommend people use gpq convert.

Pull request description:
Suggest running validate if metadata parsing fails in describe
This adds output to the `describe` command that suggests running `validate` if the geo metadata is invalid.

Example output:
```shell
# gpq describe invalid.geoparquet
╭──────────┬────────┬────────────┬────────────┬─────────────╮
│ COLUMN   │ TYPE   │ ANNOTATION │ REPETITION │ COMPRESSION │
├──────────┼────────┼────────────┼────────────┼─────────────┤
│ geoid    │ binary │ string     │ 0..1       │ snappy      │
│ geometry │ binary │            │ 0..1       │ snappy      │
├──────────┼────────┴────────────┴────────────┴─────────────┤
│ Rows     │ 3233                                           │
╰──────────┴────────────────────────────────────────────────╯
 ⚠️  Not a valid GeoParquet file (invalid "geo" metadata). Run describe with the --metadata-only flag 
to see the "geo" metadata value. Run validate for more detail on validation issues.
```

In addition, this change adds a suggestion in the `describe` output to run `validate` if the file is missing geo metadata altogether.

Example output:
```shell
# gpq describe not-geo.parquet
╭───────────────────┬────────┬────────────┬────────────┬──────────────╮
│ COLUMN            │ TYPE   │ ANNOTATION │ REPETITION │ COMPRESSION  │
├───────────────────┼────────┼────────────┼────────────┼──────────────┤
│ registration_dttm │ int96  │            │ 0..1       │ uncompressed │
│ id                │ int32  │            │ 0..1       │ uncompressed │
│ first_name        │ binary │ string     │ 0..1       │ uncompressed │
│ last_name         │ binary │ string     │ 0..1       │ uncompressed │
│ email             │ binary │ string     │ 0..1       │ uncompressed │
│ gender            │ binary │ string     │ 0..1       │ uncompressed │
│ ip_address        │ binary │ string     │ 0..1       │ uncompressed │
│ cc                │ binary │ string     │ 0..1       │ uncompressed │
│ country           │ binary │ string     │ 0..1       │ uncompressed │
│ birthdate         │ binary │ string     │ 0..1       │ uncompressed │
│ salary            │ double │            │ 0..1       │ uncompressed │
│ title             │ binary │ string     │ 0..1       │ uncompressed │
│ comments          │ binary │ string     │ 0..1       │ uncompressed │
├───────────────────┼────────┴────────────┴────────────┴──────────────┤
│ Rows              │ 1000                                            │
╰───────────────────┴─────────────────────────────────────────────────╯
 ⚠️  Not a valid GeoParquet file (missing the "geo" metadata key). Run convert to try to convert it 
to GeoParquet.
```

Fixes #87.

Patch:
diff --git a/cmd/gpq/command/describe.go b/cmd/gpq/command/describe.go
index 8b80531..3e7dadb 100644
--- a/cmd/gpq/command/describe.go
+++ b/cmd/gpq/command/describe.go
@@ -96,8 +96,21 @@ func (c *DescribeCmd) Run() error {
 
 	metadata, geoErr := geoparquet.GetMetadata(fileMetadata.KeyValueMetadata())
 	if geoErr != nil {
-		if !errors.Is(geoErr, geoparquet.ErrNoMetadata) {
-			message := fmt.Sprintf("Metadata parsing failed, try running describe with the --metadata-only flag.  Error message: %s", geoErr.Error())
+		if errors.Is(geoErr, geoparquet.ErrNoMetadata) {
+			message := fmt.Sprintf(
+				"Not a valid GeoParquet file (missing the %q metadata key)."+
+					" Run convert to try to convert it to GeoParquet.",
+				geoparquet.MetadataKey,
+			)
+			info.Issues = append(info.Issues, message)
+		} else {
+			message := fmt.Sprintf(
+				"Not a valid GeoParquet file (invalid %q metadata)."+
+					" Run describe with the --metadata-only flag to see the %q metadata value."+
+					" Run validate for more detail on validation issues.",
+				geoparquet.MetadataKey,
+				geoparquet.MetadataKey,
+			)
 			info.Issues = append(info.Issues, message)
 		}
 	} else {
@@ -127,12 +140,16 @@ func (c *DescribeCmd) formatText(info *DescribeInfo) error {
 	if metadata != nil {
 		header = append(header, ColEncoding, ColGeometryTypes, ColBounds, ColDetail)
 		columnConfigs = append(columnConfigs, table.ColumnConfig{
+			Name:             ColEncoding,
+			WidthMax:         40,
+			WidthMaxEnforcer: text.WrapSoft,
+		}, table.ColumnConfig{
 			Name:             ColGeometryTypes,
-			WidthMax:         50,
+			WidthMax:         40,
 			WidthMaxEnforcer: text.WrapSoft,
 		}, table.ColumnConfig{
 			Name:             ColBounds,
-			WidthMax:         50,
+			WidthMax:         40,
 			WidthMaxEnforcer: text.WrapSoft,
 		})
 	}


Test patch:
diff --git a/cmd/gpq/command/describe_test.go b/cmd/gpq/command/describe_test.go
index 091cccf..3622f48 100644
--- a/cmd/gpq/command/describe_test.go
+++ b/cmd/gpq/command/describe_test.go
@@ -55,6 +55,8 @@ func (s *Suite) TestDescribe() {
 	s.Equal("string", info.Schema.Fields[5].Annotation)
 	s.Equal("gzip", info.Schema.Fields[5].Compression)
 	s.True(info.Schema.Fields[5].Optional)
+
+	s.Len(info.Issues, 0)
 }
 
 func (s *Suite) TestDescribeFromStdin() {
@@ -98,4 +100,45 @@ func (s *Suite) TestDescribeFromStdin() {
 	s.Equal("string", info.Schema.Fields[1].Annotation)
 	s.Equal("zstd", info.Schema.Fields[1].Compression)
 	s.True(info.Schema.Fields[1].Optional)
+
+	s.Len(info.Issues, 0)
+}
+
+func (s *Suite) TestDescribeMissingMetadata() {
+	s.writeStdin(test.ParquetFromJSON(s.T(), `[
+		{
+			"food": "burrito",
+			"good": true
+		},
+		{
+			"food": "onion",
+			"good": false
+		}
+	]`, nil))
+
+	cmd := &command.DescribeCmd{
+		Format: "json",
+	}
+
+	s.Require().NoError(cmd.Run())
+
+	output := s.readStdout()
+	info := &command.DescribeInfo{}
+	err := json.Unmarshal(output, info)
+	s.Require().NoError(err)
+
+	s.Equal(int64(2), info.NumRows)
+	s.Require().Len(info.Schema.Fields, 2)
+
+	s.Equal("food", info.Schema.Fields[0].Name)
+	s.Equal("binary", info.Schema.Fields[0].Type)
+	s.Equal("string", info.Schema.Fields[0].Annotation)
+	s.True(info.Schema.Fields[0].Optional)
+
+	s.Equal("good", info.Schema.Fields[1].Name)
+	s.Equal("boolean", info.Schema.Fields[1].Type)
+	s.True(info.Schema.Fields[1].Optional)
+
+	s.Require().Len(info.Issues, 1)
+	s.Contains(info.Issues[0], "Not a valid GeoParquet file (missing the \"geo\" metadata key).")
 }
diff --git a/internal/pqutil/transform_test.go b/internal/pqutil/transform_test.go
index 5402554..3559906 100644
--- a/internal/pqutil/transform_test.go
+++ b/internal/pqutil/transform_test.go
@@ -85,7 +85,7 @@ func TestTransformByColumn(t *testing.T) {
 
 	for i, c := range cases {
 		t.Run(fmt.Sprintf("%s (case %d)", c.name, i), func(t *testing.T) {
-			input := test.ParquetFromJSON(t, c.data, nil)
+			input := bytes.NewReader(test.ParquetFromJSON(t, c.data, nil))
 			output := &bytes.Buffer{}
 			config := c.config
 			if config == nil {
@@ -174,7 +174,7 @@ func TestTransformByRowGroupLength(t *testing.T) {
 	for i, c := range cases {
 		t.Run(fmt.Sprintf("%s (case %d)", c.name, i), func(t *testing.T) {
 			writerProperties := parquet.NewWriterProperties(parquet.WithMaxRowGroupLength(int64(c.inputRowGroupLength)))
-			input := test.ParquetFromJSON(t, string(inputData), writerProperties)
+			input := bytes.NewReader(test.ParquetFromJSON(t, string(inputData), writerProperties))
 			output := &bytes.Buffer{}
 			config := c.config
 			if config == nil {
@@ -293,7 +293,7 @@ func TestTransformColumn(t *testing.T) {
 		return arrow.NewChunked(builder.Type(), transformed), nil
 	}
 
-	input := test.ParquetFromJSON(t, data, nil)
+	input := bytes.NewReader(test.ParquetFromJSON(t, data, nil))
 	output := &bytes.Buffer{}
 	config := &pqutil.TransformConfig{
 		Reader:          input,
diff --git a/internal/test/test.go b/internal/test/test.go
index 0377483..1c75337 100644
--- a/internal/test/test.go
+++ b/internal/test/test.go
@@ -21,7 +21,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func ParquetFromJSON(t *testing.T, data string, writerProperties *parquet.WriterProperties) parquet.ReaderAtSeeker {
+func ParquetFromJSON(t *testing.T, data string, writerProperties *parquet.WriterProperties) []byte {
 	if writerProperties == nil {
 		writerProperties = parquet.NewWriterProperties()
 	}
@@ -50,7 +50,7 @@ func ParquetFromJSON(t *testing.T, data string, writerProperties *parquet.Writer
 	require.NoError(t, writer.WriteBuffered(rec))
 	require.NoError(t, writer.Close())
 
-	return bytes.NewReader(output.Bytes())
+	return output.Bytes()
 }
 
 func ParquetToJSON(t *testing.T, input parquet.ReaderAtSeeker) string {