This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new cc260be95 refactor(io)!: consolidate fs adapters behind options (#2300)
cc260be95 is described below

commit cc260be95386c91ab55b7cfc322265034d168bea
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 26 18:16:31 2026 +0100

    refactor(io)!: consolidate fs adapters behind options (#2300)
    
    * refactor(io)!: consolidate fs adapters behind options
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * docs(release): note v1 API compatibility change
    
    * chore: sync release notes with main to resolve conflicts
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
    Co-authored-by: Matt Topol <[email protected]>
---
 io/io.go                | 35 ++++++++++++++++++++-----------
 io/io_test.go           | 56 ++++++++++++++++++++++++++++++++++++++++++++++---
 website/src/releases.md |  1 +
 3 files changed, 77 insertions(+), 15 deletions(-)

diff --git a/io/io.go b/io/io.go
index 1e45cb80f..b7e53a1db 100644
--- a/io/io.go
+++ b/io/io.go
@@ -222,24 +222,35 @@ type MkdirAllIO interface {
        MkdirAll(path string) error
 }
 
-// FS wraps an io/fs.FS as an IO interface.
-func FS(fsys fs.FS) IO {
+// FS wraps an io/fs.FS as an IO interface. It implements ReadFileIO only
+// if the underlying fsys supports fs.ReadFileFS. Options can configure name
+// processing; see WithNamePreprocessor.
+func FS(fsys fs.FS, opts ...FSOption) IO {
+       adapter := ioFS{fsys: fsys}
+       for _, opt := range opts {
+               if opt != nil {
+                       opt(&adapter)
+               }
+       }
+
        if _, ok := fsys.(fs.ReadFileFS); ok {
-               return readFileFS{ioFS{fsys, nil}}
+               return readFileFS{adapter}
        }
 
-       return ioFS{fsys, nil}
+       return adapter
 }
 
-// FSPreProcName wraps an io/fs.FS like FS, only if fn is non-nil then
-// it is called to preprocess any filenames before they are passed to
-// the underlying fsys.
-func FSPreProcName(fsys fs.FS, fn func(string) string) IO {
-       if _, ok := fsys.(fs.ReadFileFS); ok {
-               return readFileFS{ioFS{fsys, fn}}
-       }
+// FSOption configures an IO adapter created by FS.
+type FSOption func(*ioFS)
 
-       return ioFS{fsys, fn}
+// WithNamePreprocessor transforms filenames before FS normalizes leading
+// slashes and passes them to the underlying fs.FS. It applies to Open, Remove,
+// and ReadFile when available. A nil preprocessor leaves names unchanged.
+// fn must be safe for concurrent use by multiple goroutines.
+func WithNamePreprocessor(fn func(string) string) FSOption {
+       return func(adapter *ioFS) {
+               adapter.preProcessName = fn
+       }
 }
 
 type readFileFS struct {
diff --git a/io/io_test.go b/io/io_test.go
index 3d870dd96..2a0f7d24d 100644
--- a/io/io_test.go
+++ b/io/io_test.go
@@ -52,13 +52,13 @@ func (f *recordingFS) Remove(name string) error {
        return nil
 }
 
-func TestFSPreProcNameTransformsAllOperations(t *testing.T) {
+func TestFSWithNamePreprocessorTransformsAllOperations(t *testing.T) {
        underlying := &recordingFS{files: fstest.MapFS{
                "rewritten/file.txt": &fstest.MapFile{Data: []byte("data")},
        }}
-       fsys := iceio.FSPreProcName(underlying, func(string) string {
+       fsys := iceio.FS(underlying, iceio.WithNamePreprocessor(func(string) 
string {
                return "/rewritten/file.txt"
-       })
+       }))
 
        file, err := fsys.Open(context.Background(), 
"file://bucket/original.txt")
        require.NoError(t, err)
@@ -71,3 +71,53 @@ func TestFSPreProcNameTransformsAllOperations(t *testing.T) {
        require.Equal(t, underlying.opened, underlying.read)
        require.Equal(t, underlying.opened, underlying.removed)
 }
+
+func TestFSDefaultAndNilOptions(t *testing.T) {
+       for _, tc := range []struct {
+               name string
+               opts []iceio.FSOption
+       }{
+               {name: "default"},
+               {name: "nil option", opts: []iceio.FSOption{nil}},
+               {name: "nil preprocessor", opts: 
[]iceio.FSOption{iceio.WithNamePreprocessor(nil)}},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       underlying := &recordingFS{files: fstest.MapFS{
+                               "plain.txt": &fstest.MapFile{Data: 
[]byte("data")},
+                       }}
+                       fsys := iceio.FS(underlying, tc.opts...)
+
+                       file, err := fsys.Open(context.Background(), 
"/plain.txt")
+                       require.NoError(t, err)
+                       require.NoError(t, file.Close())
+
+                       reader, ok := fsys.(iceio.ReadFileIO)
+                       require.True(t, ok)
+                       data, err := reader.ReadFile("/plain.txt")
+                       require.NoError(t, err)
+                       require.Equal(t, []byte("data"), data)
+
+                       require.NoError(t, fsys.Remove(context.Background(), 
"/plain.txt"))
+                       require.Equal(t, "plain.txt", underlying.opened)
+                       require.Equal(t, underlying.opened, underlying.read)
+                       require.Equal(t, underlying.opened, underlying.removed)
+               })
+       }
+}
+
+type openOnlyFS struct{ fs.FS }
+
+func TestFSDoesNotAdvertiseUnsupportedReadFile(t *testing.T) {
+       fsys := iceio.FS(openOnlyFS{fstest.MapFS{
+               "plain.txt": &fstest.MapFile{Data: []byte("data")},
+       }}, iceio.WithNamePreprocessor(func(string) string {
+               return "/plain.txt"
+       }))
+
+       _, ok := fsys.(iceio.ReadFileIO)
+       require.False(t, ok)
+
+       file, err := fsys.Open(context.Background(), "unused")
+       require.NoError(t, err)
+       require.NoError(t, file.Close())
+}
diff --git a/website/src/releases.md b/website/src/releases.md
index 67f72328d..723138fd8 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -42,6 +42,7 @@ Per-release notes (highlights, breaking changes, 
contributors) are published on
 
 ### Unreleased compatibility notes
 
+- **Filesystem adapter construction consolidated.** Replace 
`io.FSPreProcName(fsys, fn)` with `io.FS(fsys, io.WithNamePreprocessor(fn))`. 
Plain `io.FS(fsys)` remains supported.
 - **Table purge is required on catalogs.** The `catalog.Catalog` interface now 
embeds `catalog.PurgeableCatalog`; every custom catalog implementation must 
provide `PurgeTable(ctx, identifier) error`. The narrow 
`catalog.PurgeableTable` interface is renamed to `catalog.PurgeableCatalog`. 
Built-in purge behavior is unchanged.
 - **Unify statistics blob types with Puffin.** Replace `table.BlobType` with 
`puffin.BlobType` when constructing `table.BlobMetadata`. Replace 
`table.BlobTypeApacheDatasketchesThetaV1` with 
`puffin.BlobTypeDataSketchesTheta` and `table.BlobTypeDeletionVectorV1` with 
`puffin.BlobTypeDeletionVector`. Use `puffin.BlobType.IsKnown()` to identify 
the types defined by the Puffin specification; the redundant 
`table.BlobType.IsValid()` method is removed. Unknown non-empty blob type names 
still r [...]
 - **View representation field uses the Go SQL initialism.** Replace 
`view.Representation.Sql` with `view.Representation.SQL`. Serialized view 
metadata still uses the `sql` JSON field.

Reply via email to