[Git][debian-gis-team/fiona][upstream] New upstream version 1.10~b2

Bas Couwenberg (@sebastic) gitlab at salsa.debian.org
Thu Jul 11 04:53:06 BST 2024



Bas Couwenberg pushed to branch upstream at Debian GIS Project / fiona


Commits:
acb0a1cd by Bas Couwenberg at 2024-07-11T05:17:45+02:00
New upstream version 1.10~b2
- - - - -


26 changed files:

- .github/workflows/rstcheck.yml
- .github/workflows/scorecard.yml
- .github/workflows/test_gdal_latest.yml
- .github/workflows/tests.yml
- CHANGES.txt
- Makefile
- ci/rstcheck/requirements.txt
- fiona/__init__.py
- fiona/_env.pxd
- fiona/_env.pyx
- fiona/_err.pxd
- fiona/_err.pyx
- fiona/_geometry.pyx
- fiona/_vsiopener.pxd
- fiona/_vsiopener.pyx
- fiona/drvsupport.py
- fiona/gdal.pxi
- fiona/model.py
- fiona/ogrext.pyx
- setup.py
- tests/test_bounds.py
- tests/test_memoryfile.py
- tests/test_model.py
- tests/test_pyopener.py
- tests/test_slice.py
- tests/test_topojson.py


Changes:

=====================================
.github/workflows/rstcheck.yml
=====================================
@@ -25,7 +25,7 @@ jobs:
 
     steps:
       - name: Checkout code
-        uses: actions/checkout at v4
+        uses: actions/checkout at v4.1.3
 
       - name: Set up Python
         uses: actions/setup-python at v5


=====================================
.github/workflows/scorecard.yml
=====================================
@@ -32,12 +32,12 @@ jobs:
 
     steps:
       - name: "Checkout code"
-        uses: actions/checkout at 93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0
+        uses: actions/checkout at 1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
         with:
           persist-credentials: false
 
       - name: "Run analysis"
-        uses: ossf/scorecard-action at 0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
+        uses: ossf/scorecard-action at dc50aa9510b46c811795eb24b2f1ba02a914e534 # v2.3.3
         with:
           results_file: results.sarif
           results_format: sarif
@@ -59,7 +59,7 @@ jobs:
       # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
       # format to the repository Actions tab.
       - name: "Upload artifact"
-        uses: actions/upload-artifact at 5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
+        uses: actions/upload-artifact at 0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
         with:
           name: SARIF file
           path: results.sarif
@@ -67,6 +67,6 @@ jobs:
 
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif at 1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
+        uses: github/codeql-action/upload-sarif at b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11
         with:
           sarif_file: results.sarif


=====================================
.github/workflows/test_gdal_latest.yml
=====================================
@@ -27,7 +27,7 @@ jobs:
       GDAL_DATA: ${{ github.workspace }}/gdal_install/share/gdal
       LD_LIBRARY_PATH: "${{ github.workspace }}/gdal_install/lib/:${LD_LIBRARY_PATH}"
     steps:
-      - uses: actions/checkout at v4
+      - uses: actions/checkout at v4.1.3
       - name: Update
         run: |
           apt-get update


=====================================
.github/workflows/tests.yml
=====================================
@@ -4,7 +4,7 @@ on:
   push:
     branches: [ main, 'maint-*' ]
     paths:
-      - '.github/workflows/tests.yaml'
+      - '.github/workflows/tests.yml'
       - 'requirements*.txt'
       - 'setup.py'
       - 'setup.cfg'
@@ -15,7 +15,7 @@ on:
   pull_request:
     branches: [ main, 'maint-*' ]
     paths:
-      - '.github/workflows/tests.yaml'
+      - '.github/workflows/tests.yml'
       - 'requirements*.txt'
       - 'setup.py'
       - 'setup.cfg'
@@ -52,7 +52,7 @@ jobs:
             gdal-version: '3.8.3'
 
     steps:
-      - uses: actions/checkout at v4
+      - uses: actions/checkout at v4.1.3
 
       - name: Update
         run: |
@@ -92,7 +92,9 @@ jobs:
       fail-fast: true
       matrix:
         include:
-          - os: macos-latest
+          - os: macos-13
+            python-version: '3.11'
+          - os: macos-14
             python-version: '3.11'
           - os: windows-latest
             python-version: '3.11'
@@ -100,53 +102,55 @@ jobs:
       - uses: actions/checkout at v4
 
       - name: Conda Setup
-        uses: s-weigand/setup-conda at v1
+        uses: conda-incubator/setup-miniconda at v3
         with:
-          conda-channels: conda-forge
+          miniforge-variant: Mambaforge
+          miniforge-version: latest
+          use-mamba: true
+          auto-update-conda: true
+          use-only-tar-bz2: false
 
       - name: Install Env (OSX)
-        if: matrix.os == 'macos-latest'
-        shell: bash
+        if: matrix.os == 'macos-13' || matrix.os == 'macos-14'
+        shell: bash -l {0}
         run: |
           conda config --prepend channels conda-forge
           conda config --set channel_priority strict
-          conda create -n test python=${{ matrix.python-version }} libgdal geos=3.10.3 cython=3
-          source activate test
+          conda create -n test python=${{ matrix.python-version }} libgdal geos=3.11 cython=3
+          conda activate test
           python -m pip install -e . || python -m pip install -e .
           python -m pip install -r requirements-dev.txt
 
       - name: Install Env (Windows)
         if: matrix.os == 'windows-latest'
-        shell: bash
+        shell: bash -l {0}
         run: |
           conda config --prepend channels conda-forge
           conda config --set channel_priority strict
-          conda create -n test python=${{ matrix.python-version }} libgdal geos=3.10.3 cython=3
-          source activate test
-          GDAL_VERSION="3.5" python setup.py build_ext -I"C:\\Miniconda\\envs\\test\\Library\\include" -lgdal_i -L"C:\\Miniconda\\envs\\test\\Library\\lib" install
+          conda create -n test python=${{ matrix.python-version }} libgdal geos=3.11 cython=3
+          conda activate test
+          GDAL_VERSION="3.7" python setup.py build_ext -I"/c/Users/runneradmin/miniconda3/envs/test/Library/include" -lgdal -L"/c/Users/runneradmin/miniconda3/envs/test/Library/lib" install
           python -m pip install -r requirements-dev.txt
 
       - name: Check and Log Environment
-        shell: bash
+        shell: bash -l {0}
         run: |
-          source activate test
+          conda activate test
           python -V
           conda info
-          conda list
 
       - name: Test with Coverage (Windows)
         if: matrix.os == 'windows-latest'
-        shell: bash
+        shell: bash -l {0}
         run: |
-          source activate test
+          conda activate test
           pytest -v -m "not wheel" -rxXs --cov fiona --cov-report term-missing
 
       - name: Test with Coverage (OSX)
-        if: matrix.os == 'macos-latest'
-        shell: bash
+        if: matrix.os == 'macos-13'
+        shell: bash -l {0}
         run: |
-          source activate test
+          conda activate test
           python -m pytest -v -m "not wheel" -rxXs  --cov fiona --cov-report term-missing
 
-
       - uses: codecov/codecov-action at v3


=====================================
CHANGES.txt
=====================================
@@ -3,6 +3,24 @@ Changes
 
 All issue numbers are relative to https://github.com/Toblerity/Fiona/issues.
 
+1.10b2 (2024-07-10)
+-------------------
+
+Bug fixes:
+
+- The Pyopener registry and VSI plugin have been rewritten to avoid filename
+  conflicts and to be compatible with multithreading. Now, a new plugin handler
+  is registered for each instance of using an opener (#1408). Before GDAL 3.9.0
+  plugin handlers cannot not be removed and so it may be observed that the size
+  of the Pyopener registry grows during the execution of a program.
+- A CSLConstList ctypedef has been added and is used where appropriate (#1404).
+- Fiona model objects have a informative, printable representation again
+  (#1380).
+
+Packaging:
+
+- PyPI wheels include GDAL 3.9.1 and curl 8.8.0.
+
 1.10b1 (2024-04-16)
 -------------------
 


=====================================
Makefile
=====================================
@@ -33,7 +33,7 @@ dockertestimage:
 	docker build --target gdal --build-arg GDAL=$(GDAL) --build-arg PYTHON_VERSION=$(PYTHON_VERSION) -t fiona:$(GDAL)-py$(PYTHON_VERSION) .
 
 dockertest: dockertestimage
-	docker run -it -v $(shell pwd):/app -v /tmp:/tmp --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY --entrypoint=/bin/bash fiona:$(GDAL)-py$(PYTHON_VERSION) -c '/venv/bin/python -m pip install --editable .[all] --no-build-isolation && /venv/bin/python -B -m pytest -m "not wheel" --cov fiona --cov-report term-missing $(OPTS)'
+	docker run -it -v $(shell pwd):/app -v /tmp:/tmp --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY --entrypoint=/bin/bash fiona:$(GDAL)-py$(PYTHON_VERSION) -c '/venv/bin/python -m pip install -vvv --editable .[all] --no-build-isolation && /venv/bin/python -B -m pytest -m "not wheel" --cov fiona --cov-report term-missing $(OPTS)'
 
 dockershell: dockertestimage
 	docker run -it -v $(shell pwd):/app --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY --entrypoint=/bin/bash fiona:$(GDAL)-py$(PYTHON_VERSION) -c '/venv/bin/python -m pip install --editable . --no-build-isolation && /bin/bash'


=====================================
ci/rstcheck/requirements.txt
=====================================
@@ -8,7 +8,7 @@ alabaster==0.7.13
     # via sphinx
 babel==2.12.1
     # via sphinx
-certifi==2023.7.22
+certifi==2024.7.4
     # via requests
 charset-normalizer==3.2.0
     # via requests
@@ -22,30 +22,28 @@ docutils==0.19
     # via
     #   rstcheck-core
     #   sphinx
-idna==3.4
+idna==3.7
     # via requests
 imagesize==1.4.1
     # via sphinx
-jinja2==3.1.3
+jinja2==3.1.4
     # via sphinx
 markupsafe==2.1.3
     # via jinja2
 packaging==23.1
     # via sphinx
-pydantic==1.10.12
+pydantic==1.10.13
     # via rstcheck-core
 pygments==2.16.1
     # via
     #   rich
     #   sphinx
-requests==2.31.0
+requests==2.32.0
     # via sphinx
 rich==12.6.0
     # via typer
 rstcheck==6.1.2
-    # via
-    #   -r requirements.in
-    #   rstcheck
+    # via -r requirements.in
 rstcheck-core==1.0.3
     # via rstcheck
 shellingham==1.5.3
@@ -73,12 +71,10 @@ sphinxcontrib-qthelp==1.0.6
 sphinxcontrib-serializinghtml==1.1.9
     # via sphinx
 typer==0.7.0
-    # via
-    #   rstcheck
-    #   typer
+    # via rstcheck
 types-docutils==0.19.1.9
     # via rstcheck-core
 typing-extensions==4.7.1
     # via pydantic
-urllib3==2.0.7
+urllib3==2.2.2
     # via requests


=====================================
fiona/__init__.py
=====================================
@@ -78,7 +78,7 @@ __all__ = [
     "remove",
 ]
 
-__version__ = "1.10b1"
+__version__ = "1.10b2"
 __gdal_version__ = get_gdal_release_name()
 
 gdal_version = get_gdal_version_tuple()
@@ -99,6 +99,10 @@ def open(
     vfs=None,
     enabled_drivers=None,
     crs_wkt=None,
+    ignore_fields=None,
+    ignore_geometry=False,
+    include_fields=None,
+    wkt_version=None,
     allow_unsupported_drivers=False,
     opener=None,
     **kwargs
@@ -150,6 +154,11 @@ def open(
       fiona.open(
           'example.shp', enabled_drivers=['GeoJSON', 'ESRI Shapefile'])
 
+    Some format drivers permit low-level filtering of fields. Specific
+    fields can be ommitted by using the ``ignore_fields`` parameter.
+    Specific fields can be selected, excluding all others, by using the
+    ``include_fields`` parameter.
+
     Parameters
     ----------
     fp : URI (str or pathlib.Path), or file-like object
@@ -177,12 +186,12 @@ def open(
     crs_wkt : str
         An optional WKT representation of a coordinate reference
         system.
-    ignore_fields : list
+    ignore_fields : list[str], optional
         List of field names to ignore on load.
+    include_fields : list[str], optional
+        List of a subset of field names to include on load.
     ignore_geometry : bool
         Ignore the geometry on load.
-    include_fields : list
-        List of a subset of field names to include on load.
     wkt_version : fiona.enums.WktVersion or str, optional
         Version to use to for the CRS WKT.
         Defaults to GDAL's default (WKT1_GDAL for GDAL 3).
@@ -209,6 +218,12 @@ def open(
     -------
     Collection
 
+    Raises
+    ------
+    DriverError
+        When the selected format driver cannot provide requested
+        capabilities such as ignoring fields.
+
     """
     if mode == "r" and hasattr(fp, "read"):
         memfile = MemoryFile(fp.read())
@@ -218,6 +233,10 @@ def open(
             schema=schema,
             layer=layer,
             encoding=encoding,
+            ignore_fields=ignore_fields,
+            include_fields=include_fields,
+            ignore_geometry=ignore_geometry,
+            wkt_version=wkt_version,
             enabled_drivers=enabled_drivers,
             allow_unsupported_drivers=allow_unsupported_drivers,
             **kwargs
@@ -233,6 +252,10 @@ def open(
             schema=schema,
             layer=layer,
             encoding=encoding,
+            ignore_fields=ignore_fields,
+            include_fields=include_fields,
+            ignore_geometry=ignore_geometry,
+            wkt_version=wkt_version,
             enabled_drivers=enabled_drivers,
             allow_unsupported_drivers=allow_unsupported_drivers,
             crs_wkt=crs_wkt,
@@ -273,6 +296,10 @@ def open(
                 schema=schema,
                 layer=layer,
                 encoding=encoding,
+                ignore_fields=ignore_fields,
+                include_fields=include_fields,
+                ignore_geometry=ignore_geometry,
+                wkt_version=wkt_version,
                 enabled_drivers=enabled_drivers,
                 allow_unsupported_drivers=allow_unsupported_drivers,
                 crs_wkt=crs_wkt,
@@ -297,7 +324,7 @@ def open(
                 log.debug("Registering opener: raw_dataset_path=%r, opener=%r", raw_dataset_path, opener)
                 vsi_path_ctx = _opener_registration(raw_dataset_path, opener)
                 registered_vsi_path = stack.enter_context(vsi_path_ctx)
-                log.debug("Registered vsi path: registered_vsi_path%r", registered_vsi_path)
+                log.debug("Registered vsi path: registered_vsi_path=%r", registered_vsi_path)
                 path = _UnparsedPath(registered_vsi_path)
             else:
                 if vfs:
@@ -318,6 +345,10 @@ def open(
                     driver=driver,
                     encoding=encoding,
                     layer=layer,
+                    ignore_fields=ignore_fields,
+                    include_fields=include_fields,
+                    ignore_geometry=ignore_geometry,
+                    wkt_version=wkt_version,
                     enabled_drivers=enabled_drivers,
                     allow_unsupported_drivers=allow_unsupported_drivers,
                     **kwargs
@@ -331,6 +362,10 @@ def open(
                     schema=schema,
                     encoding=encoding,
                     layer=layer,
+                    ignore_fields=ignore_fields,
+                    include_fields=include_fields,
+                    ignore_geometry=ignore_geometry,
+                    wkt_version=wkt_version,
                     enabled_drivers=enabled_drivers,
                     crs_wkt=crs_wkt,
                     allow_unsupported_drivers=allow_unsupported_drivers,
@@ -351,7 +386,7 @@ collection = open
 
 
 @ensure_env_with_credentials
-def remove(path_or_collection, driver=None, layer=None):
+def remove(path_or_collection, driver=None, layer=None, opener=None):
     """Delete an OGR data source or one of its layers.
 
     If no layer is specified, the entire dataset and all of its layers
@@ -361,6 +396,19 @@ def remove(path_or_collection, driver=None, layer=None):
     ----------
     path_or_collection : str, pathlib.Path, or Collection
         The target Collection or its path.
+    opener : callable or obj, optional
+        A custom dataset opener which can serve GDAL's virtual
+        filesystem machinery via Python file-like objects. The
+        underlying file-like object is obtained by calling *opener* with
+        (*fp*, *mode*) or (*fp*, *mode* + "b") depending on the format
+        driver's native mode. *opener* must return a Python file-like
+        object that provides read, seek, tell, and close methods. Note:
+        only one opener at a time per fp, mode pair is allowed.
+
+        Alternatively, opener may be a filesystem object from a package
+        like fsspec that provides the following methods: isdir(),
+        isfile(), ls(), mtime(), open(), and size(). The exact interface
+        is defined in the fiona._vsiopener._AbstractOpener class.
     driver : str, optional
         The name of a driver to be used for deletion, optional. Can
         usually be detected.
@@ -379,21 +427,37 @@ def remove(path_or_collection, driver=None, layer=None):
     """
     if isinstance(path_or_collection, Collection):
         collection = path_or_collection
-        path = collection.path
+        raw_dataset_path = collection.path
         driver = collection.driver
         collection.close()
-    elif isinstance(path_or_collection, Path):
-        path = str(path_or_collection)
+
     else:
-        path = path_or_collection
-    if layer is None:
-        _remove(path, driver)
+        fp = path_or_collection
+        if hasattr(fp, "path") and hasattr(fp, "fs"):
+            log.debug("Detected fp is an OpenFile: fp=%r", fp)
+            raw_dataset_path = fp.path
+            opener = fp.fs.open
+        else:
+            raw_dataset_path = os.fspath(fp)
+
+    if opener:
+        log.debug("Registering opener: raw_dataset_path=%r, opener=%r", raw_dataset_path, opener)
+        with _opener_registration(raw_dataset_path, opener) as registered_vsi_path:
+            log.debug("Registered vsi path: registered_vsi_path=%r", registered_vsi_path)
+            if layer is None:
+                _remove(registered_vsi_path, driver)
+            else:
+                _remove_layer(registered_vsi_path, layer, driver)
     else:
-        _remove_layer(path, layer, driver)
+        pobj = _parse_path(raw_dataset_path)
+        if layer is None:
+            _remove(_vsi_path(pobj), driver)
+        else:
+            _remove_layer(_vsi_path(pobj), layer, driver)
 
 
 @ensure_env_with_credentials
-def listdir(fp):
+def listdir(fp, opener=None):
     """Lists the datasets in a directory or archive file.
 
     Archive files must be prefixed like "zip://" or "tar://".
@@ -402,6 +466,19 @@ def listdir(fp):
     ----------
     fp : str or pathlib.Path
         Directory or archive path.
+    opener : callable or obj, optional
+        A custom dataset opener which can serve GDAL's virtual
+        filesystem machinery via Python file-like objects. The
+        underlying file-like object is obtained by calling *opener* with
+        (*fp*, *mode*) or (*fp*, *mode* + "b") depending on the format
+        driver's native mode. *opener* must return a Python file-like
+        object that provides read, seek, tell, and close methods. Note:
+        only one opener at a time per fp, mode pair is allowed.
+
+        Alternatively, opener may be a filesystem object from a package
+        like fsspec that provides the following methods: isdir(),
+        isfile(), ls(), mtime(), open(), and size(). The exact interface
+        is defined in the fiona._vsiopener._AbstractOpener class.
 
     Returns
     -------
@@ -414,18 +491,25 @@ def listdir(fp):
         If the input is not a str or Path.
 
     """
-    if isinstance(fp, Path):
-        fp = str(fp)
-
-    if not isinstance(fp, str):
-        raise TypeError("invalid path: %r" % fp)
+    if hasattr(fp, "path") and hasattr(fp, "fs"):
+        log.debug("Detected fp is an OpenFile: fp=%r", fp)
+        raw_dataset_path = fp.path
+        opener = fp.fs.open
+    else:
+        raw_dataset_path = os.fspath(fp)
 
-    pobj = _parse_path(fp)
-    return _listdir(_vsi_path(pobj))
+    if opener:
+        log.debug("Registering opener: raw_dataset_path=%r, opener=%r", raw_dataset_path, opener)
+        with _opener_registration(raw_dataset_path, opener) as registered_vsi_path:
+            log.debug("Registered vsi path: registered_vsi_path=%r", registered_vsi_path)
+            return _listdir(registered_vsi_path)
+    else:
+        pobj = _parse_path(raw_dataset_path)
+        return _listdir(_vsi_path(pobj))
 
 
 @ensure_env_with_credentials
-def listlayers(fp, vfs=None, **kwargs):
+def listlayers(fp, opener=None, vfs=None, **kwargs):
     """Lists the layers (collections) in a dataset.
 
     Archive files must be prefixed like "zip://" or "tar://".
@@ -434,6 +518,19 @@ def listlayers(fp, vfs=None, **kwargs):
     ----------
     fp : str, pathlib.Path, or file-like object
         A dataset identifier or file object containing a dataset.
+    opener : callable or obj, optional
+        A custom dataset opener which can serve GDAL's virtual
+        filesystem machinery via Python file-like objects. The
+        underlying file-like object is obtained by calling *opener* with
+        (*fp*, *mode*) or (*fp*, *mode* + "b") depending on the format
+        driver's native mode. *opener* must return a Python file-like
+        object that provides read, seek, tell, and close methods. Note:
+        only one opener at a time per fp, mode pair is allowed.
+
+        Alternatively, opener may be a filesystem object from a package
+        like fsspec that provides the following methods: isdir(),
+        isfile(), ls(), mtime(), open(), and size(). The exact interface
+        is defined in the fiona._vsiopener._AbstractOpener class.
     vfs : str
         This is a deprecated parameter. A URI scheme such as "zip://"
         should be used instead.
@@ -451,18 +548,26 @@ def listlayers(fp, vfs=None, **kwargs):
         If the input is not a str, Path, or file object.
 
     """
+    if vfs and not isinstance(vfs, str):
+        raise TypeError(f"invalid vfs: {vfs!r}")
+
     if hasattr(fp, 'read'):
         with MemoryFile(fp.read()) as memfile:
             return _listlayers(memfile.name, **kwargs)
-    else:
-        if isinstance(fp, Path):
-            fp = str(fp)
 
-        if not isinstance(fp, str):
-            raise TypeError(f"invalid path: {fp!r}")
-        if vfs and not isinstance(vfs, str):
-            raise TypeError(f"invalid vfs: {vfs!r}")
+    if hasattr(fp, "path") and hasattr(fp, "fs"):
+        log.debug("Detected fp is an OpenFile: fp=%r", fp)
+        raw_dataset_path = fp.path
+        opener = fp.fs.open
+    else:
+        raw_dataset_path = os.fspath(fp)
 
+    if opener:
+        log.debug("Registering opener: raw_dataset_path=%r, opener=%r", raw_dataset_path, opener)
+        with _opener_registration(raw_dataset_path, opener) as registered_vsi_path:
+            log.debug("Registered vsi path: registered_vsi_path=%r", registered_vsi_path)
+            return _listlayers(registered_vsi_path, **kwargs)
+    else:
         if vfs:
             warnings.warn(
                 "The vfs keyword argument is deprecated and will be removed in 2.0. "
@@ -471,10 +576,10 @@ def listlayers(fp, vfs=None, **kwargs):
                 stacklevel=2,
             )
             pobj_vfs = _parse_path(vfs)
-            pobj_path = _parse_path(fp)
+            pobj_path = _parse_path(raw_dataset_path)
             pobj = _ParsedPath(pobj_path.path, pobj_vfs.path, pobj_vfs.scheme)
         else:
-            pobj = _parse_path(fp)
+            pobj = _parse_path(raw_dataset_path)
 
         return _listlayers(_vsi_path(pobj), **kwargs)
 


=====================================
fiona/_env.pxd
=====================================
@@ -1,11 +1,6 @@
 include "gdal.pxi"
 
 
-cdef extern from "ogr_srs_api.h":
-    void OSRSetPROJSearchPaths(const char *const *papszPaths)
-    void OSRGetPROJVersion	(int *pnMajor, int *pnMinor, int *pnPatch)
-
-
 cdef class ConfigEnv(object):
     cdef public object options
 


=====================================
fiona/_env.pyx
=====================================
@@ -17,7 +17,6 @@ import threading
 
 from fiona._err cimport exc_wrap_int, exc_wrap_ogrerr
 from fiona._err import CPLE_BaseError
-from fiona._vsiopener cimport install_pyopener_plugin
 from fiona.errors import EnvError
 
 level_map = {
@@ -406,10 +405,8 @@ cdef class GDALEnv(ConfigEnv):
         if not self._have_registered_drivers:
             with threading.Lock():
                 if not self._have_registered_drivers:
-
                     GDALAllRegister()
                     OGRRegisterAll()
-                    install_pyopener_plugin(pyopener_plugin)
 
                     if 'GDAL_DATA' in os.environ:
                         log.debug("GDAL_DATA found in environment.")


=====================================
fiona/_err.pxd
=====================================
@@ -1,15 +1,14 @@
-from libc.stdio cimport *
-
-cdef extern from "cpl_vsi.h":
-
-    ctypedef FILE VSILFILE
+include "gdal.pxi"
 
-cdef extern from "ogr_core.h":
-
-    ctypedef int OGRErr
+from libc.stdio cimport *
 
 cdef get_last_error_msg()
 cdef int exc_wrap_int(int retval) except -1
 cdef OGRErr exc_wrap_ogrerr(OGRErr retval) except -1
 cdef void *exc_wrap_pointer(void *ptr) except NULL
 cdef VSILFILE *exc_wrap_vsilfile(VSILFILE *f) except NULL
+
+cdef class StackChecker:
+    cdef object error_stack
+    cdef int exc_wrap_int(self, int retval) except -1
+    cdef void *exc_wrap_pointer(self, void *ptr) except NULL


=====================================
fiona/_err.pyx
=====================================
@@ -29,23 +29,17 @@ manager raises a more useful and informative error:
     ValueError: The PNG driver does not support update access to existing datasets.
 """
 
-# CPL function declarations.
-cdef extern from "cpl_error.h":
-
-    ctypedef enum CPLErr:
-        CE_None
-        CE_Debug
-        CE_Warning
-        CE_Failure
-        CE_Fatal
+import contextlib
+from contextvars import ContextVar
+from enum import IntEnum
+from itertools import zip_longest
+import logging
 
-    int CPLGetLastErrorNo()
-    const char* CPLGetLastErrorMsg()
-    int CPLGetLastErrorType()
-    void CPLErrorReset()
+log = logging.getLogger(__name__)
 
+_ERROR_STACK = ContextVar("error_stack")
+_ERROR_STACK.set([])
 
-from enum import IntEnum
 
 # Python exceptions expressing the CPL error numbers.
 
@@ -132,6 +126,10 @@ class CPLE_AWSSignatureDoesNotMatchError(CPLE_BaseError):
     pass
 
 
+class CPLE_AWSError(CPLE_BaseError):
+    pass
+
+
 class FionaNullPointerError(CPLE_BaseError):
     """
     Returned from exc_wrap_pointer when a NULL pointer is passed, but no GDAL
@@ -148,6 +146,14 @@ class FionaCPLError(CPLE_BaseError):
     pass
 
 
+cdef dict _LEVEL_MAP = {
+    0: 0,
+    1: logging.DEBUG,
+    2: logging.WARNING,
+    3: logging.ERROR,
+    4: logging.CRITICAL
+}
+
 # Map of GDAL error numbers to the Python exceptions.
 exception_map = {
     1: CPLE_AppDefinedError,
@@ -168,8 +174,30 @@ exception_map = {
     13: CPLE_AWSObjectNotFoundError,
     14: CPLE_AWSAccessDeniedError,
     15: CPLE_AWSInvalidCredentialsError,
-    16: CPLE_AWSSignatureDoesNotMatchError}
-
+    16: CPLE_AWSSignatureDoesNotMatchError,
+    17: CPLE_AWSError
+}
+
+cdef dict _CODE_MAP = {
+    0: 'CPLE_None',
+    1: 'CPLE_AppDefined',
+    2: 'CPLE_OutOfMemory',
+    3: 'CPLE_FileIO',
+    4: 'CPLE_OpenFailed',
+    5: 'CPLE_IllegalArg',
+    6: 'CPLE_NotSupported',
+    7: 'CPLE_AssertionFailed',
+    8: 'CPLE_NoWriteAccess',
+    9: 'CPLE_UserInterrupt',
+    10: 'ObjectNull',
+    11: 'CPLE_HttpResponse',
+    12: 'CPLE_AWSBucketNotFound',
+    13: 'CPLE_AWSObjectNotFound',
+    14: 'CPLE_AWSAccessDenied',
+    15: 'CPLE_AWSInvalidCredentials',
+    16: 'CPLE_AWSSignatureDoesNotMatch',
+    17: 'CPLE_AWSError'
+}
 
 # CPL Error types as an enum.
 class GDALError(IntEnum):
@@ -305,3 +333,127 @@ cdef VSILFILE *exc_wrap_vsilfile(VSILFILE *f) except NULL:
     return f
 
 cpl_errs = GDALErrCtxManager()
+
+
+cdef class StackChecker:
+
+    def __init__(self, error_stack=None):
+        self.error_stack = error_stack or {}
+
+    cdef int exc_wrap_int(self, int err) except -1:
+        """Wrap a GDAL/OGR function that returns CPLErr (int).
+
+        Raises a Rasterio exception if a non-fatal error has be set.
+        """
+        if err:
+            stack = self.error_stack.get()
+            for error, cause in zip_longest(stack[::-1], stack[::-1][1:]):
+                if error is not None and cause is not None:
+                    error.__cause__ = cause
+
+            if stack:
+                last = stack.pop()
+                if last is not None:
+                    raise last
+
+        return err
+
+    cdef void *exc_wrap_pointer(self, void *ptr) except NULL:
+        """Wrap a GDAL/OGR function that returns a pointer.
+
+        Raises a Rasterio exception if a non-fatal error has be set.
+        """
+        if ptr == NULL:
+            stack = self.error_stack.get()
+            for error, cause in zip_longest(stack[::-1], stack[::-1][1:]):
+                if error is not None and cause is not None:
+                    error.__cause__ = cause
+
+            if stack:
+                last = stack.pop()
+                if last is not None:
+                    raise last
+
+        return ptr
+
+
+cdef void log_error(
+    CPLErr err_class,
+    int err_no,
+    const char* msg,
+) noexcept with gil:
+    """Send CPL errors to Python's logger.
+
+    Because this function is called by GDAL with no Python context, we
+    can't propagate exceptions that we might raise here. They'll be
+    ignored.
+
+    """
+    if err_no in _CODE_MAP:
+        # We've observed that some GDAL functions may emit multiple
+        # ERROR level messages and yet succeed. We want to see those
+        # messages in our log file, but not at the ERROR level. We
+        # turn the level down to INFO.
+        if err_class == 3:
+            log.info(
+                "GDAL signalled an error: err_no=%r, msg=%r",
+                err_no,
+                msg.decode("utf-8")
+            )
+        elif err_no == 0:
+            log.log(_LEVEL_MAP[err_class], "%s", msg.decode("utf-8"))
+        else:
+            log.log(_LEVEL_MAP[err_class], "%s:%s", _CODE_MAP[err_no], msg.decode("utf-8"))
+    else:
+        log.info("Unknown error number %r", err_no)
+
+
+IF UNAME_SYSNAME == "Windows":
+    cdef void __stdcall chaining_error_handler(
+        CPLErr err_class,
+        int err_no,
+        const char* msg
+    ) noexcept with gil:
+        global _ERROR_STACK
+        log_error(err_class, err_no, msg)
+        if err_class == 3:
+            stack = _ERROR_STACK.get()
+            stack.append(
+                exception_map.get(err_no, CPLE_BaseError)(err_class, err_no, msg.decode("utf-8")),
+            )
+            _ERROR_STACK.set(stack)
+ELSE:
+    cdef void chaining_error_handler(
+        CPLErr err_class,
+        int err_no,
+        const char* msg
+    ) noexcept with gil:
+        global _ERROR_STACK
+        log_error(err_class, err_no, msg)
+        if err_class == 3:
+            stack = _ERROR_STACK.get()
+            stack.append(
+                exception_map.get(err_no, CPLE_BaseError)(err_class, err_no, msg.decode("utf-8")),
+            )
+            _ERROR_STACK.set(stack)
+
+
+ at contextlib.contextmanager
+def stack_errors():
+    # TODO: better name?
+    # Note: this manager produces one chain of errors and thus assumes
+    # that no more than one GDAL function is called.
+    CPLErrorReset()
+    global _ERROR_STACK
+    _ERROR_STACK.set([])
+
+    # chaining_error_handler (better name a TODO) records GDAL errors
+    # in the order they occur and converts to exceptions.
+    CPLPushErrorHandlerEx(<CPLErrorHandler>chaining_error_handler, NULL)
+
+    # Run code in the `with` block.
+    yield StackChecker(_ERROR_STACK)
+
+    CPLPopErrorHandler()
+    _ERROR_STACK.set([])
+    CPLErrorReset()


=====================================
fiona/_geometry.pyx
=====================================
@@ -20,6 +20,23 @@ log.addHandler(NullHandler())
 # mapping of GeoJSON type names to OGR integer geometry types
 GEOJSON2OGR_GEOMETRY_TYPES = dict((v, k) for k, v in GEOMETRY_TYPES.items())
 
+cdef set LINEAR_GEOM_TYPES = {
+    OGRGeometryType.CircularString.value,
+    OGRGeometryType.CompoundCurve.value,
+    OGRGeometryType.CurvePolygon.value,
+    OGRGeometryType.MultiCurve.value,
+    OGRGeometryType.MultiSurface.value,
+    # OGRGeometryType.Curve.value,  # Abstract type
+    # OGRGeometryType.Surface.value,  # Abstract type
+}
+
+cdef set PS_TIN_Tri_TYPES = {
+    OGRGeometryType.PolyhedralSurface.value,
+    OGRGeometryType.TIN.value,
+    OGRGeometryType.Triangle.value
+}
+
+
 cdef int ogr_get_geometry_type(void *geometry):
     # OGR_G_GetGeometryType with NULL geometry support
     if geometry == NULL:
@@ -137,14 +154,11 @@ cdef class GeomBuilder:
         parts = []
         j = 0
         count = OGR_G_GetGeometryCount(geom)
+
         while j < count:
             part = OGR_G_GetGeometryRef(geom, j)
-            code = base_geometry_type_code(ogr_get_geometry_type(part))
-            if code in (
-                OGRGeometryType.PolyhedralSurface.value,
-                OGRGeometryType.TIN.value,
-                OGRGeometryType.Triangle.value,
-            ):
+            code = base_geometry_type_code(OGR_G_GetGeometryType(part))
+            if code in PS_TIN_Tri_TYPES:
                 OGR_G_RemoveGeometry(geom, j, False)
                 # Removing a geometry will cause the geometry count to drop by one,
                 # and all “higher” geometries will shuffle down one in index.
@@ -186,11 +200,7 @@ cdef class GeomBuilder:
 
         # We need to take ownership of the geometry before we can call 
         # OGR_G_ForceToPolygon or OGR_G_ForceToMultiPolygon
-        if code in (
-            OGRGeometryType.PolyhedralSurface.value,
-            OGRGeometryType.TIN.value,
-            OGRGeometryType.Triangle.value,
-        ):
+        if code in PS_TIN_Tri_TYPES:
             cogr_geometry = OGR_F_StealGeometry(feature)
         return self.build(cogr_geometry)
 
@@ -206,28 +216,16 @@ cdef class GeomBuilder:
 
         # We convert special geometries (Curves, TIN, Triangle, ...)
         # to GeoJSON compatible geometries (LineStrings, Polygons, MultiPolygon, ...)
-        if code in (
-            OGRGeometryType.CircularString.value,
-            OGRGeometryType.CompoundCurve.value,
-            OGRGeometryType.CurvePolygon.value,
-            OGRGeometryType.MultiCurve.value,
-            OGRGeometryType.MultiSurface.value,
-            # OGRGeometryType.Curve.value,  # Abstract type
-            # OGRGeometryType.Surface.value,  # Abstract type
-        ):
+        if code in LINEAR_GEOM_TYPES:
             geometry_to_dealloc = OGR_G_GetLinearGeometry(geom, 0.0, NULL)
             code = base_geometry_type_code(ogr_get_geometry_type(geometry_to_dealloc))
             geom = geometry_to_dealloc
-        elif code in (
-            OGRGeometryType.PolyhedralSurface.value,
-            OGRGeometryType.TIN.value,
-            OGRGeometryType.Triangle.value,
-        ):
-            if code in (OGRGeometryType.PolyhedralSurface.value, OGRGeometryType.TIN.value):
-                geometry_to_dealloc = OGR_G_ForceToMultiPolygon(geom)
-            elif code == OGRGeometryType.Triangle.value:
+        elif code in PS_TIN_Tri_TYPES:
+            if code == OGRGeometryType.Triangle.value:
                 geometry_to_dealloc = OGR_G_ForceToPolygon(geom)
-            code = base_geometry_type_code(ogr_get_geometry_type(geometry_to_dealloc))
+            else:
+                geometry_to_dealloc = OGR_G_ForceToMultiPolygon(geom)
+            code = base_geometry_type_code(OGR_G_GetGeometryType(geometry_to_dealloc))
             geom = geometry_to_dealloc
         self.ndims = OGR_G_GetCoordinateDimension(geom)
 


=====================================
fiona/_vsiopener.pxd
=====================================
@@ -1,4 +1 @@
 include "gdal.pxi"
-
-cdef int install_pyopener_plugin(VSIFilesystemPluginCallbacksStruct *callbacks_struct)
-cdef void uninstall_pyopener_plugin(VSIFilesystemPluginCallbacksStruct *callbacks_struct)


=====================================
fiona/_vsiopener.pyx
=====================================
@@ -8,19 +8,17 @@ from contextvars import ContextVar
 import logging
 import os
 from pathlib import Path
-
 import stat
+from uuid import uuid4
 
 from libc.string cimport memcpy
 
+from fiona._env import get_gdal_version_tuple
 from fiona.errors import OpenerRegistrationError
 
 log = logging.getLogger(__name__)
 
-# Prefix for all in-memory paths used by GDAL's VSI system
-# Except for errors and log messages this shouldn't really be seen by the user
-cdef str PREFIX = "/vsifiopener/"
-cdef bytes PREFIX_BYTES = PREFIX.encode("utf-8")
+cdef str VSI_NS_ROOT = "vsifiopener"
 
 # This is global state for the Python filesystem plugin. It currently only
 # contains path -> PyOpenerBase (or subclass) instances. This is used by
@@ -33,38 +31,12 @@ _OPEN_FILE_EXIT_STACKS = ContextVar("open_file_exit_stacks")
 _OPEN_FILE_EXIT_STACKS.set({})
 
 
-cdef int install_pyopener_plugin(VSIFilesystemPluginCallbacksStruct *callbacks_struct):
-    """Install handlers for python file openers if it isn't already installed."""
-    cdef char **registered_prefixes = VSIGetFileSystemsPrefixes()
-    cdef int prefix_index = CSLFindString(registered_prefixes, PREFIX_BYTES)
-    CSLDestroy(registered_prefixes)
-
-    if prefix_index < 0:
-        log.debug("Installing Python opener handler plugin...")
-        callbacks_struct = VSIAllocFilesystemPluginCallbacksStruct()
-        callbacks_struct.open = <VSIFilesystemPluginOpenCallback>pyopener_open
-        callbacks_struct.eof = <VSIFilesystemPluginEofCallback>pyopener_eof
-        callbacks_struct.tell = <VSIFilesystemPluginTellCallback>pyopener_tell
-        callbacks_struct.seek = <VSIFilesystemPluginSeekCallback>pyopener_seek
-        callbacks_struct.read = <VSIFilesystemPluginReadCallback>pyopener_read
-        callbacks_struct.write = <VSIFilesystemPluginWriteCallback>pyopener_write
-        callbacks_struct.flush = <VSIFilesystemPluginFlushCallback>pyopener_flush
-        callbacks_struct.close = <VSIFilesystemPluginCloseCallback>pyopener_close
-        callbacks_struct.read_dir = <VSIFilesystemPluginReadDirCallback>pyopener_read_dir
-        callbacks_struct.stat = <VSIFilesystemPluginStatCallback>pyopener_stat
-        callbacks_struct.pUserData = <void*>_OPENER_REGISTRY
-        retval = VSIInstallPluginHandler(PREFIX_BYTES, callbacks_struct)
-        VSIFreeFilesystemPluginCallbacksStruct(callbacks_struct)
-        return retval
-    else:
-        return 0
-
-
-cdef void uninstall_pyopener_plugin(VSIFilesystemPluginCallbacksStruct *callbacks_struct):
-    if callbacks_struct is not NULL:
-        callbacks_struct.pUserData = NULL
-        VSIFreeFilesystemPluginCallbacksStruct(callbacks_struct)
-    callbacks_struct = NULL
+# When an opener is registered for a path, this structure captures the
+# path and unique registration instance. VSI stat, read_dir, and open
+# calls have access to the struct instance.
+cdef struct FSData:
+    char *path
+    char *uuid
 
 
 cdef int pyopener_stat(
@@ -74,14 +46,20 @@ cdef int pyopener_stat(
     int nFlags
 ) with gil:
     """Provides POSIX stat data to GDAL from a Python filesystem."""
-    # Convert the given filename to a registry key.
-    # Reminder: openers are registered by URI scheme, authority, and 
-    # *directory* path.
+    cdef FSData *fsdata = <FSData *>pUserData
+    path = fsdata.path.decode("utf-8")
+    uuid = fsdata.uuid.decode("utf-8")
+    key = (Path(path), uuid)
     urlpath = pszFilename.decode("utf-8")
-    key = Path(urlpath).parent
 
     registry = _OPENER_REGISTRY.get()
-    log.debug("Looking up opener in pyopener_stat: registry=%r, key=%r", registry, key)
+    log.debug(
+        "Looking up opener in pyopener_stat: urlpath=%r, registry=%r, key=%r",
+        urlpath,
+        registry,
+        key
+    )
+
     try:
         file_opener = registry[key]
     except KeyError as err:
@@ -91,15 +69,15 @@ cdef int pyopener_stat(
 
     try:
         if file_opener.isfile(urlpath):
-            fmode = 0o170000 | stat.S_IFREG
+            fmode = stat.S_IFREG
         elif file_opener.isdir(urlpath):
-            fmode = 0o170000 | stat.S_IFDIR
+            fmode = stat.S_IFDIR
         else:
             # No such file or directory.
             return -1
         size = file_opener.size(urlpath)
         mtime = file_opener.mtime(urlpath)
-    except (FileNotFoundError, KeyError):
+    except (FileNotFoundError, KeyError) as err:
         # No such file or directory.
         return -1
     except Exception as err:
@@ -113,17 +91,64 @@ cdef int pyopener_stat(
     return 0
 
 
+cdef int pyopener_unlink(
+    void *pUserData,
+    const char *pszFilename,
+) with gil:
+    """Unlink a file from a Python filesystem."""
+    cdef FSData *fsdata = <FSData *>pUserData
+    path = fsdata.path.decode("utf-8")
+    uuid = fsdata.uuid.decode("utf-8")
+    key = (Path(path), uuid)
+    urlpath = pszFilename.decode("utf-8")
+
+    registry = _OPENER_REGISTRY.get()
+    log.debug(
+        "Looking up opener in pyopener_unlink: urlpath=%r, registry=%r, key=%r",
+        urlpath,
+        registry,
+        key
+    )
+
+    try:
+        file_opener = registry[key]
+    except KeyError as err:
+        errmsg = f"Opener not found: {repr(err)}".encode("utf-8")
+        CPLError(CE_Failure, <CPLErrorNum>4, <const char *>"%s", <const char *>errmsg)
+        return -1
+
+    try:
+        file_opener.rm(urlpath)
+        return 0
+    except (FileNotFoundError, KeyError) as err:
+        # No such file or directory.
+        return -1
+    except Exception as err:
+        errmsg = f"Opener failed to determine file info: {repr(err)}".encode("utf-8")
+        CPLError(CE_Failure, <CPLErrorNum>4, <const char *>"%s", <const char *>errmsg)
+        return -1
+
+
 cdef char ** pyopener_read_dir(
     void *pUserData,
     const char *pszDirname,
     int nMaxFiles
 ) with gil:
     """Provides a directory listing to GDAL from a Python filesystem."""
+    cdef FSData *fsdata = <FSData *>pUserData
+    path = fsdata.path.decode("utf-8")
+    uuid = fsdata.uuid.decode("utf-8")
+    key = (Path(path), uuid)
     urlpath = pszDirname.decode("utf-8")
-    key = Path(urlpath)
 
     registry = _OPENER_REGISTRY.get()
-    log.debug("Looking up opener in pyopener_read_dir: registry=%r, key=%r", registry, key)
+    log.debug(
+        "Looking up opener in pyopener_read_dir: urlpath=%r, registry=%r, key=%r",
+        urlpath,
+        registry,
+        key
+    )
+
     try:
         file_opener = registry[key]
     except KeyError as err:
@@ -134,8 +159,7 @@ cdef char ** pyopener_read_dir(
     try:
         # GDAL wants relative file names.
         contents = [Path(item).name for item in file_opener.ls(urlpath)]
-        log.debug("Looking for dir contents: urlpath=%r, contents=%r", urlpath, contents)
-    except (FileNotFoundError, KeyError):
+    except (FileNotFoundError, KeyError) as err:
         # No such file or directory.
         return NULL
     except Exception as err:
@@ -163,12 +187,24 @@ cdef void* pyopener_open(
     GDAL may call this function multiple times per filename and each
     result must be seperately seekable.
     """
+    cdef FSData *fsdata = <FSData *>pUserData
+    path = fsdata.path.decode("utf-8")
+    uuid = fsdata.uuid.decode("utf-8")
+    key = (Path(path), uuid)
     urlpath = pszFilename.decode("utf-8")
+
     mode = pszAccess.decode("utf-8")
-    key = Path(urlpath).parent
+    if not "b" in mode:
+        mode += "b"
 
     registry = _OPENER_REGISTRY.get()
-    log.debug("Looking up opener in pyopener_open: registry=%r, key=%r", registry, key)
+    log.debug(
+        "Looking up opener in pyopener_open: urlpath=%r, registry=%r, key=%r",
+        urlpath,
+        registry,
+        key
+    )
+
     try:
         file_opener = registry[key]
     except KeyError as err:
@@ -199,7 +235,6 @@ cdef void* pyopener_open(
     try:
         file_obj = stack.enter_context(file_obj)
     except (AttributeError, TypeError) as err:
-        log.error("File object is not a context manager: file_obj=%r", file_obj)
         errmsg = f"Opener failed to open file with arguments ({repr(urlpath)}, {repr(mode)}): {repr(err)}".encode("utf-8")
         CPLError(CE_Failure, <CPLErrorNum>4, <const char *>"%s", <const char *>errmsg)
         return NULL
@@ -207,10 +242,9 @@ cdef void* pyopener_open(
         errmsg = "OpenFile didn't resolve".encode("utf-8")
         return NULL
     else:
-        exit_stacks = _OPEN_FILE_EXIT_STACKS.get()
+        exit_stacks = _OPEN_FILE_EXIT_STACKS.get({})
         exit_stacks[file_obj] = stack
         _OPEN_FILE_EXIT_STACKS.set(exit_stacks)
-        log.debug("Returning: file_obj=%r", file_obj)
         return <void *>file_obj
 
 
@@ -222,6 +256,7 @@ cdef int pyopener_eof(void *pFile) with gil:
     else:
         return 0
 
+
 cdef vsi_l_offset pyopener_tell(void *pFile) with gil:
     cdef object file_obj = <object>pFile
     return <vsi_l_offset>file_obj.tell()
@@ -249,7 +284,11 @@ cdef size_t pyopener_write(void *pFile, void *pBuffer, size_t nSize, size_t nCou
     cdef object file_obj = <object>pFile
     buffer_len = nSize * nCount
     cdef unsigned char [:] buff_view = <unsigned char[:buffer_len]>pBuffer
-    log.debug("Writing data: file_obj=%r, buff_view=%r, buffer_len=%r", file_obj, buff_view, buffer_len)
+    log.debug(
+        "Writing data: file_obj=%r, buff_view=%r, buffer_len=%r",
+        file_obj,
+        buff_view,
+        buffer_len)
     try:
         num = file_obj.write(buff_view)
     except TypeError:
@@ -279,32 +318,86 @@ cdef int pyopener_close(void *pFile) with gil:
 
 @contextlib.contextmanager
 def _opener_registration(urlpath, obj):
-    key = Path(urlpath).parent
+    cdef char **registered_prefixes = NULL
+    cdef int prefix_index = 0
+    cdef VSIFilesystemPluginCallbacksStruct *callbacks_struct = NULL
+    cdef FSData fsdata
+    cdef char *path_c = NULL
+    cdef char *uuid_c = NULL
+
+    # To resolve issue 1406 we add the opener or filesystem id to the
+    # registry key.
+    kpath = Path(urlpath).parent
+    kid = uuid4().hex
+    key = (kpath, kid)
+
+    path_b = kpath.as_posix().encode("utf-8")
+    path_c = path_b
+    uuid_b = kid.encode("utf-8")
+    uuid_c = uuid_b
+
+    fsdata = FSData(path_c, uuid_c)
+
+    namespace = f"{VSI_NS_ROOT}_{kid}"
+    cdef bytes prefix_bytes = f"/{namespace}/".encode("utf-8")
 
     # Might raise.
     opener = _create_opener(obj)
 
-    registry = _OPENER_REGISTRY.get()
+    registry = _OPENER_REGISTRY.get({})
+
     if key in registry:
         if registry[key] != opener:
             raise OpenerRegistrationError(f"Opener already registered for urlpath.")
         else:
             try:
-                yield f"{PREFIX}{urlpath}"
+                yield f"/{namespace}/{urlpath}"
             finally:
                 registry = _OPENER_REGISTRY.get()
                 _ = registry.pop(key, None)
                 _OPENER_REGISTRY.set(registry)
+
     else:
+        # Install handler.
+        registered_prefixes = VSIGetFileSystemsPrefixes()
+        prefix_index = CSLFindString(<CSLConstList>registered_prefixes, prefix_bytes)
+        CSLDestroy(registered_prefixes)
+
+        if prefix_index < 0:
+            log.debug("Installing Python opener handler plugin: prefix_bytes=%r", prefix_bytes)
+            callbacks_struct = VSIAllocFilesystemPluginCallbacksStruct()
+            callbacks_struct.open = <VSIFilesystemPluginOpenCallback>pyopener_open
+            callbacks_struct.eof = <VSIFilesystemPluginEofCallback>pyopener_eof
+            callbacks_struct.tell = <VSIFilesystemPluginTellCallback>pyopener_tell
+            callbacks_struct.seek = <VSIFilesystemPluginSeekCallback>pyopener_seek
+            callbacks_struct.read = <VSIFilesystemPluginReadCallback>pyopener_read
+            callbacks_struct.write = <VSIFilesystemPluginWriteCallback>pyopener_write
+            callbacks_struct.flush = <VSIFilesystemPluginFlushCallback>pyopener_flush
+            callbacks_struct.close = <VSIFilesystemPluginCloseCallback>pyopener_close
+            callbacks_struct.read_dir = <VSIFilesystemPluginReadDirCallback>pyopener_read_dir
+            callbacks_struct.stat = <VSIFilesystemPluginStatCallback>pyopener_stat
+            callbacks_struct.unlink = <VSIFilesystemPluginUnlinkCallback>pyopener_unlink
+            callbacks_struct.pUserData = &fsdata
+            retval = VSIInstallPluginHandler(prefix_bytes, callbacks_struct)
+            VSIFreeFilesystemPluginCallbacksStruct(callbacks_struct)
+
+        registered_prefixes = VSIGetFileSystemsPrefixes()
+        prefix_index = CSLFindString(<CSLConstList>registered_prefixes, prefix_bytes)
+        CSLDestroy(registered_prefixes)
+
         registry[key] = opener
         _OPENER_REGISTRY.set(registry)
+
         try:
-            yield f"{PREFIX}{urlpath}"
+            yield f"/{namespace}/{urlpath}"
         finally:
             registry = _OPENER_REGISTRY.get()
             _ = registry.pop(key, None)
             _OPENER_REGISTRY.set(registry)
 
+            IF (CTE_GDAL_MAJOR_VERSION, CTE_GDAL_MINOR_VERSION) >= (3, 9):
+                retval = VSIRemovePluginHandler(prefix_bytes)
+
 
 class _AbstractOpener:
     """Adapts a Python object to the opener interface."""
@@ -381,6 +474,19 @@ class _AbstractOpener:
             Modification timestamp in seconds.
         """
         raise NotImplementedError
+    def rm(self, path):
+        """Remove a resource.
+
+        Parameters
+        ----------
+        path : str
+            The identifier/locator for a resource within a filesystem.
+
+        Returns
+        -------
+        None
+        """
+        raise NotImplementedError
     def size(self, path):
         """Get the size, in bytes, of a resource..
 
@@ -427,14 +533,16 @@ class _FilesystemOpener(_AbstractOpener):
     def isdir(self, path):
         return self._obj.isdir(path)
     def ls(self, path):
-        return self._obj.ls(path)
+        # return value of ls() varies between file and zip fsspec filesystems.
+        return [item if isinstance(item, str) else item["filename"] for item in self._obj.ls(path)]
     def mtime(self, path):
         try:
             mtime = int(self._obj.modified(path).timestamp())
         except NotImplementedError:
             mtime = 0
-        log.debug("Modification time: mtime=%r", mtime)
         return mtime
+    def rm(self, path):
+        return self._obj.rm(path)
     def size(self, path):
         return self._obj.size(path)
 
@@ -447,6 +555,8 @@ class _AltFilesystemOpener(_FilesystemOpener):
         return self._obj.is_dir(path)
     def mtime(self, path):
         return 0
+    def rm(self, path):
+        self._obj.remove_file(path)
     def size(self, path):
         return self._obj.file_size(path)
 


=====================================
fiona/drvsupport.py
=====================================
@@ -103,7 +103,7 @@ supported_drivers = dict(
         # multi-layer
         #   ("OpenAir", "r"),
         # (Geo)Parquet
-        ("Parquet", "raw"),
+        ("Parquet", "rw"),
         # PCI Geomatics Database File 	PCIDSK 	No 	No 	Yes, using internal PCIDSK SDK (from GDAL 1.7.0)
         ("PCIDSK", "raw"),
         # PDS 	PDS 	No 	Yes 	Yes


=====================================
fiona/gdal.pxi
=====================================
@@ -16,18 +16,22 @@ cdef extern from "cpl_conv.h":
     const char *CPLFindFile(const char *pszClass, const char *pszBasename)
 
 
+cdef extern from "cpl_port.h":
+    ctypedef char **CSLConstList
+
+
 cdef extern from "cpl_string.h":
-    char ** CSLAddNameValue (char **list, const char *name, const char *value)
-    char ** CSLSetNameValue (char **list, const char *name, const char *value)
-    void CSLDestroy (char **list)
+    char ** CSLAddNameValue(char **list, const char *name, const char *value)
+    char ** CSLSetNameValue(char **list, const char *name, const char *value)
+    void CSLDestroy(char **list)
     char ** CSLAddString(char **list, const char *string)
-    int CSLCount(char **papszStrList)
-    char **CSLDuplicate(char **papszStrList)
-    int CSLFindName(char **papszStrList, const char *pszName)
-    int CSLFindString(char **papszStrList, const char *pszString)
-    int CSLFetchBoolean(char **papszStrList, const char *pszName, int default)
-    const char *CSLFetchNameValue(char **papszStrList, const char *pszName)
-    char **CSLMerge(char **first, char **second)
+    int CSLCount(CSLConstList papszStrList)
+    char **CSLDuplicate(CSLConstList papszStrList)
+    int CSLFindName(CSLConstList papszStrList, const char *pszName)
+    int CSLFindString(CSLConstList papszStrList, const char *pszString)
+    int CSLFetchBoolean(CSLConstList papszStrList, const char *pszName, int default)
+    const char *CSLFetchNameValue(CSLConstList papszStrList, const char *pszName)
+    char **CSLMerge(char **first, CSLConstList second)
 
 
 cdef extern from "cpl_error.h" nogil:
@@ -47,7 +51,9 @@ cdef extern from "cpl_error.h" nogil:
     const char* CPLGetLastErrorMsg()
     CPLErr CPLGetLastErrorType()
     void CPLPushErrorHandler(CPLErrorHandler handler)
+    void CPLPushErrorHandlerEx(CPLErrorHandler handler, void *userdata)
     void CPLPopErrorHandler()
+    void CPLQuietErrorHandler(CPLErr eErrClass, CPLErrorNum nError, const char *pszErrorMsg)
 
 
 cdef extern from "cpl_vsi.h" nogil:
@@ -137,6 +143,11 @@ cdef extern from "cpl_vsi.h" nogil:
     int VSI_ISDIR(int mode)
 
 
+IF (CTE_GDAL_MAJOR_VERSION, CTE_GDAL_MINOR_VERSION) >= (3, 9):
+    cdef extern from "cpl_vsi.h" nogil:
+        int VSIRemovePluginHandler(const char*)
+
+
 cdef extern from "ogr_core.h" nogil:
     ctypedef int OGRErr
     char *OGRGeometryTypeToName(int type)
@@ -297,7 +308,7 @@ cdef extern from "ogr_srs_api.h" nogil:
     OGRErr OSRExportToPROJJSON(OGRSpatialReferenceH hSRS,
                                 char ** ppszReturn,
                                 const char* const* papszOptions)
-
+    void OSRGetPROJVersion	(int *pnMajor, int *pnMinor, int *pnPatch)
 
 cdef extern from "gdal.h" nogil:
 


=====================================
fiona/model.py
=====================================
@@ -5,10 +5,15 @@ from collections.abc import MutableMapping
 from enum import Enum
 import itertools
 from json import JSONEncoder
+import reprlib
 from warnings import warn
 
 from fiona.errors import FionaDeprecationWarning
 
+_model_repr = reprlib.Repr()
+_model_repr.maxlist = 1
+_model_repr.maxdict = 5
+
 
 class OGRGeometryType(Enum):
     Unknown = 0
@@ -134,7 +139,10 @@ class Object(MutableMapping):
         }
 
     def __getitem__(self, item):
-        props = self._props()
+        props = {
+            k: (dict(v) if isinstance(v, Object) else v)
+            for k, v in self._props().items()
+        }
         props.update(**self._data)
         return props[item]
 
@@ -146,6 +154,13 @@ class Object(MutableMapping):
         props = self._props()
         return len(props) + len(self._data)
 
+    def __repr__(self):
+        kvs = [
+            f"{k}={v!r}"
+            for k, v in itertools.chain(self._props().items(), self._data.items())
+        ]
+        return "fiona.{}({})".format(self.__class__.__name__, ", ".join(kvs))
+
     def __setitem__(self, key, value):
         warn(
             "instances of this class -- CRS, geometry, and feature objects -- will become immutable in fiona version 2.0",
@@ -197,6 +212,10 @@ class Geometry(Object):
         )
         super().__init__(**data)
 
+    def __repr__(self):
+        kvs = [f"{k}={_model_repr.repr(v)}" for k, v in self.items() if v is not None]
+        return "fiona.Geometry({})".format(", ".join(kvs))
+
     @classmethod
     def from_dict(cls, ob=None, **kwargs):
         if ob is not None:
@@ -384,7 +403,10 @@ class ObjectEncoder(JSONEncoder):
 
     def default(self, o):
         if isinstance(o, Object):
-            o_dict = {k: self.default(v) for k, v in o.items()}
+            o_dict = {
+                k: self.default(v)
+                for k, v in itertools.chain(o._props().items(), o._data.items())
+            }
             if isinstance(o, Geometry):
                 if o.type == "GeometryCollection":
                     _ = o_dict.pop("coordinates", None)


=====================================
fiona/ogrext.pyx
=====================================
@@ -18,11 +18,12 @@ from fiona._geometry cimport (
     GeomBuilder, OGRGeomBuilder, geometry_type_code,
     normalize_geometry_type_code, base_geometry_type_code)
 from fiona._err cimport exc_wrap_int, exc_wrap_pointer, exc_wrap_vsilfile, get_last_error_msg
+from fiona._err cimport StackChecker
 
 import fiona
 from fiona._env import get_gdal_version_num, calc_gdal_version_num, get_gdal_version_tuple
 from fiona._err import (
-    cpl_errs, FionaNullPointerError, CPLE_BaseError, CPLE_AppDefinedError,
+    cpl_errs, stack_errors, FionaNullPointerError, CPLE_BaseError, CPLE_AppDefinedError,
     CPLE_OpenFailedError)
 from fiona._geometry import GEOMETRY_TYPES
 from fiona import compat
@@ -92,6 +93,10 @@ cdef void* gdal_open_vector(const char* path_c, int mode, drivers, options) exce
     cdef char **drvs = NULL
     cdef void* drv = NULL
     cdef char **open_opts = NULL
+    cdef char **registered_prefixes = NULL
+    cdef int prefix_index = 0
+    cdef VSIFilesystemPluginCallbacksStruct *callbacks_struct = NULL
+    cdef StackChecker checker
 
     flags = GDAL_OF_VECTOR | GDAL_OF_VERBOSE_ERROR
     if mode == 1:
@@ -122,15 +127,13 @@ cdef void* gdal_open_vector(const char* path_c, int mode, drivers, options) exce
     open_opts = CSLAddNameValue(open_opts, "VALIDATE_OPEN_OPTIONS", "NO")
 
     try:
-        cogr_ds = exc_wrap_pointer(
-            GDALOpenEx(path_c, flags, <const char *const *>drvs, <const char *const *>open_opts, NULL)
-        )
-        return cogr_ds
-    except FionaNullPointerError:
-        raise DriverError(
-            f"Failed to open dataset (mode={mode}): {path_c.decode('utf-8')}")
+        with stack_errors() as checker:
+            cogr_ds = GDALOpenEx(
+                path_c, flags, <const char *const *>drvs, <const char *const *>open_opts, NULL
+            )
+            return checker.exc_wrap_pointer(cogr_ds)
     except CPLE_BaseError as exc:
-        raise DriverError(str(exc))
+        raise DriverError(f"Failed to open dataset (flags={flags}): {path_c.decode('utf-8')}") from exc
     finally:
         CSLDestroy(drvs)
         CSLDestroy(open_opts)
@@ -149,9 +152,7 @@ cdef void* gdal_create(void* cogr_driver, const char *path_c, options) except NU
     creation_option_keys = option_keys & set(meta.dataset_creation_options(db.decode("utf-8")))
 
     for k, v in options.items():
-
         if k.upper() in creation_option_keys:
-
             kb = k.upper().encode('utf-8')
 
             if isinstance(v, bool):
@@ -171,7 +172,6 @@ cdef void* gdal_create(void* cogr_driver, const char *path_c, options) except NU
         CSLDestroy(creation_opts)
 
 
-
 def _explode(coords):
     """Explode a GeoJSON geometry's coordinates object and yield
     coordinate tuples. As long as the input is conforming, the type of
@@ -193,6 +193,7 @@ def _bounds(geometry):
     except (KeyError, TypeError):
         return None
 
+
 cdef int GDAL_VERSION_NUM = get_gdal_version_num()
 
 
@@ -376,7 +377,7 @@ cdef class StringListField(AbstractField):
         for item in value:
             item_b = item.encode(encoding)
             string_list = CSLAddString(string_list, <const char *>item_b)
-        OGR_F_SetFieldStringList(feature, i, <const char **>string_list)
+        OGR_F_SetFieldStringList(feature, i, <CSLConstList>string_list)
 
 
 cdef class JSONField(AbstractField):
@@ -1264,7 +1265,7 @@ cdef class Session:
 
         cdef char **metadata = NULL
         metadata = GDALGetMetadata(obj, domain)
-        num_items = CSLCount(metadata)
+        num_items = CSLCount(<CSLConstList>metadata)
 
         return dict(metadata[i].decode('utf-8').split('=', 1) for i in range(num_items))
 
@@ -2126,10 +2127,8 @@ def _remove_layer(path, layer, driver=None):
 
 
 def _listlayers(path, **kwargs):
-
     """Provides a list of the layers in an OGR data source.
     """
-
     cdef void *cogr_ds = NULL
     cdef void *cogr_layer = NULL
     cdef const char *path_c
@@ -2175,7 +2174,7 @@ def _listdir(path):
         raise FionaValueError(f"Path '{path}' is not a directory.")
 
     papszFiles = VSIReadDir(path_c)
-    n = CSLCount(papszFiles)
+    n = CSLCount(<CSLConstList>papszFiles)
     files = []
     for i in range(n):
         files.append(papszFiles[i].decode("utf-8"))


=====================================
setup.py
=====================================
@@ -83,7 +83,7 @@ if 'clean' not in sys.argv:
                          " setup.py to locate needed GDAL files.\nMore"
                          " information is available in the README.")
         else:
-            logging.warn("Failed to get options via gdal-config: %s", str(e))
+            logging.warning("Failed to get options via gdal-config: %s", str(e))
 
     # Get GDAL API version from environment variable.
     if 'GDAL_VERSION' in os.environ:


=====================================
tests/test_bounds.py
=====================================
@@ -69,7 +69,7 @@ def test_bounds(tmpdir, driver, testdata_generator):
             ys.append(r.geometry["coordinates"][1])
         return min(xs), max(xs), min(ys), max(ys)
 
-    with fiona.open(path, "w", driver=driver, schema=schema) as c:
+    with fiona.open(path, "w", crs="OGC:CRS84", driver=driver, schema=schema) as c:
         c.writerecords(records1)
 
         try:


=====================================
tests/test_memoryfile.py
=====================================
@@ -217,7 +217,7 @@ def test_mapinfo_raises():
         for driver in supported_drivers
         if _driver_supports_mode(driver, "w")
         and supports_vsi(driver)
-        and driver not in {"MapInfo File"}
+        and driver not in {"MapInfo File", "TileDB"}
     ],
 )
 def test_write_memoryfile_drivers(driver, testdata_generator):
@@ -226,7 +226,7 @@ def test_write_memoryfile_drivers(driver, testdata_generator):
     schema, crs, records1, _, _ = testdata_generator(driver, range1, [])
 
     with MemoryFile() as memfile:
-        with memfile.open(driver=driver, schema=schema) as c:
+        with memfile.open(driver=driver, crs="OGC:CRS84", schema=schema) as c:
             c.writerecords(records1)
 
         with memfile.open(driver=driver) as c:
@@ -267,7 +267,7 @@ def test_multiple_layer_memoryfile(testdata_generator):
         for driver in supported_drivers
         if _driver_supports_mode(driver, "a")
         and supports_vsi(driver)
-        and driver not in {"MapInfo File"}
+        and driver not in {"MapInfo File", "TileDB"}
     ],
 )
 def test_append_memoryfile_drivers(driver, testdata_generator):
@@ -277,16 +277,23 @@ def test_append_memoryfile_drivers(driver, testdata_generator):
     schema, crs, records1, records2, _ = testdata_generator(driver, range1, range2)
 
     with MemoryFile() as memfile:
-        with memfile.open(driver=driver, schema=schema) as c:
+        with memfile.open(driver=driver, crs="OGC:CRS84", schema=schema) as c:
             c.writerecords(records1)
 
-        with memfile.open(mode='a', driver=driver, schema=schema) as c:
-            c.writerecords(records2)
-
-        with memfile.open(driver=driver) as c:
-            assert driver == c.driver
-            items = list(c)
-            assert len(items) == len(range1 + range2)
+        # The parquet dataset does not seem to support append mode
+        if driver == "Parquet":
+            with memfile.open(driver=driver) as c:
+                assert driver == c.driver
+                items = list(c)
+                assert len(items) == len(range1)
+        else:
+            with memfile.open(mode='a', driver=driver, schema=schema) as c:
+                c.writerecords(records2)
+
+            with memfile.open(driver=driver) as c:
+                assert driver == c.driver
+                items = list(c)
+                assert len(items) == len(range1 + range2)
 
 
 def test_memoryfile_driver_does_not_support_vsi():


=====================================
tests/test_model.py
=====================================
@@ -333,3 +333,12 @@ def test_geometry_collection_encoding():
     assert "coordinates" not in ObjectEncoder().default(
         Geometry(type="GeometryCollection", geometries=[])
     )
+
+
+def test_feature_repr():
+    feat = Feature(
+        id="1",
+        geometry=Geometry(type="LineString", coordinates=[(0, 0)] * 100),
+        properties=Properties(a=1, foo="bar"),
+    )
+    assert repr(feat) == "fiona.Feature(geometry=fiona.Geometry(coordinates=[(0, 0), ...], type='LineString'), id='1', properties=fiona.Properties(a=1, foo='bar'))"


=====================================
tests/test_pyopener.py
=====================================
@@ -1,6 +1,7 @@
 """Tests of the Python opener VSI plugin."""
 
 import io
+import os
 
 import fsspec
 import pytest
@@ -78,3 +79,85 @@ def test_opener_fsspec_fs_write(tmp_path):
         collection.write(feature)
         assert len(collection) == 1
         assert collection.crs == "OGC:CRS84"
+
+
+def test_threads_context():
+    import io
+    from threading import Thread
+
+
+    def target():
+        with fiona.open("tests/data/coutwildrnp.shp", opener=io.open) as colxn:
+            print(colxn.profile)
+            assert len(colxn) == 67
+
+
+    thread = Thread(target=target)
+    thread.start()
+    thread.join()
+
+
+def test_overwrite(data):
+    """Opener can overwrite data."""
+    schema = {"geometry": "Point", "properties": {"zero": "int"}}
+    feature = Feature.from_dict(
+        **{
+            "geometry": {"type": "Point", "coordinates": (0, 0)},
+            "properties": {"zero": "0"},
+        }
+    )
+    fs = fsspec.filesystem("file")
+    outputfile = os.path.join(str(data), "coutwildrnp.shp")
+
+    with fiona.open(
+        str(outputfile),
+        "w",
+        driver="ESRI Shapefile",
+        schema=schema,
+        crs="OGC:CRS84",
+        opener=fs,
+    ) as collection:
+        collection.write(feature)
+        assert len(collection) == 1
+        assert collection.crs == "OGC:CRS84"
+
+
+def test_opener_fsspec_zip_fs_listlayers():
+    """Use fsspec zip filesystem as opener for listlayers()."""
+    fs = fsspec.filesystem("zip", fo="tests/data/coutwildrnp.zip")
+    assert fiona.listlayers("coutwildrnp.shp", opener=fs) == ["coutwildrnp"]
+
+
+def test_opener_fsspec_zip_fs_listdir():
+    """Use fsspec zip filesystem as opener for listdir()."""
+    fs = fsspec.filesystem("zip", fo="tests/data/coutwildrnp.zip")
+    listing = fiona.listdir("/", opener=fs)
+    assert len(listing) == 4
+    assert set(
+        ["coutwildrnp.shp", "coutwildrnp.dbf", "coutwildrnp.shx", "coutwildrnp.prj"]
+    ) & set(listing)
+
+
+
+def test_opener_fsspec_file_fs_listdir():
+    """Use fsspec file filesystem as opener for listdir()."""
+    fs = fsspec.filesystem("file")
+    listing = fiona.listdir("tests/data", opener=fs)
+    assert len(listing) >= 35
+    assert set(
+        ["coutwildrnp.shp", "coutwildrnp.dbf", "coutwildrnp.shx", "coutwildrnp.prj"]
+    ) & set(listing)
+
+
+def test_opener_fsspec_file_remove(data):
+    """Opener can remove data."""
+    fs = fsspec.filesystem("file")
+    listing = fiona.listdir(str(data), opener=fs)
+    assert len(listing) == 4
+    outputfile = os.path.join(str(data), "coutwildrnp.shp")
+    fiona.remove(outputfile)
+    listing = fiona.listdir(str(data), opener=fs)
+    assert len(listing) == 0
+    assert not set(
+        ["coutwildrnp.shp", "coutwildrnp.dbf", "coutwildrnp.shx", "coutwildrnp.prj"]
+    ) & set(listing)


=====================================
tests/test_slice.py
=====================================
@@ -113,7 +113,9 @@ def slice_dataset_path(request):
     tmpdir = tempfile.mkdtemp()
     path = os.path.join(tmpdir, get_temp_filename(driver))
 
-    with fiona.open(path, "w", driver=driver, schema=schema, **create_kwargs) as c:
+    with fiona.open(
+        path, "w", driver=driver, crs="OGC:CRS84", schema=schema, **create_kwargs
+    ) as c:
         c.writerecords(records)
     yield path
     shutil.rmtree(tmpdir)


=====================================
tests/test_topojson.py
=====================================
@@ -32,6 +32,6 @@ def test_read_topojson(data_dir):
 
     assert len(features) == 3, "unexpected number of features"
     for feature in features:
-        assert isinstance(feature["properties"], Properties)
-        assert len(feature["properties"]) > 0
-        assert feature["geometry"]["type"] in {"Point", "LineString", "Polygon"}
+        assert isinstance(feature.properties, Properties)
+        assert len(feature.properties) > 0
+        assert feature.geometry.type in {"Point", "LineString", "Polygon"}



View it on GitLab: https://salsa.debian.org/debian-gis-team/fiona/-/commit/acb0a1cdc8686164f7c117541453674577d94e80

-- 
This project does not include diff previews in email notifications.
View it on GitLab: https://salsa.debian.org/debian-gis-team/fiona/-/commit/acb0a1cdc8686164f7c117541453674577d94e80
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20240711/ea7e22b9/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list