Repository: ecmwf/earthkit-data
Pull request #195 (2023-09-19 08:49:23)

194 write method added to NetCDFFieldList method
write method added to NetCDFFieldList method, simply redirect to the to_netCDF method.


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4893d17..d817a23 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,6 +52,7 @@ jobs:
         environment-name: DEVELOP
         channels: conda-forge
         cache-env: true
+        cache-env-key: ubuntu-latest-3.10
         extra-specs: |
           python=3.10
     - name: Install package
@@ -60,9 +61,32 @@ jobs:
     - name: Run tests
       run: |
         make unit-tests
-    - name: Run tests without eccodes
+
+  unit-tests-no-eccodes:
+    name: unit-tests (3.10)
+    if: ${{ !github.event.pull_request.head.repo.fork && github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci' }}
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v3
+      with:
+        ref: ${{ github.event.pull_request.head.sha || github.ref }}
+    - name: Install Conda environment with Micromamba
+      uses: mamba-org/provision-with-micromamba@v14
+      with:
+        environment-file: tests/environment-unit-tests.yml
+        environment-name: DEVELOP
+        channels: conda-forge
+        cache-env: true
+        cache-env-key: ubuntu-latest-3.10-no-eccodes
+        extra-specs: |
+          python=3.10
+    - name: Install package
       run: |
+        python -m pip install --no-deps -e .
         micromamba remove eccodes
+    - name: Run tests without eccodes
+      run: |
         python -m pytest -v -m 'no_eccodes'
 
 
diff --git a/README.md b/README.md
index d04a7a1..4f8cfbc 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,8 @@
 # earthkit-data
 
-![earthkit-data](docs/_static/earthkit-data.png)
+<img src="docs/_static/earthkit-data.png" width="120">
 
+[![PyPI version fury.io](https://badge.fury.io/py/earthkit-data.svg)](https://pypi.python.org/pypi/earthkit-data/)
 [![PyPI pyversions](https://img.shields.io/pypi/pyversions/earthkit-data.svg)](https://pypi.python.org/pypi/earthkit-data/)
 
 > :warning: **DISCLAIMER**
diff --git a/docs/api.rst b/docs/api.rst
index ce2f0a4..16fc7ea 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -30,6 +30,11 @@ BUFR
 - :py:class:`~data.readers.bufr.bufr.BUFRList`
 - :py:class:`~data.readers.bufr.bufr.BUFRMessage`
 
+CSV
+----
+
+- :py:class:`~data.readers.csv.CSVReader`
+
 Other
 --------
 
diff --git a/docs/conf.py b/docs/conf.py
index c587a32..ad0e477 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -35,6 +35,7 @@ extensions = [
     "nbsphinx",
     "sphinx.ext.autodoc",
     "sphinx.ext.napoleon",
+    "sphinx.ext.intersphinx",
     "autoapi.extension",
     "earthkit.data.sphinxext.xref",
     "earthkit.data.sphinxext.module_output",
@@ -109,6 +110,9 @@ xref_links = {
 }
 
 
+intersphinx_mapping = {"pandas": ("https://pandas.pydata.org/docs/", None)}
+
+
 def setup(app):
     from skip_api_rules import _skip_api_items
 
diff --git a/docs/examples.rst b/docs/examples.rst
index 7442cf5..d061b41 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -15,6 +15,7 @@ Here is a list of example notebooks to illustrate how to use earthkit-data.
     examples/cds.ipynb
     examples/ecmwf_open_data.ipynb
     examples/fdb.ipynb
+    examples/wekeo.ipynb
 
 
 .. toctree::
diff --git a/docs/examples/cds.ipynb b/docs/examples/cds.ipynb
index a2149bd..2b82c0e 100644
--- a/docs/examples/cds.ipynb
+++ b/docs/examples/cds.ipynb
@@ -211,7 +211,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.16"
+   "version": "3.8.18"
   }
  },
  "nbformat": 4,
diff --git a/docs/examples/wekeo.ipynb b/docs/examples/wekeo.ipynb
new file mode 100644
index 0000000..3a4826e
--- /dev/null
+++ b/docs/examples/wekeo.ipynb
@@ -0,0 +1,711 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "3599bcd8-a5ee-4911-96c9-976b849138fb",
+   "metadata": {},
+   "source": [
+    "## Retieving data from WEkEO"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "1491dd53-2993-48c1-9118-72b1ffdd2b91",
+   "metadata": {},
+   "source": [
+    "The “wekeo” and \"wekeocds\" data sources provide access to [WEkEO](https://www.wekeo.eu/)."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e32b8f13-137a-436e-8523-fcee759dfb02",
+   "metadata": {},
+   "source": [
+    "#### Using the WEkEO grammar"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c9d0a291-c9b9-480d-a70a-8be0a4be423f",
+   "metadata": {},
+   "source": [
+    "When we want access data using the WEkEO grammar (see the [hda](https://hda.readthedocs.io/) documentation) we need to use the \"wekeo\" source. The following example retrieves Normalized Difference Vegetation Index data derived from EO satellite imagery in NetCDF format:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "15d5599f-1242-4238-8d5d-e459694bc296",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div><svg style=\"position: absolute; width: 0; height: 0; overflow: hidden\">\n",
+       "<defs>\n",
+       "<symbol id=\"icon-database\" viewBox=\"0 0 32 32\">\n",
+       "<path d=\"M16 0c-8.837 0-16 2.239-16 5v4c0 2.761 7.163 5 16 5s16-2.239 16-5v-4c0-2.761-7.163-5-16-5z\"></path>\n",
+       "<path d=\"M16 17c-8.837 0-16-2.239-16-5v6c0 2.761 7.163 5 16 5s16-2.239 16-5v-6c0 2.761-7.163 5-16 5z\"></path>\n",
+       "<path d=\"M16 26c-8.837 0-16-2.239-16-5v6c0 2.761 7.163 5 16 5s16-2.239 16-5v-6c0 2.761-7.163 5-16 5z\"></path>\n",
+       "</symbol>\n",
+       "<symbol id=\"icon-file-text2\" viewBox=\"0 0 32 32\">\n",
+       "<path d=\"M28.681 7.159c-0.694-0.947-1.662-2.053-2.724-3.116s-2.169-2.030-3.116-2.724c-1.612-1.182-2.393-1.319-2.841-1.319h-15.5c-1.378 0-2.5 1.121-2.5 2.5v27c0 1.378 1.122 2.5 2.5 2.5h23c1.378 0 2.5-1.122 2.5-2.5v-19.5c0-0.448-0.137-1.23-1.319-2.841zM24.543 5.457c0.959 0.959 1.712 1.825 2.268 2.543h-4.811v-4.811c0.718 0.556 1.584 1.309 2.543 2.268zM28 29.5c0 0.271-0.229 0.5-0.5 0.5h-23c-0.271 0-0.5-0.229-0.5-0.5v-27c0-0.271 0.229-0.5 0.5-0.5 0 0 15.499-0 15.5 0v7c0 0.552 0.448 1 1 1h7v19.5z\"></path>\n",
+       "<path d=\"M23 26h-14c-0.552 0-1-0.448-1-1s0.448-1 1-1h14c0.552 0 1 0.448 1 1s-0.448 1-1 1z\"></path>\n",
+       "<path d=\"M23 22h-14c-0.552 0-1-0.448-1-1s0.448-1 1-1h14c0.552 0 1 0.448 1 1s-0.448 1-1 1z\"></path>\n",
+       "<path d=\"M23 18h-14c-0.552 0-1-0.448-1-1s0.448-1 1-1h14c0.552 0 1 0.448 1 1s-0.448 1-1 1z\"></path>\n",
+       "</symbol>\n",
+       "</defs>\n",
+       "</svg>\n",
+       "<style>/* CSS stylesheet for displaying xarray objects in jupyterlab.\n",
+       " *\n",
+       " */\n",
+       "\n",
+       ":root {\n",
+       "  --xr-font-color0: var(--jp-content-font-color0, rgba(0, 0, 0, 1));\n",
+       "  --xr-font-color2: var(--jp-content-font-color2, rgba(0, 0, 0, 0.54));\n",
+       "  --xr-font-color3: var(--jp-content-font-color3, rgba(0, 0, 0, 0.38));\n",
+       "  --xr-border-color: var(--jp-border-color2, #e0e0e0);\n",
+       "  --xr-disabled-color: var(--jp-layout-color3, #bdbdbd);\n",
+       "  --xr-background-color: var(--jp-layout-color0, white);\n",
+       "  --xr-background-color-row-even: var(--jp-layout-color1, white);\n",
+       "  --xr-background-color-row-odd: var(--jp-layout-color2, #eeeeee);\n",
+       "}\n",
+       "\n",
+       "html[theme=dark],\n",
+       "body[data-theme=dark],\n",
+       "body.vscode-dark {\n",
+       "  --xr-font-color0: rgba(255, 255, 255, 1);\n",
+       "  --xr-font-color2: rgba(255, 255, 255, 0.54);\n",
+       "  --xr-font-color3: rgba(255, 255, 255, 0.38);\n",
+       "  --xr-border-color: #1F1F1F;\n",
+       "  --xr-disabled-color: #515151;\n",
+       "  --xr-background-color: #111111;\n",
+       "  --xr-background-color-row-even: #111111;\n",
+       "  --xr-background-color-row-odd: #313131;\n",
+       "}\n",
+       "\n",
+       ".xr-wrap {\n",
+       "  display: block !important;\n",
+       "  min-width: 300px;\n",
+       "  max-width: 700px;\n",
+       "}\n",
+       "\n",
+       ".xr-text-repr-fallback {\n",
+       "  /* fallback to plain text repr when CSS is not injected (untrusted notebook) */\n",
+       "  display: none;\n",
+       "}\n",
+       "\n",
+       ".xr-header {\n",
+       "  padding-top: 6px;\n",
+       "  padding-bottom: 6px;\n",
+       "  margin-bottom: 4px;\n",
+       "  border-bottom: solid 1px var(--xr-border-color);\n",
+       "}\n",
+       "\n",
+       ".xr-header > div,\n",
+       ".xr-header > ul {\n",
+       "  display: inline;\n",
+       "  margin-top: 0;\n",
+       "  margin-bottom: 0;\n",
+       "}\n",
+       "\n",
+       ".xr-obj-type,\n",
+       ".xr-array-name {\n",
+       "  margin-left: 2px;\n",
+       "  margin-right: 10px;\n",
+       "}\n",
+       "\n",
+       ".xr-obj-type {\n",
+       "  color: var(--xr-font-color2);\n",
+       "}\n",
+       "\n",
+       ".xr-sections {\n",
+       "  padding-left: 0 !important;\n",
+       "  display: grid;\n",
+       "  grid-template-columns: 150px auto auto 1fr 20px 20px;\n",
+       "}\n",
+       "\n",
+       ".xr-section-item {\n",
+       "  display: contents;\n",
+       "}\n",
+       "\n",
+       ".xr-section-item input {\n",
+       "  display: none;\n",
+       "}\n",
+       "\n",
+       ".xr-section-item input + label {\n",
+       "  color: var(--xr-disabled-color);\n",
+       "}\n",
+       "\n",
+       ".xr-section-item input:enabled + label {\n",
+       "  cursor: pointer;\n",
+       "  color: var(--xr-font-color2);\n",
+       "}\n",
+       "\n",
+       ".xr-section-item input:enabled + label:hover {\n",
+       "  color: var(--xr-font-color0);\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary {\n",
+       "  grid-column: 1;\n",
+       "  color: var(--xr-font-color2);\n",
+       "  font-weight: 500;\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary > span {\n",
+       "  display: inline-block;\n",
+       "  padding-left: 0.5em;\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary-in:disabled + label {\n",
+       "  color: var(--xr-font-color2);\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary-in + label:before {\n",
+       "  display: inline-block;\n",
+       "  content: '►';\n",
+       "  font-size: 11px;\n",
+       "  width: 15px;\n",
+       "  text-align: center;\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary-in:disabled + label:before {\n",
+       "  color: var(--xr-disabled-color);\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary-in:checked + label:before {\n",
+       "  content: '▼';\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary-in:checked + label > span {\n",
+       "  display: none;\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary,\n",
+       ".xr-section-inline-details {\n",
+       "  padding-top: 4px;\n",
+       "  padding-bottom: 4px;\n",
+       "}\n",
+       "\n",
+       ".xr-section-inline-details {\n",
+       "  grid-column: 2 / -1;\n",
+       "}\n",
+       "\n",
+       ".xr-section-details {\n",
+       "  display: none;\n",
+       "  grid-column: 1 / -1;\n",
+       "  margin-bottom: 5px;\n",
+       "}\n",
+       "\n",
+       ".xr-section-summary-in:checked ~ .xr-section-details {\n",
+       "  display: contents;\n",
+       "}\n",
+       "\n",
+       ".xr-array-wrap {\n",
+       "  grid-column: 1 / -1;\n",
+       "  display: grid;\n",
+       "  grid-template-columns: 20px auto;\n",
+       "}\n",
+       "\n",
+       ".xr-array-wrap > label {\n",
+       "  grid-column: 1;\n",
+       "  vertical-align: top;\n",
+       "}\n",
+       "\n",
+       ".xr-preview {\n",
+       "  color: var(--xr-font-color3);\n",
+       "}\n",
+       "\n",
+       ".xr-array-preview,\n",
+       ".xr-array-data {\n",
+       "  padding: 0 5px !important;\n",
+       "  grid-column: 2;\n",
+       "}\n",
+       "\n",
+       ".xr-array-data,\n",
+       ".xr-array-in:checked ~ .xr-array-preview {\n",
+       "  display: none;\n",
+       "}\n",
+       "\n",
+       ".xr-array-in:checked ~ .xr-array-data,\n",
+       ".xr-array-preview {\n",
+       "  display: inline-block;\n",
+       "}\n",
+       "\n",
+       ".xr-dim-list {\n",
+       "  display: inline-block !important;\n",
+       "  list-style: none;\n",
+       "  padding: 0 !important;\n",
+       "  margin: 0;\n",
+       "}\n",
+       "\n",
+       ".xr-dim-list li {\n",
+       "  display: inline-block;\n",
+       "  padding: 0;\n",
+       "  margin: 0;\n",
+       "}\n",
+       "\n",
+       ".xr-dim-list:before {\n",
+       "  content: '(';\n",
+       "}\n",
+       "\n",
+       ".xr-dim-list:after {\n",
+       "  content: ')';\n",
+       "}\n",
+       "\n",
+       ".xr-dim-list li:not(:last-child):after {\n",
+       "  content: ',';\n",
+       "  padding-right: 5px;\n",
+       "}\n",
+       "\n",
+       ".xr-has-index {\n",
+       "  font-weight: bold;\n",
+       "}\n",
+       "\n",
+       ".xr-var-list,\n",
+       ".xr-var-item {\n",
+       "  display: contents;\n",
+       "}\n",
+       "\n",
+       ".xr-var-item > div,\n",
+       ".xr-var-item label,\n",
+       ".xr-var-item > .xr-var-name span {\n",
+       "  background-color: var(--xr-background-color-row-even);\n",
+       "  margin-bottom: 0;\n",
+       "}\n",
+       "\n",
+       ".xr-var-item > .xr-var-name:hover span {\n",
+       "  padding-right: 5px;\n",
+       "}\n",
+       "\n",
+       ".xr-var-list > li:nth-child(odd) > div,\n",
+       ".xr-var-list > li:nth-child(odd) > label,\n",
+       ".xr-var-list > li:nth-child(odd) > .xr-var-name span {\n",
+       "  background-color: var(--xr-background-color-row-odd);\n",
+       "}\n",
+       "\n",
+       ".xr-var-name {\n",
+       "  grid-column: 1;\n",
+       "}\n",
+       "\n",
+       ".xr-var-dims {\n",
+       "  grid-column: 2;\n",
+       "}\n",
+       "\n",
+       ".xr-var-dtype {\n",
+       "  grid-column: 3;\n",
+       "  text-align: right;\n",
+       "  color: var(--xr-font-color2);\n",
+       "}\n",
+       "\n",
+       ".xr-var-preview {\n",
+       "  grid-column: 4;\n",
+       "}\n",
+       "\n",
+       ".xr-index-preview {\n",
+       "  grid-column: 2 / 5;\n",
+       "  color: var(--xr-font-color2);\n",
+       "}\n",
+       "\n",
+       ".xr-var-name,\n",
+       ".xr-var-dims,\n",
+       ".xr-var-dtype,\n",
+       ".xr-preview,\n",
+       ".xr-attrs dt {\n",
+       "  white-space: nowrap;\n",
+       "  overflow: hidden;\n",
+       "  text-overflow: ellipsis;\n",
+       "  padding-right: 10px;\n",
+       "}\n",
+       "\n",
+       ".xr-var-name:hover,\n",
+       ".xr-var-dims:hover,\n",
+       ".xr-var-dtype:hover,\n",
+       ".xr-attrs dt:hover {\n",
+       "  overflow: visible;\n",
+       "  width: auto;\n",
+       "  z-index: 1;\n",
+       "}\n",
+       "\n",
+       ".xr-var-attrs,\n",
+       ".xr-var-data,\n",
+       ".xr-index-data {\n",
+       "  display: none;\n",
+       "  background-color: var(--xr-background-color) !important;\n",
+       "  padding-bottom: 5px !important;\n",
+       "}\n",
+       "\n",
+       ".xr-var-attrs-in:checked ~ .xr-var-attrs,\n",
+       ".xr-var-data-in:checked ~ .xr-var-data,\n",
+       ".xr-index-data-in:checked ~ .xr-index-data {\n",
+       "  display: block;\n",
+       "}\n",
+       "\n",
+       ".xr-var-data > table {\n",
+       "  float: right;\n",
+       "}\n",
+       "\n",
+       ".xr-var-name span,\n",
+       ".xr-var-data,\n",
+       ".xr-index-name div,\n",
+       ".xr-index-data,\n",
+       ".xr-attrs {\n",
+       "  padding-left: 25px !important;\n",
+       "}\n",
+       "\n",
+       ".xr-attrs,\n",
+       ".xr-var-attrs,\n",
+       ".xr-var-data,\n",
+       ".xr-index-data {\n",
+       "  grid-column: 1 / -1;\n",
+       "}\n",
+       "\n",
+       "dl.xr-attrs {\n",
+       "  padding: 0;\n",
+       "  margin: 0;\n",
+       "  display: grid;\n",
+       "  grid-template-columns: 125px auto;\n",
+       "}\n",
+       "\n",
+       ".xr-attrs dt,\n",
+       ".xr-attrs dd {\n",
+       "  padding: 0;\n",
+       "  margin: 0;\n",
+       "  float: left;\n",
+       "  padding-right: 10px;\n",
+       "  width: auto;\n",
+       "}\n",
+       "\n",
+       ".xr-attrs dt {\n",
+       "  font-weight: normal;\n",
+       "  grid-column: 1;\n",
+       "}\n",
+       "\n",
+       ".xr-attrs dt:hover span {\n",
+       "  display: inline-block;\n",
+       "  background: var(--xr-background-color);\n",
+       "  padding-right: 10px;\n",
+       "}\n",
+       "\n",
+       ".xr-attrs dd {\n",
+       "  grid-column: 2;\n",
+       "  white-space: pre-wrap;\n",
+       "  word-break: break-all;\n",
+       "}\n",
+       "\n",
+       ".xr-icon-database,\n",
+       ".xr-icon-file-text2,\n",
+       ".xr-no-icon {\n",
+       "  display: inline-block;\n",
+       "  vertical-align: middle;\n",
+       "  width: 1em;\n",
+       "  height: 1.5em !important;\n",
+       "  stroke-width: 0;\n",
+       "  stroke: currentColor;\n",
+       "  fill: currentColor;\n",
+       "}\n",
+       "</style><pre class='xr-text-repr-fallback'>&lt;xarray.Dataset&gt;\n",
+       "Dimensions:  (lon: 120960, lat: 47040)\n",
+       "Coordinates:\n",
+       "  * lon      (lon) float64 -180.0 -180.0 -180.0 -180.0 ... 180.0 180.0 180.0\n",
+       "  * lat      (lat) float64 80.0 80.0 79.99 79.99 ... -59.99 -59.99 -59.99 -60.0\n",
+       "Data variables:\n",
+       "    crs      |S1 ...\n",
+       "    NDVI     (lat, lon) float32 dask.array&lt;chunksize=(47040, 120960), meta=np.ndarray&gt;\n",
+       "Attributes: (12/19)\n",
+       "    Conventions:          CF-1.6\n",
+       "    parent_identifier:    urn:cgls:global:ndvi300_v1_333m\n",
+       "    platform:             Proba-V\n",
+       "    copyright:            Copernicus Service information 2016\n",
+       "    time_coverage_end:    2014-01-10T23:59:59Z\n",
+       "    title:                10-daily Normalized Difference Vegetation Index 333...\n",
+       "    ...                   ...\n",
+       "    archive_facility:     VITO\n",
+       "    identifier:           urn:cgls:global:ndvi300_v1_333m:NDVI300_20140101000...\n",
+       "    sensor:               VEGETATION\n",
+       "    institution:          VITO NV\n",
+       "    processing_level:     L3\n",
+       "    history:              Processing line NDVI: 2016-04-26</pre><div class='xr-wrap' style='display:none'><div class='xr-header'><div class='xr-obj-type'>xarray.Dataset</div></div><ul class='xr-sections'><li class='xr-section-item'><input id='section-6c88937f-e8f8-4669-8427-0ccec22c96fc' class='xr-section-summary-in' type='checkbox' disabled ><label for='section-6c88937f-e8f8-4669-8427-0ccec22c96fc' class='xr-section-summary'  title='Expand/collapse section'>Dimensions:</label><div class='xr-section-inline-details'><ul class='xr-dim-list'><li><span class='xr-has-index'>lon</span>: 120960</li><li><span class='xr-has-index'>lat</span>: 47040</li></ul></div><div class='xr-section-details'></div></li><li class='xr-section-item'><input id='section-f587a791-8404-45c3-815d-fb7b21980e59' class='xr-section-summary-in' type='checkbox'  checked><label for='section-f587a791-8404-45c3-815d-fb7b21980e59' class='xr-section-summary' >Coordinates: <span>(2)</span></label><div class='xr-section-inline-details'></div><div class='xr-section-details'><ul class='xr-var-list'><li class='xr-var-item'><div class='xr-var-name'><span class='xr-has-index'>lon</span></div><div class='xr-var-dims'>(lon)</div><div class='xr-var-dtype'>float64</div><div class='xr-var-preview xr-preview'>-180.0 -180.0 ... 180.0 180.0</div><input id='attrs-23d60d96-c959-456b-a26b-36fd24bc5ced' class='xr-var-attrs-in' type='checkbox' ><label for='attrs-23d60d96-c959-456b-a26b-36fd24bc5ced' title='Show/Hide attributes'><svg class='icon xr-icon-file-text2'><use xlink:href='#icon-file-text2'></use></svg></label><input id='data-0111f7eb-37dd-41db-a9d4-5f0cfe6cef08' class='xr-var-data-in' type='checkbox'><label for='data-0111f7eb-37dd-41db-a9d4-5f0cfe6cef08' title='Show/Hide data repr'><svg class='icon xr-icon-database'><use xlink:href='#icon-database'></use></svg></label><div class='xr-var-attrs'><dl class='xr-attrs'><dt><span>standard_name :</span></dt><dd>longitude</dd><dt><span>long_name :</span></dt><dd>longitude</dd><dt><span>DIMENSION_LABELS :</span></dt><dd>lon</dd><dt><span>units :</span></dt><dd>degrees_east</dd><dt><span>_CoordinateAxisType :</span></dt><dd>Lon</dd><dt><span>axis :</span></dt><dd>X</dd></dl></div><div class='xr-var-data'><pre>array([-180.      , -179.997024, -179.994048, ...,  179.991071,  179.994048,\n",
+       "        179.997024])</pre></div></li><li class='xr-var-item'><div class='xr-var-name'><span class='xr-has-index'>lat</span></div><div class='xr-var-dims'>(lat)</div><div class='xr-var-dtype'>float64</div><div class='xr-var-preview xr-preview'>80.0 80.0 79.99 ... -59.99 -60.0</div><input id='attrs-2a7ef296-e581-4408-95e5-dcbac650a36d' class='xr-var-attrs-in' type='checkbox' ><label for='attrs-2a7ef296-e581-4408-95e5-dcbac650a36d' title='Show/Hide attributes'><svg class='icon xr-icon-file-text2'><use xlink:href='#icon-file-text2'></use></svg></label><input id='data-13b55a11-5731-44f6-a1f8-ac81e0edcb05' class='xr-var-data-in' type='checkbox'><label for='data-13b55a11-5731-44f6-a1f8-ac81e0edcb05' title='Show/Hide data repr'><svg class='icon xr-icon-database'><use xlink:href='#icon-database'></use></svg></label><div class='xr-var-attrs'><dl class='xr-attrs'><dt><span>standard_name :</span></dt><dd>latitude</dd><dt><span>long_name :</span></dt><dd>latitude</dd><dt><span>DIMENSION_LABELS :</span></dt><dd>lat</dd><dt><span>units :</span></dt><dd>degrees_north</dd><dt><span>_CoordinateAxisType :</span></dt><dd>Lat</dd><dt><span>axis :</span></dt><dd>Y</dd></dl></div><div class='xr-var-data'><pre>array([ 80.      ,  79.997024,  79.994048, ..., -59.991071, -59.994048,\n",
+       "       -59.997024])</pre></div></li></ul></div></li><li class='xr-section-item'><input id='section-b49fbb46-6b1e-4677-9aa6-f5189fb9326e' class='xr-section-summary-in' type='checkbox'  checked><label for='section-b49fbb46-6b1e-4677-9aa6-f5189fb9326e' class='xr-section-summary' >Data variables: <span>(2)</span></label><div class='xr-section-inline-details'></div><div class='xr-section-details'><ul class='xr-var-list'><li class='xr-var-item'><div class='xr-var-name'><span>crs</span></div><div class='xr-var-dims'>()</div><div class='xr-var-dtype'>|S1</div><div class='xr-var-preview xr-preview'>...</div><input id='attrs-28f6bfe3-dbd8-4b52-889c-6d74d2f7cc5c' class='xr-var-attrs-in' type='checkbox' ><label for='attrs-28f6bfe3-dbd8-4b52-889c-6d74d2f7cc5c' title='Show/Hide attributes'><svg class='icon xr-icon-file-text2'><use xlink:href='#icon-file-text2'></use></svg></label><input id='data-f9ee2afd-1e47-474f-8192-40b01439bfea' class='xr-var-data-in' type='checkbox'><label for='data-f9ee2afd-1e47-474f-8192-40b01439bfea' title='Show/Hide data repr'><svg class='icon xr-icon-database'><use xlink:href='#icon-database'></use></svg></label><div class='xr-var-attrs'><dl class='xr-attrs'><dt><span>long_name :</span></dt><dd>coordinate reference system</dd><dt><span>semi_major_axis :</span></dt><dd>6378137.0</dd><dt><span>spatial_ref :</span></dt><dd>GEOGCS[&quot;WGS 84&quot;,DATUM[&quot;WGS_1984&quot;,SPHEROID[&quot;WGS 84&quot;,6378137,298.257223563,AUTHORITY[&quot;EPSG&quot;,&quot;7030&quot;]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY[&quot;EPSG&quot;,&quot;6326&quot;]],PRIMEM[&quot;Greenwich&quot;,0,AUTHORITY[&quot;EPSG&quot;,&quot;8901&quot;]],UNIT[&quot;degree&quot;,0.0174532925199433,AUTHORITY[&quot;EPSG&quot;,&quot;9108&quot;]],AUTHORITY[&quot;EPSG&quot;,&quot;4326&quot;]]</dd><dt><span>grid_mapping_name :</span></dt><dd>latitude_longitude</dd><dt><span>_CoordinateTransformType :</span></dt><dd>Projection</dd><dt><span>_CoordinateAxisTypes :</span></dt><dd>GeoX GeoY</dd><dt><span>inverse_flattening :</span></dt><dd>298.257223563</dd><dt><span>GeoTransform :</span></dt><dd>-180.0000000000 0.0029761905 0.0 80.0000000000 0.0 -0.0029761905</dd><dt><span>longitude_of_prime_meridian :</span></dt><dd>0.0</dd></dl></div><div class='xr-var-data'><pre>[1 values with dtype=|S1]</pre></div></li><li class='xr-var-item'><div class='xr-var-name'><span>NDVI</span></div><div class='xr-var-dims'>(lat, lon)</div><div class='xr-var-dtype'>float32</div><div class='xr-var-preview xr-preview'>dask.array&lt;chunksize=(47040, 120960), meta=np.ndarray&gt;</div><input id='attrs-8fa6f58f-22d8-46ec-92bc-fbdfebc0622b' class='xr-var-attrs-in' type='checkbox' ><label for='attrs-8fa6f58f-22d8-46ec-92bc-fbdfebc0622b' title='Show/Hide attributes'><svg class='icon xr-icon-file-text2'><use xlink:href='#icon-file-text2'></use></svg></label><input id='data-e72149c3-701f-4389-8e77-77812e507e3c' class='xr-var-data-in' type='checkbox'><label for='data-e72149c3-701f-4389-8e77-77812e507e3c' title='Show/Hide data repr'><svg class='icon xr-icon-database'><use xlink:href='#icon-database'></use></svg></label><div class='xr-var-attrs'><dl class='xr-attrs'><dt><span>grid_mapping :</span></dt><dd>crs</dd><dt><span>flag_meanings :</span></dt><dd>Missing cloud snow sea background</dd><dt><span>long_name :</span></dt><dd>Normalized Difference Vegetation Index 333M</dd><dt><span>standard_name :</span></dt><dd>normalized_difference_vegetation_index</dd><dt><span>flag_values :</span></dt><dd>[251 252 253 254 255]</dd><dt><span>units :</span></dt><dd></dd><dt><span>valid_range :</span></dt><dd>[  0 250]</dd></dl></div><div class='xr-var-data'><table>\n",
+       "    <tr>\n",
+       "        <td>\n",
+       "            <table style=\"border-collapse: collapse;\">\n",
+       "                <thead>\n",
+       "                    <tr>\n",
+       "                        <td> </td>\n",
+       "                        <th> Array </th>\n",
+       "                        <th> Chunk </th>\n",
+       "                    </tr>\n",
+       "                </thead>\n",
+       "                <tbody>\n",
+       "                    \n",
+       "                    <tr>\n",
+       "                        <th> Bytes </th>\n",
+       "                        <td> 21.20 GiB </td>\n",
+       "                        <td> 21.20 GiB </td>\n",
+       "                    </tr>\n",
+       "                    \n",
+       "                    <tr>\n",
+       "                        <th> Shape </th>\n",
+       "                        <td> (47040, 120960) </td>\n",
+       "                        <td> (47040, 120960) </td>\n",
+       "                    </tr>\n",
+       "                    <tr>\n",
+       "                        <th> Dask graph </th>\n",
+       "                        <td colspan=\"2\"> 1 chunks in 2 graph layers </td>\n",
+       "                    </tr>\n",
+       "                    <tr>\n",
+       "                        <th> Data type </th>\n",
+       "                        <td colspan=\"2\"> float32 numpy.ndarray </td>\n",
+       "                    </tr>\n",
+       "                </tbody>\n",
+       "            </table>\n",
+       "        </td>\n",
+       "        <td>\n",
+       "        <svg width=\"170\" height=\"96\" style=\"stroke:rgb(0,0,0);stroke-width:1\" >\n",
+       "\n",
+       "  <!-- Horizontal lines -->\n",
+       "  <line x1=\"0\" y1=\"0\" x2=\"120\" y2=\"0\" style=\"stroke-width:2\" />\n",
+       "  <line x1=\"0\" y1=\"46\" x2=\"120\" y2=\"46\" style=\"stroke-width:2\" />\n",
+       "\n",
+       "  <!-- Vertical lines -->\n",
+       "  <line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"46\" style=\"stroke-width:2\" />\n",
+       "  <line x1=\"120\" y1=\"0\" x2=\"120\" y2=\"46\" style=\"stroke-width:2\" />\n",
+       "\n",
+       "  <!-- Colored Rectangle -->\n",
+       "  <polygon points=\"0.0,0.0 120.0,0.0 120.0,46.666666666666664 0.0,46.666666666666664\" style=\"fill:#ECB172A0;stroke-width:0\"/>\n",
+       "\n",
+       "  <!-- Text -->\n",
+       "  <text x=\"60.000000\" y=\"66.666667\" font-size=\"1.0rem\" font-weight=\"100\" text-anchor=\"middle\" >120960</text>\n",
+       "  <text x=\"140.000000\" y=\"23.333333\" font-size=\"1.0rem\" font-weight=\"100\" text-anchor=\"middle\" transform=\"rotate(-90,140.000000,23.333333)\">47040</text>\n",
+       "</svg>\n",
+       "        </td>\n",
+       "    </tr>\n",
+       "</table></div></li></ul></div></li><li class='xr-section-item'><input id='section-e00525cc-04f9-412d-a251-2318b5446f35' class='xr-section-summary-in' type='checkbox'  ><label for='section-e00525cc-04f9-412d-a251-2318b5446f35' class='xr-section-summary' >Indexes: <span>(2)</span></label><div class='xr-section-inline-details'></div><div class='xr-section-details'><ul class='xr-var-list'><li class='xr-var-item'><div class='xr-index-name'><div>lon</div></div><div class='xr-index-preview'>PandasIndex</div><div></div><input id='index-cf0d896a-8b33-4cdb-8e62-18c641356a3e' class='xr-index-data-in' type='checkbox'/><label for='index-cf0d896a-8b33-4cdb-8e62-18c641356a3e' title='Show/Hide index repr'><svg class='icon xr-icon-database'><use xlink:href='#icon-database'></use></svg></label><div class='xr-index-data'><pre>PandasIndex(Index([             -180.0,  -179.9970238095238,  -179.9940476190476,\n",
+       "        -179.9910714285714, -179.98809523809518, -179.98511904761898,\n",
+       "       -179.98214285714278, -179.97916666666657, -179.97619047619037,\n",
+       "       -179.97321428571416,\n",
+       "       ...\n",
+       "        179.97023809687505,  179.97321428735125,  179.97619047782746,\n",
+       "        179.97916666830366,  179.98214285877987,  179.98511904925607,\n",
+       "        179.98809523973227,  179.99107143020848,  179.99404762068468,\n",
+       "         179.9970238111609],\n",
+       "      dtype=&#x27;float64&#x27;, name=&#x27;lon&#x27;, length=120960))</pre></div></li><li class='xr-var-item'><div class='xr-index-name'><div>lat</div></div><div class='xr-index-preview'>PandasIndex</div><div></div><input id='index-f5a411cd-3fa0-4833-8618-1cdbff754caf' class='xr-index-data-in' type='checkbox'/><label for='index-f5a411cd-3fa0-4833-8618-1cdbff754caf' title='Show/Hide index repr'><svg class='icon xr-icon-database'><use xlink:href='#icon-database'></use></svg></label><div class='xr-index-data'><pre>PandasIndex(Index([               80.0,   79.99702380952381,   79.99404761904762,\n",
+       "         79.99107142857143,   79.98809523809524,   79.98511904761905,\n",
+       "         79.98214285714286,   79.97916666666667,   79.97619047619048,\n",
+       "         79.97321428571429,\n",
+       "       ...\n",
+       "        -59.97023809520627, -59.973214285682445,  -59.97619047615865,\n",
+       "        -59.97916666663485,  -59.98214285711103, -59.985119047587204,\n",
+       "        -59.98809523806341,  -59.99107142853961,  -59.99404761901579,\n",
+       "       -59.997023809491964],\n",
+       "      dtype=&#x27;float64&#x27;, name=&#x27;lat&#x27;, length=47040))</pre></div></li></ul></div></li><li class='xr-section-item'><input id='section-e97fd7ac-b8ad-45d1-83ca-b2c0cc04d497' class='xr-section-summary-in' type='checkbox'  ><label for='section-e97fd7ac-b8ad-45d1-83ca-b2c0cc04d497' class='xr-section-summary' >Attributes: <span>(19)</span></label><div class='xr-section-inline-details'></div><div class='xr-section-details'><dl class='xr-attrs'><dt><span>Conventions :</span></dt><dd>CF-1.6</dd><dt><span>parent_identifier :</span></dt><dd>urn:cgls:global:ndvi300_v1_333m</dd><dt><span>platform :</span></dt><dd>Proba-V</dd><dt><span>copyright :</span></dt><dd>Copernicus Service information 2016</dd><dt><span>time_coverage_end :</span></dt><dd>2014-01-10T23:59:59Z</dd><dt><span>title :</span></dt><dd>10-daily Normalized Difference Vegetation Index 333M: GLOBE 2014-01-01T00:00:00Z</dd><dt><span>product_version :</span></dt><dd>V1.0.1</dd><dt><span>long_name :</span></dt><dd>Normalized Difference Vegetation Index</dd><dt><span>time_coverage_start :</span></dt><dd>2014-01-01T00:00:00Z</dd><dt><span>source :</span></dt><dd>Derived from EO satellite imagery</dd><dt><span>processing_mode :</span></dt><dd>Near Real Time</dd><dt><span>references :</span></dt><dd>http://land.copernicus.eu/global/products/ndvi</dd><dt><span>orbit_type :</span></dt><dd>LEO</dd><dt><span>archive_facility :</span></dt><dd>VITO</dd><dt><span>identifier :</span></dt><dd>urn:cgls:global:ndvi300_v1_333m:NDVI300_201401010000_GLOBE_PROBAV_V1.0.1</dd><dt><span>sensor :</span></dt><dd>VEGETATION</dd><dt><span>institution :</span></dt><dd>VITO NV</dd><dt><span>processing_level :</span></dt><dd>L3</dd><dt><span>history :</span></dt><dd>Processing line NDVI: 2016-04-26</dd></dl></div></li></ul></div></div>"
+      ],
+      "text/plain": [
+       "<xarray.Dataset>\n",
+       "Dimensions:  (lon: 120960, lat: 47040)\n",
+       "Coordinates:\n",
+       "  * lon      (lon) float64 -180.0 -180.0 -180.0 -180.0 ... 180.0 180.0 180.0\n",
+       "  * lat      (lat) float64 80.0 80.0 79.99 79.99 ... -59.99 -59.99 -59.99 -60.0\n",
+       "Data variables:\n",
+       "    crs      |S1 ...\n",
+       "    NDVI     (lat, lon) float32 dask.array<chunksize=(47040, 120960), meta=np.ndarray>\n",
+       "Attributes: (12/19)\n",
+       "    Conventions:          CF-1.6\n",
+       "    parent_identifier:    urn:cgls:global:ndvi300_v1_333m\n",
+       "    platform:             Proba-V\n",
+       "    copyright:            Copernicus Service information 2016\n",
+       "    time_coverage_end:    2014-01-10T23:59:59Z\n",
+       "    title:                10-daily Normalized Difference Vegetation Index 333...\n",
+       "    ...                   ...\n",
+       "    archive_facility:     VITO\n",
+       "    identifier:           urn:cgls:global:ndvi300_v1_333m:NDVI300_20140101000...\n",
+       "    sensor:               VEGETATION\n",
+       "    institution:          VITO NV\n",
+       "    processing_level:     L3\n",
+       "    history:              Processing line NDVI: 2016-04-26"
+      ]
+     },
+     "execution_count": 1,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "import earthkit.data \n",
+    "\n",
+    "ds = earthkit.data.from_source(\n",
+    "        \"wekeo\",\n",
+    "        \"EO:CLMS:DAT:CGLS_GLOBAL_NDVI300_V1_333M\",\n",
+    "        request={\n",
+    "            \"datasetId\": \"EO:CLMS:DAT:CGLS_GLOBAL_NDVI300_V1_333M\",\n",
+    "            \"dateRangeSelectValues\": [\n",
+    "                {\n",
+    "                  \"name\": \"dtrange\",\n",
+    "                  \"start\": \"2014-01-01T00:00:00.000Z\",\n",
+    "                  \"end\": \"2014-01-01T23:59:59.999Z\"\n",
+    "                }\n",
+    "            ]\n",
+    "      }\n",
+    "    )\n",
+    "\n",
+    "ds.to_xarray()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "23cd1784-1478-4ac0-8ad8-cf6d8d5358f8",
+   "metadata": {},
+   "source": [
+    "####  Using the CDSAPI grammar"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e50860ec-4da6-4fdb-b65c-af876ad5c6df",
+   "metadata": {},
+   "source": [
+    "We can use the \"wekeocds\" source to access [Copernicus Climate Data Store (CDS)](https://cds.climate.copernicus.eu) datasets served on [WEkEO](https://www.wekeo.eu/) using the [cdsapi](https://pypi.org/project/cdsapi) standard grammar."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "777ec204-fbff-41f3-8ae2-3f60c6acb39f",
+   "metadata": {},
+   "source": [
+    "The following example retrieves ERA5 surface data in GRIB format:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "eff2587c-eb03-4de5-b3c4-f894d31027be",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>centre</th>\n",
+       "      <th>shortName</th>\n",
+       "      <th>typeOfLevel</th>\n",
+       "      <th>level</th>\n",
+       "      <th>dataDate</th>\n",
+       "      <th>dataTime</th>\n",
+       "      <th>stepRange</th>\n",
+       "      <th>dataType</th>\n",
+       "      <th>number</th>\n",
+       "      <th>gridType</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>ecmf</td>\n",
+       "      <td>2t</td>\n",
+       "      <td>surface</td>\n",
+       "      <td>0</td>\n",
+       "      <td>20121212</td>\n",
+       "      <td>1200</td>\n",
+       "      <td>0</td>\n",
+       "      <td>an</td>\n",
+       "      <td>0</td>\n",
+       "      <td>regular_ll</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>ecmf</td>\n",
+       "      <td>msl</td>\n",
+       "      <td>surface</td>\n",
+       "      <td>0</td>\n",
+       "      <td>20121212</td>\n",
+       "      <td>1200</td>\n",
+       "      <td>0</td>\n",
+       "      <td>an</td>\n",
+       "      <td>0</td>\n",
+       "      <td>regular_ll</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "  centre shortName typeOfLevel  level  dataDate  dataTime stepRange dataType  \\\n",
+       "0   ecmf        2t     surface      0  20121212      1200         0       an   \n",
+       "1   ecmf       msl     surface      0  20121212      1200         0       an   \n",
+       "\n",
+       "   number    gridType  \n",
+       "0       0  regular_ll  \n",
+       "1       0  regular_ll  "
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "ds = earthkit.data.from_source(\n",
+    "        \"wekeocds\",\n",
+    "        \"EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS\",\n",
+    "        variable=[\"2m_temperature\", \"mean_sea_level_pressure\"],\n",
+    "        product_type=[\"reanalysis\"],\n",
+    "        year=\"2012\",\n",
+    "        month=\"12\",\n",
+    "        day=\"12\",\n",
+    "        time=\"12:00\",\n",
+    "        format=\"grib\"\n",
+    "    )\n",
+    "\n",
+    "ds.ls()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "fffe1e96-03da-434d-a3cd-fa51ed847ac6",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "dev",
+   "language": "python",
+   "name": "dev"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.11"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/guide/data_format/bufr.rst b/docs/guide/data_format/bufr.rst
index a01db1c..118282f 100644
--- a/docs/guide/data_format/bufr.rst
+++ b/docs/guide/data_format/bufr.rst
@@ -9,12 +9,12 @@ BUFR (Binary Universal Form for Representation of meteorological data) is a bina
 BUFRList
 +++++++++++
 
-We can read/retrieve BUFR data with :func:`from_source <data-sources>`. The resulting object will be a :obj:`BUFRList <data.readers.bufr.bufr.BUFRList>` representing a list of :obj:`BUFRMessage <data.readers.bufr.bufr.BUFRMessage>`.
+We can read/retrieve BUFR data with :func:`from_source <data-sources>`. The resulting object will be a :obj:`BUFRList <data.readers.bufr.bufr.BUFRList>` representing a list of :obj:`BUFRMessage <data.readers.bufr.bufr.BUFRMessage>`\ s.
 
 The structure of a BUFR message is typically hierarchical and can be rather complex, so the recommended way to deal with BUFR data is to extract the required data with :meth:`to_pandas() <data.readers.bufr.bufr.BUFRList.to_pandas>`
 into a pandas DataFrame, which is much easier to work with.
 
-The following table gives an overview of the :obj:`BUFRList API<data.readers.bufr.bufr.BUFRList>`:
+The following table gives us an overview of the :obj:`BUFRList API<data.readers.bufr.bufr.BUFRList>`:
 
 .. list-table:: Highlights of the BUFRList API
    :header-rows: 1
diff --git a/docs/guide/data_format/grib.rst b/docs/guide/data_format/grib.rst
index 3a6dd88..818f79a 100644
--- a/docs/guide/data_format/grib.rst
+++ b/docs/guide/data_format/grib.rst
@@ -9,7 +9,7 @@ GRIB is the WMO's format for binary gridded data consisting of GRIB messages, wh
 Fieldlists
 +++++++++++
 
-We can read/retrieve GRIB data with :func:`from_source`. The resulting object will be a :class:`~data.readers.grib.index.GribFieldList` representing a list of :class:`~data.readers.grib.codes.GribField`, which we can iterate through:
+We can read/retrieve GRIB data with :func:`from_source`. The resulting object will be a :class:`~data.readers.grib.index.GribFieldList` representing a list of :class:`~data.readers.grib.codes.GribField`\ s, which we can iterate through:
 
 .. code-block:: python
 
@@ -26,7 +26,7 @@ We can read/retrieve GRIB data with :func:`from_source`. The resulting object wi
     GribField(msl,1000,20200513,1200,0,0)
 
 
-The following table gives an overview of the GRIB :class:`~data.readers.grib.index.FieldList` API:
+The following table gives us an overview of the GRIB :class:`~data.readers.grib.index.FieldList` API:
 
 .. list-table:: Highlights of the GRIB FieldList API
    :header-rows: 1
diff --git a/docs/guide/sources.rst b/docs/guide/sources.rst
index e823475..82fae65 100644
--- a/docs/guide/sources.rst
+++ b/docs/guide/sources.rst
@@ -46,6 +46,10 @@ We can get data from a given source by using :func:`from_source`:
       - retrieve `ECMWF open data <https://www.ecmwf.int/en/forecasts/datasets/open-data>`_
     * - :ref:`data-sources-fdb`
       - retrieve data from the `Fields DataBase <https://fields-database.readthedocs.io/en/latest/>`_ (FDB)
+    * - :ref:`data-sources-wekeo`
+      - retrieve data from `WEkEO`_ using the WEkEO grammar
+    * - :ref:`data-sources-wekeocds`
+      - retrieve `CDS <https://cds.climate.copernicus.eu/>`_ data stored on `WEkEO`_ using the `cdsapi`_ grammar
 
 
 ----------------------------------
@@ -525,6 +529,92 @@ fdb
       - :ref:`/examples/fdb.ipynb`
 
 
+.. _data-sources-wekeo:
+
+wekeo
+-----
+
+.. py:function:: from_source("wekeo", dataset, *args, **kwargs)
+  :noindex:
+
+  `WEkEO`_ is the Copernicus DIAS reference service for environmental data and virtual processing environments. The ``wekeo`` source provides access to `WEkEO`_ using the WEkEO grammar. The retrieval is based on the hda_ Python API.
+
+  :param str dataset: the name of the WEkEO dataset
+  :param tuple *args: specifies the request as a dict
+  :param dict **kwargs: other keyword arguments specifying the request
+
+  The following example retrieves Normalized Difference Vegetation Index data derived from EO satellite imagery in NetCDF format:
+
+  .. code-block:: python
+
+      import earthkit.data
+
+      ds = earthkit.data.from_source(
+          "wekeo",
+          "EO:CLMS:DAT:CGLS_GLOBAL_NDVI300_V1_333M",
+          request={
+              "datasetId": "EO:CLMS:DAT:CGLS_GLOBAL_NDVI300_V1_333M",
+              "dateRangeSelectValues": [
+                  {
+                      "name": "dtrange",
+                      "start": "2014-01-01T00:00:00.000Z",
+                      "end": "2014-01-01T23:59:59.999Z",
+                  }
+              ],
+          },
+      )
+
+
+  Data downloaded from WEkEO is stored in the the :ref:`cache <caching>`.
+
+  To access data from WEkEO, you will need to register and set up the Harmonized Data Access (HDA) API client. The process is described `here <https://help.wekeo.eu/en/articles/6751608-what-is-the-hda-api-python-client-and-how-to-use-it>`_.
+
+  Further examples:
+
+      - :ref:`/examples/wekeo.ipynb`
+
+
+.. _data-sources-wekeocds:
+
+wekeocds
+--------
+
+.. py:function:: from_source("wekeocds", dataset, *args, **kwargs)
+  :noindex:
+
+  `WEkEO`_ is the Copernicus DIAS reference service for environmental data and virtual processing environments. The ``wekeocds`` source provides access to `Copernicus Climate Data Store`_ (CDS) datasets served on `WEkEO`_ using the `cdsapi`_ grammar. The retrieval is based on the hda_ Python API.
+
+  :param str dataset: the name of the WEkEO dataset
+  :param tuple *args: specifies the request as a dict
+  :param dict **kwargs: other keyword arguments specifying the request
+
+  The following example retrieves ERA5 surface data for multiple days in GRIB format:
+
+  .. code-block:: python
+
+      import earthkit.data
+
+      ds = earthkit.data.from_source(
+          "wekeocds",
+          "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
+          variable=["2m_temperature", "mean_sea_level_pressure"],
+          product_type=["reanalysis"],
+          year=["2012"],
+          month=["12"],
+          day=["12", "13", "14", "15"],
+          time=["11:00"],
+          format="grib",
+      )
+
+  Data downloaded from WEkEO is stored in the the :ref:`cache <caching>`.
+
+  To access data from WEkEO, you will need to register and set up the Harmonized Data Access (HDA) API client. The process is described `here <https://help.wekeo.eu/en/articles/6751608-what-is-the-hda-api-python-client-and-how-to-use-it>`_.
+
+  Further examples:
+
+      - :ref:`/examples/wekeo.ipynb`
+
+
 .. _MARS catalog: https://apps.ecmwf.int/archive-catalogue/
 .. _MARS user documentation: https://confluence.ecmwf.int/display/UDOC/MARS+user+documentation
 .. _web API: https://www.ecmwf.int/en/forecasts/access-forecasts/ecmwf-web-api
@@ -536,3 +626,6 @@ fdb
 .. _ADS_knowledge base: https://confluence.ecmwf.int/pages/viewpage.action?pageId=151530675
 
 .. _ECMWF open data: https://www.ecmwf.int/en/forecasts/datasets/open-data
+
+.. _WEkEO: https://www.wekeo.eu/
+.. _hda: https://pypi.org/project/hda
diff --git a/docs/howtos.rst b/docs/howtos.rst
new file mode 100644
index 0000000..1f19201
--- /dev/null
+++ b/docs/howtos.rst
@@ -0,0 +1,27 @@
+.. _howtos
+
+
+Howtos
+============
+
+
+How to save results from a retrieval into a file?
+--------------------------------------------------------------
+
+You need to use the :func:`save` method on the resulting object. For example, this is how to
+save the results of a :ref:`MARS retrieval <data-sources-mars>` into a file:
+
+.. code-block:: python
+
+    import earthkit.data
+
+    ds = earthkit.data.from_source(
+        "mars",
+        param=["2t", "msl"],
+        levtype="sfc",
+        area=[50, -10, 40, 10],  # N,W,S,E
+        grid=[2, 2],
+        date="2023-05-10",
+    )
+
+    ds.save("my_data.grib")
diff --git a/docs/index.rst b/docs/index.rst
index 937d8b9..baa69b8 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -5,11 +5,6 @@ Welcome to earthkit-data's documentation
 
     This project is **BETA** and will be **Experimental** for the foreseeable future. Interfaces and functionality are likely to change, and the project itself may be scrapped. **DO NOT** use this software in any project/software that is operational.
 
-.. warning::
-
-    This documentation is still work in progress and can only be regarded as a **DRAFT**.
-
-
 **earthkit-data** is a format-agnostic Python interface for geospatial data with a focus on meteorology and
 climate science.
 
@@ -40,6 +35,7 @@ reference systems and bounding boxes.
    :maxdepth: 1
    :caption: Documentation
 
+   howtos
    guide/index
    api
 
diff --git a/docs/release_notes/index.rst b/docs/release_notes/index.rst
index 735948d..31d83e5 100644
--- a/docs/release_notes/index.rst
+++ b/docs/release_notes/index.rst
@@ -4,5 +4,6 @@ Release notes
 .. toctree::
     :maxdepth: 1
 
+    version_0.4_updates
     version_0.3_updates
     version_0.2_updates
diff --git a/docs/release_notes/version_0.4_updates.rst b/docs/release_notes/version_0.4_updates.rst
new file mode 100644
index 0000000..f9ae512
--- /dev/null
+++ b/docs/release_notes/version_0.4_updates.rst
@@ -0,0 +1,29 @@
+Version 0.4 Updates
+/////////////////////////
+
+.. warning::
+    This is an upcoming release.
+
+Version 0.4.0
+===============
+
+New features
+++++++++++++++++
+
+- added new sources :ref:`data-sources-wekeo` and :ref:`data-sources-wekeocds` to retrieve data from `WEkEO <https://www.wekeo.eu/>`_. See the :ref:`/examples/wekeo.ipynb` notebook example.
+- added the ``append`` option to :meth:`FieldList.save() <data.core.fieldlist.FieldList.save>`.
+- added the ``dtype`` option to the ``to_data()``, ``to_latlon()`` and ``to_points()`` methods both on a :class:`~data.core.fieldlist.Field` or :class:`~data.core.fieldlist.FieldList`.
+- allowed access to 32-bit GRIB data values without requiring a cast in Python from 64 to 32 bits. Only works with a recent ecCodes version (ecCodes >= 2.31.0 and eccodes-python >= 1.6.0 required). In order to use this feature set ``dtype=np.float32`` in the ``to_numpy()``, ``to_data()``, ``to_latlon()`` or ``to_points()`` methods on either a :class:`~data.core.fieldlist.Field` or :class:`~data.core.fieldlist.FieldList`.
+- added :meth:`~data.core.readers.csv.CSVReader.to_xarray` to csv data
+- no ecCodes installation is required when only accessing non-GRIB data
+
+
+Fixes
+++++++
+
+- fixed issue when concatenation for :class:`~data.core.readers.numpy_list.NumpyFieldList` did not work
+- fixed issue when concatenation to an empty Fieldlist did not work
+- fixed issue when could not get values from a  :class:`~data.core.readers.numpy_list.NumpyFieldList`
+- fixed issue when could not retrieve data from the :ref:`CDS <data-sources-wekeo>` beacause the ``month`` and ``day`` request parameters were pre-filled by earthkit-data. These parameters are not pre-filled any longer.
+- fixed issue when missing values were not correctly written to GRIB output
+- fixed issue when could not read non-fieldlist type NetCDF data with :func:`from_source`
diff --git a/docs/skip_api_rules.py b/docs/skip_api_rules.py
index 71de5b7..3e9f4fb 100644
--- a/docs/skip_api_rules.py
+++ b/docs/skip_api_rules.py
@@ -65,6 +65,24 @@ _skip_methods = {
         "statistics",
         "xarray_open_dataset_kwargs",
     ],
+    "data.readers.csv.CSVReader": [
+        "bounding_box",
+        "cache_file",
+        "to_numpy",
+        "ignore",
+        "datetime",
+        "index_content",
+        "isel",
+        "merge",
+        "metadata",
+        "mutate",
+        "mutate_source",
+        "order_by",
+        "sel",
+        "filter",
+        "merger",
+        "source",
+    ],
 }
 
 
@@ -81,6 +99,7 @@ def _skip_api_items(app, what, name, obj, skip, options):
         "data.readers.bufr.bufr",
         "data.readers.grib.codes",
         "data.readers.grib.index",
+        "data.readers.csv",
         "data.utils",
         "data.utils.bbox",
     ]:
@@ -93,6 +112,7 @@ def _skip_api_items(app, what, name, obj, skip, options):
         "data.readers.bufr.bufr",
         "data.readers.grib",
         "data.readers.grib.index",
+        "data.readers.csv",
         "data.utils",
         "data.utils.bbox",
     ]:
@@ -107,6 +127,7 @@ def _skip_api_items(app, what, name, obj, skip, options):
         "data.readers.grib.codes.GribField",
         "data.readers.grib.index.GribFieldList",
         "data.readers.grib.metadata.GribMetadata",
+        "data.readers.csv.CSVReader",
         "data.utils.bbox.BoundingBox",
     ]:
         skip = True
diff --git a/earthkit/data/readers/csv.py b/earthkit/data/readers/csv.py
index 3a9a927..61b7e35 100644
--- a/earthkit/data/readers/csv.py
+++ b/earthkit/data/readers/csv.py
@@ -127,15 +127,38 @@ def is_csv(path, probe_size=4096, compression=None):
 
 
 class CSVReader(Reader):
+    r"""Class representing CSV data"""
+
     def __init__(self, source, path, compression=None):
         super().__init__(source, path)
         self.compression = compression
         self.dialect, self.has_header = probe_csv(path, compression=compression)
 
-    def to_pandas(self, **kwargs):
+    def to_pandas(self, pandas_read_csv_kwargs=None):
+        """Convert CSV data into a :py:class:`pandas.DataFrame` using :py:func:`pandas.read_csv`.
+
+        Parameters
+        ----------
+        pandas_read_csv_kwargs: dict
+            kwargs passed to :func:`pandas.read_csv`.
+
+        Returns
+        -------
+        :py:class:`pandas.DataFrame`
+
+
+        Examples
+        --------
+        >>> import earthkit.data
+        >>> ds = earthkit.data.from_source("file", "data_with_comments.csv")
+        >>> df = ds.to_pandas(pandas_read_csv_kwargs={"comment": "#"})
+
+        """
         import pandas
 
-        pandas_read_csv_kwargs = kwargs.get("pandas_read_csv_kwargs", {})
+        if pandas_read_csv_kwargs is None:
+            pandas_read_csv_kwargs = {}
+
         if self.compression is not None:
             pandas_read_csv_kwargs = dict(**pandas_read_csv_kwargs)
             pandas_read_csv_kwargs["compression"] = self.compression
@@ -143,6 +166,27 @@ class CSVReader(Reader):
         LOG.debug("pandas.read_csv(%s,%s)", self.path, pandas_read_csv_kwargs)
         return pandas.read_csv(self.path, **pandas_read_csv_kwargs)
 
+    def to_xarray(self, pandas_read_csv_kwargs=None):
+        """Convert CSV data into an xarray object`.
+
+        First, the data is converted into a :py:class:`pandas.DataFrame` with :py:func:`pandas.read_csv`,
+        then :py:meth:`pandas.DataFrame.to_xarray` is called to generate the xarray object.
+
+        Parameters
+        ----------
+        pandas_read_csv_kwargs: dict
+            kwargs passed to :py:func:`pandas.read_csv`.
+
+        Returns
+        -------
+        Xarray object
+
+        """
+        if pandas_read_csv_kwargs is None:
+            pandas_read_csv_kwargs = {}
+
+        return self.to_pandas(pandas_read_csv_kwargs=pandas_read_csv_kwargs).to_xarray()
+
 
 def reader(source, path, magic, deeper_check, fwf=False):
     kind, compression = mimetypes.guess_type(path)
diff --git a/earthkit/data/readers/netcdf.py b/earthkit/data/readers/netcdf.py
index 0074ff2..fa8bf0e 100644
--- a/earthkit/data/readers/netcdf.py
+++ b/earthkit/data/readers/netcdf.py
@@ -144,6 +144,122 @@ class DataSet:
         return self._bbox[(lat, lon)]
 
 
+def get_fields_from_ds(
+    ds,
+    field_type=None,
+    check_only=False,
+):  # noqa C901
+    # Select only geographical variables
+    has_lat = False
+    has_lon = False
+
+    fields = []
+
+    skip = set()
+
+    for name in ds.data_vars:
+        v = ds[name]
+        skip.update(getattr(v, "coordinates", "").split(" "))
+        skip.update(getattr(v, "bounds", "").split(" "))
+        skip.update(getattr(v, "grid_mapping", "").split(" "))
+
+    for name in ds.data_vars:
+        if name in skip:
+            continue
+
+        v = ds[name]
+
+        coordinates = []
+
+        # self.log.info('Scanning file: %s var=%s coords=%s', self.path, name, v.coords)
+
+        info = [value for value in v.coords if value not in v.dims]
+        non_dim_coords = {}
+        for coord in v.coords:
+            if coord not in v.dims:
+                non_dim_coords[coord] = ds[coord].values
+                continue
+
+            c = ds[coord]
+
+            # self.log.info("COORD %s %s %s %s", coord, type(coord), hasattr(c, 'calendar'), c)
+
+            standard_name = getattr(c, "standard_name", "")
+            axis = getattr(c, "axis", "")
+            long_name = getattr(c, "long_name", "")
+
+            use = False
+
+            if (
+                standard_name.lower() in GEOGRAPHIC_COORDS["x"]
+                or (long_name == "longitude")
+                or (axis == "X")
+            ):
+                has_lon = True
+                use = True
+
+            if (
+                standard_name.lower() in GEOGRAPHIC_COORDS["y"]
+                or (long_name == "latitude")
+                or (axis == "Y")
+            ):
+                has_lat = True
+                use = True
+
+            # print(f"  standard_name={standard_name}")
+
+            # Of course, not every one sets the standard_name
+            if (
+                standard_name in ["time", "forecast_reference_time"]
+                or long_name in ["time"]
+                or axis == "T"
+            ):
+                # we might not be able to convert time to datetime
+                try:
+                    coordinates.append(TimeCoordinate(c, coord in info))
+                    use = True
+                except ValueError:
+                    break
+
+            # TODO: Support other level types
+            if standard_name in [
+                "air_pressure",
+                "model_level_number",
+                "altitude",
+            ] or long_name in [
+                "pressure_level"
+            ]:  # or axis == 'Z':
+                coordinates.append(LevelCoordinate(c, coord in info))
+                use = True
+
+            if axis in ("X", "Y"):
+                use = True
+
+            if not use:
+                coordinates.append(OtherCoordinate(c, coord in info))
+
+        if not (has_lat and has_lon):
+            # self.log.info("NetCDFReader: skip %s (Not a 2 field)", name)
+            continue
+
+        for values in product(*[c.values for c in coordinates]):
+            slices = []
+            for value, coordinate in zip(values, coordinates):
+                slices.append(coordinate.make_slice(value))
+
+            if check_only:
+                return True
+
+            fields.append(field_type(ds, name, slices, non_dim_coords))
+
+    # if not fields:
+    #     raise Exception("NetCDFReader no 2D fields found in %s" % (self.path,))
+
+    if check_only:
+        return False
+    return fields
+
+
 class XArrayFieldGeography(Geography):
     def __init__(self, metadata, da, ds, variable):
         self.metadata = metadata
@@ -253,7 +369,6 @@ class XArrayMetadata(RawMetadata):
 class XArrayField(Field):
     def __init__(self, ds, variable, slices, non_dim_coords):
         super().__init__()
-
         self._ds = ds
         self._da = ds[variable]
 
@@ -343,118 +458,20 @@ class XArrayFieldListCore(FieldList):
             self._scan()
         return self._fields
 
+    def has_fields(self):
+        if self._fields is None:
+            return get_fields_from_ds(
+                DataSet(self.ds), field_type=self.FIELD_TYPE, check_only=True
+            )
+        else:
+            return len(self._fields)
+
     def _scan(self):
         if self._fields is None:
             self._fields = self._get_fields()
 
     def _get_fields(self):
-        return self._get_fields_from_ds(DataSet(self.ds))
-
-    def _get_fields_from_ds(self, ds):  # noqa C901
-        # Select only geographical variables
-        has_lat = False
-        has_lon = False
-
-        fields = []
-
-        skip = set()
-
-        for name in ds.data_vars:
-            v = ds[name]
-            skip.update(getattr(v, "coordinates", "").split(" "))
-            skip.update(getattr(v, "bounds", "").split(" "))
-            skip.update(getattr(v, "grid_mapping", "").split(" "))
-
-        for name in ds.data_vars:
-            if name in skip:
-                continue
-
-            v = ds[name]
-
-            coordinates = []
-
-            # self.log.info('Scanning file: %s var=%s coords=%s', self.path, name, v.coords)
-
-            info = [value for value in v.coords if value not in v.dims]
-            non_dim_coords = {}
-            for coord in v.coords:
-                if coord not in v.dims:
-                    non_dim_coords[coord] = ds[coord].values
-                    continue
-
-                c = ds[coord]
-
-                # self.log.info("COORD %s %s %s %s", coord, type(coord), hasattr(c, 'calendar'), c)
-
-                standard_name = getattr(c, "standard_name", "")
-                axis = getattr(c, "axis", "")
-                long_name = getattr(c, "long_name", "")
-
-                use = False
-
-                if (
-                    standard_name.lower() in GEOGRAPHIC_COORDS["x"]
-                    or (long_name == "longitude")
-                    or (axis == "X")
-                ):
-                    has_lon = True
-                    use = True
-
-                if (
-                    standard_name.lower() in GEOGRAPHIC_COORDS["y"]
-                    or (long_name == "latitude")
-                    or (axis == "Y")
-                ):
-                    has_lat = True
-                    use = True
-
-                # print(f"  standard_name={standard_name}")
-
-                # Of course, not every one sets the standard_name
-                if (
-                    standard_name in ["time", "forecast_reference_time"]
-                    or long_name in ["time"]
-                    or axis == "T"
-                ):
-                    # we might not be able to convert time to datetime
-                    try:
-                        coordinates.append(TimeCoordinate(c, coord in info))
-                        use = True
-                    except ValueError:
-                        break
-
-                # TODO: Support other level types
-                if standard_name in [
-                    "air_pressure",
-                    "model_level_number",
-                    "altitude",
-                ] or long_name in [
-                    "pressure_level"
-                ]:  # or axis == 'Z':
-                    coordinates.append(LevelCoordinate(c, coord in info))
-                    use = True
-
-                if axis in ("X", "Y"):
-                    use = True
-
-                if not use:
-                    coordinates.append(OtherCoordinate(c, coord in info))
-
-            if not (has_lat and has_lon):
-                # self.log.info("NetCDFReader: skip %s (Not a 2 field)", name)
-                continue
-
-            for values in product(*[c.values for c in coordinates]):
-                slices = []
-                for value, coordinate in zip(values, coordinates):
-                    slices.append(coordinate.make_slice(value))
-
-                fields.append(self.FIELD_TYPE(ds, name, slices, non_dim_coords))
-
-        # if not fields:
-        #     raise Exception("NetCDFReader no 2D fields found in %s" % (self.path,))
-
-        return fields
+        return get_fields_from_ds(DataSet(self.ds), field_type=self.FIELD_TYPE)
 
     def to_pandas(self):
         return self.to_xarray().to_pandas()
@@ -524,8 +541,9 @@ class NetCDFField(XArrayField):
 
 
 class NetCDFFieldList(XArrayFieldListCore):
+    FIELD_TYPE = NetCDFField
+
     def __init__(self, path, *args, **kwargs):
-        self.FIELD_TYPE = NetCDFField
         self.path = path
         # self._fields = None
         super().__init__(None, *args, **kwargs)
@@ -536,7 +554,20 @@ class NetCDFFieldList(XArrayFieldListCore):
         with closing(
             xr.open_mfdataset(self.path, combine="by_coords")
         ) as ds:  # or nested
-            return self._get_fields_from_ds(DataSet(ds))
+            return get_fields_from_ds(DataSet(ds), field_type=self.FIELD_TYPE)
+
+    def has_fields(self):
+        if self._fields is None:
+            import xarray as xr
+
+            with closing(
+                xr.open_mfdataset(self.path, combine="by_coords")
+            ) as ds:  # or nested
+                return get_fields_from_ds(
+                    DataSet(ds), field_type=self.FIELD_TYPE, check_only=True
+                )
+        else:
+            return len(self._fields)
 
     @classmethod
     def merge(cls, sources):
@@ -575,6 +606,9 @@ class NetCDFFieldList(XArrayFieldListCore):
             **options,
         )
 
+    def write(self, *args, **kwargs):
+        return self.to_netcdf(*args, **kwargs)
+
 
 class NetCDFFieldListInFiles(NetCDFFieldList):
     pass
@@ -601,15 +635,24 @@ class NetCDFMaskFieldList(NetCDFFieldList, MaskIndex):
     def __init__(self, *args, **kwargs):
         MaskIndex.__init__(self, *args, **kwargs)
 
+    # TODO: Implement this, but discussion required
+    def to_xarray(self, *args, **kwargs):
+        self._not_implemented()
+
 
 class NetCDFMultiFieldList(NetCDFFieldList, MultiIndex):
     def __init__(self, *args, **kwargs):
         MultiIndex.__init__(self, *args, **kwargs)
 
     def to_xarray(self, **kwargs):
-        return NetCDFFieldList.to_xarray_multi_from_paths(
-            [x.path for x in self.indexes], **kwargs
-        )
+        try:
+            return NetCDFFieldList.to_xarray_multi_from_paths(
+                [x.path for x in self.indexes], **kwargs
+            )
+        except AttributeError:
+            # TODO: Implement this, but discussion required
+            #  This catches Multi-MaskFieldLists which cannot be openned in xarray
+            self._not_implemented()
 
 
 class NetCDFFieldListReader(NetCDFFieldListInOneFile, Reader):
@@ -628,7 +671,6 @@ class NetCDFFieldListReader(NetCDFFieldListInOneFile, Reader):
 class NetCDFReader(Reader):
     def __init__(self, source, path):
         Reader.__init__(self, source, path)
-        NetCDFFieldList.__init__(self, path)
 
     def __repr__(self):
         return "NetCDFReader(%s)" % (self.path,)
@@ -671,7 +713,7 @@ def _match_magic(magic, deeper_check):
 def reader(source, path, magic=None, deeper_check=False):
     if _match_magic(magic, deeper_check):
         fs = NetCDFFieldListReader(source, path)
-        if len(fs) > 0:
+        if fs.has_fields():
             return fs
         else:
             return NetCDFReader(source, path)
diff --git a/earthkit/data/sources/cds.py b/earthkit/data/sources/cds.py
index 778cd36..aafec3c 100644
--- a/earthkit/data/sources/cds.py
+++ b/earthkit/data/sources/cds.py
@@ -110,13 +110,6 @@ class CdsRetriever(FileSource):
     @normalize("date", "date-list(%Y-%m-%d)")
     @normalize("area", "bounding-box(list)")
     def requests(self, **kwargs):
-        # TODO: move these 5 lines into @normalize
-        if "year" in kwargs:
-            if "month" not in kwargs:
-                kwargs["month"] = [f"{i+1:02}" for i in range(0, 12)]
-            if "day" not in kwargs:
-                kwargs["day"] = [f"{i+1:02}" for i in range(0, 31)]
-
         split_on = kwargs.pop("split_on", None)
         if split_on is None or not isinstance(kwargs.get(split_on), (list, tuple)):
             return [kwargs]
diff --git a/earthkit/data/sources/dummy_source.py b/earthkit/data/sources/dummy_source.py
index 3c826a6..1a672b5 100644
--- a/earthkit/data/sources/dummy_source.py
+++ b/earthkit/data/sources/dummy_source.py
@@ -155,10 +155,16 @@ def generate_csv(
     separator=",",
     quote_strings=None,
     none_are_empty=True,
+    comment="#",
+    comment_line=None,
     **kwargs,
 ):
     assert none_are_empty
+
     with open(target, "w") as f:
+        if comment_line is not None:
+            print(f"{comment} {comment_line}", file=f)
+
         if headers:
             print(separator.join(headers), file=f)
 
diff --git a/earthkit/data/sources/numpy_list.py b/earthkit/data/sources/numpy_list.py
index 8e8e5a5..aeb1473 100644
--- a/earthkit/data/sources/numpy_list.py
+++ b/earthkit/data/sources/numpy_list.py
@@ -34,6 +34,9 @@ class NumpyField(Field):
         else:
             return self._array.astype(dtype)
 
+    def __repr__(self):
+        return f"{self.__class__.__name__}()"
+
     def write(self, f):
         from earthkit.data.writers import write
 
@@ -52,22 +55,38 @@ class NumpyFieldListCore(PandasMixIn, XarrayMixIn, FieldList):
             if not isinstance(md, Metadata):
                 raise TypeError("metadata must be a subclass of MetaData")
 
-        if self._array.shape[0] != len(self._metadata):
-            import numpy as np
-
-            # we have a single array and a single metadata
-            if len(self._metadata) == 1 and self._shape_match(
-                self._array.shape, self._metadata[0].geography.shape()
-            ):
-                self._array = np.array([self._array])
-            else:
+        if isinstance(self._array, np.ndarray):
+            if self._array.shape[0] != len(self._metadata):
+                # we have a single array and a single metadata
+                if len(self._metadata) == 1 and self._shape_match(
+                    self._array.shape, self._metadata[0].geography.shape()
+                ):
+                    self._array = np.array([self._array])
+                else:
+                    raise ValueError(
+                        (
+                            f"first array dimension ({self._array.shape[0]}) differs "
+                            f"from number of metadata objects ({len(self._metadata)})"
+                        )
+                    )
+        elif isinstance(self._array, list):
+            if len(self._array) != len(self._metadata):
                 raise ValueError(
                     (
-                        f"first array dimension ({self._array.shape[0]}) differs "
+                        f"array len ({len(self._array)}) differs "
                         f"from number of metadata objects ({len(self._metadata)})"
                     )
                 )
 
+            for i, a in enumerate(self._array):
+                if not isinstance(a, np.ndarray):
+                    raise ValueError(
+                        f"All array element must be an ndarray. Type at position={i} is {type(a)}"
+                    )
+
+        else:
+            raise TypeError("array must be an ndarray or a list of ndarrays")
+
         super().__init__(*args, **kwargs)
 
     def _shape_match(self, shape1, shape2):
@@ -84,7 +103,44 @@ class NumpyFieldListCore(PandasMixIn, XarrayMixIn, FieldList):
     @classmethod
     def merge(cls, sources):
         assert all(isinstance(_, NumpyFieldListCore) for _ in sources)
-        return NumpyMultiFieldList(sources)
+        merger = ListMerger(sources)
+        # merger = MultiUnwindMerger(sources)
+        return merger.to_fieldlist()
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}(fields={len(self)})"
+
+
+class MultiUnwindMerger:
+    def __init__(self, sources):
+        self.sources = list(self._flatten(sources))
+
+    def _flatten(self, sources):
+        if isinstance(sources, NumpyMultiFieldList):
+            for s in sources.indexes:
+                yield from self._flatten(s)
+        elif isinstance(sources, list):
+            for s in sources:
+                yield from self._flatten(s)
+        else:
+            yield sources
+
+    def to_fieldlist(self):
+        return NumpyMultiFieldList(self.sources)
+
+
+class ListMerger:
+    def __init__(self, sources):
+        self.sources = sources
+
+    def to_fieldlist(self):
+        array = []
+        metadata = []
+        for s in self.sources:
+            for f in s:
+                array.append(f._array)
+                metadata.append(f._metadata)
+        return NumpyFieldList(array, metadata)
 
 
 class NumpyFieldList(NumpyFieldListCore):
@@ -95,7 +151,9 @@ class NumpyFieldList(NumpyFieldListCore):
             return super().__getitem__(n)
 
     def __len__(self):
-        return self._array.shape[0]
+        return (
+            len(self._array) if isinstance(self._array, list) else self._array.shape[0]
+        )
 
 
 class NumpyMaskFieldList(NumpyFieldListCore, MaskIndex):
diff --git a/earthkit/data/sources/wekeo.py b/earthkit/data/sources/wekeo.py
new file mode 100644
index 0000000..b4bddcf
--- /dev/null
+++ b/earthkit/data/sources/wekeo.py
@@ -0,0 +1,160 @@
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import os
+
+import hda
+import yaml
+from hda.api import DataOrderRequest
+
+from earthkit.data.core.thread import SoftThreadPool
+from earthkit.data.utils import tqdm
+
+from .file import FileSource
+from .prompt import APIKeyPrompt
+
+
+class HDAAPIKeyPrompt(APIKeyPrompt):
+    register_or_sign_in_url = "https://www.wekeo.eu"
+    retrieve_api_key_url = "https://www.wekeo.eu"
+
+    prompts = [
+        dict(
+            name="url",
+            default="https://wekeo-broker.apps.mercator.dpi.wekeo.eu/databroker",
+            title="API url",
+            validate=r"http.?://.*",
+        ),
+        dict(
+            name="user",
+            example="name",
+            title="User name",
+            hidden=False,
+            validate=r"[0-9a-z]+",
+        ),
+        dict(
+            name="password",
+            example="secretpassword",
+            title="Password",
+            hidden=True,
+            validate=r"[0-9A-z\!\@\#\$\%\&\*]{5,30}",
+        ),
+    ]
+
+    rcfile = "~/.hdarc"
+
+    def save(self, input, file):
+        yaml.dump(input, file, default_flow_style=False)
+
+
+class ApiClient(hda.Client):
+    name = "wekeo"
+
+    def __int__(self, *args, **kwargs):
+        super().__init__(self, *args, **kwargs)
+
+    def retrieve(self, name, request, target=None):
+        matches = self.search(request["request"])
+        out = []
+        for result in matches.results:
+            query = {"jobId": matches.job_id, "uri": result["url"]}
+            url = DataOrderRequest(self).run(query)
+            out.append(
+                os.path.abspath(
+                    self.stream(
+                        result.get("filename"), result.get("size"), target, *url
+                    )
+                )
+            )
+        return out
+
+    def download(self, download_dir: str = "."):
+        for result in self.results:
+            query = {"jobId": self.jobId, "uri": result["url"]}
+            self.debug(result)
+            url = DataOrderRequest(self.client).run(query)
+            self.stream(result.get("filename"), result.get("size"), download_dir, *url)
+
+
+EXTENSIONS = {
+    "grib": ".grib",
+    "netcdf": ".nc",
+}
+
+
+class WekeoRetriever(FileSource):
+    sphinxdoc = """
+    WekeoRetriever
+    """
+
+    @staticmethod
+    def client():
+        prompt = HDAAPIKeyPrompt()
+        prompt.check()
+
+        try:
+            return ApiClient()
+        except Exception as e:
+            if ".hdarc" in str(e):
+                prompt.ask_user_and_save()
+                return ApiClient()
+            raise
+
+    def __init__(self, dataset, *args, **kwargs):
+        super().__init__()
+
+        assert isinstance(dataset, str)
+        if len(args):
+            assert len(args) == 1
+            assert isinstance(args[0], dict)
+            assert not kwargs
+            kwargs = args[0]
+
+        requests = self.requests(**kwargs)
+
+        self.client()  # Trigger password prompt before thraeding
+
+        nthreads = min(self.settings("number-of-download-threads"), len(requests))
+
+        if nthreads < 2:
+            self.path = [self._retrieve(dataset, r) for r in requests]
+        else:
+            with SoftThreadPool(nthreads=nthreads) as pool:
+                futures = [pool.submit(self._retrieve, dataset, r) for r in requests]
+
+                iterator = (f.result() for f in futures)
+                self.path = list(tqdm(iterator, leave=True, total=len(requests)))
+
+    def _retrieve(self, dataset, request):
+        def retrieve(target, args):
+            self.client().retrieve(args[0], args[1], target)
+
+        return self.cache_file(
+            retrieve,
+            (dataset, request),
+            extension=EXTENSIONS.get(request.get("format"), ".cache"),
+        )
+
+    @staticmethod
+    def requests(**kwargs):
+        split_on = kwargs.pop("split_on", None)
+        if split_on is None or not isinstance(kwargs.get(split_on), (list, tuple)):
+            return [kwargs]
+
+        result = []
+
+        for v in kwargs[split_on]:
+            r = dict(**kwargs)
+            r[split_on] = v
+            result.append(r)
+
+        return result
+
+
+source = WekeoRetriever
diff --git a/earthkit/data/sources/wekeocds.py b/earthkit/data/sources/wekeocds.py
new file mode 100644
index 0000000..d86b2ca
--- /dev/null
+++ b/earthkit/data/sources/wekeocds.py
@@ -0,0 +1,131 @@
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import os
+
+from hda.api import DataOrderRequest
+
+from earthkit.data.core.thread import SoftThreadPool
+from earthkit.data.decorators import normalize
+from earthkit.data.utils import tqdm
+
+from .file import FileSource
+from .wekeo import EXTENSIONS
+from .wekeo import ApiClient as WekeoClient
+from .wekeo import HDAAPIKeyPrompt
+
+
+class ApiClient(WekeoClient):
+    name = "wekeocds"
+
+    def __int__(self, *args, **kwargs):
+        super().__init__(self, *args, **kwargs)
+
+    def retrieve(self, name, request, target=None):
+        rq = {
+            "datasetId": name,
+            "multiStringSelectValues": [
+                {
+                    "name": _name,
+                    "value": _value if isinstance(_value, list) else [_value],
+                }
+                for _name, _value in request.items()
+            ],
+        }
+        if "area" in request:
+            rq.update({"boundingBoxValues": {"name": "area", "bbox": request["area"]}})
+        matches = self.search(rq)
+        out = []
+        for result in matches.results:
+            query = {"jobId": matches.job_id, "uri": result["url"]}
+            # matches.debug(result)
+            url = DataOrderRequest(self).run(query)
+            out.append(
+                self.stream(result.get("filename"), result.get("size"), target, *url)
+            )
+        return [os.path.abspath(_) for _ in out]
+
+
+class WekeoCdsRetriever(FileSource):
+    sphinxdoc = """
+    WekeoCdsRetriever
+    """
+
+    @staticmethod
+    def client():
+        prompt = HDAAPIKeyPrompt()
+        prompt.check()
+        try:
+            return ApiClient()
+        except Exception as e:
+            if ".hdarc" in str(e):
+                prompt.ask_user_and_save()
+                return ApiClient()
+
+            raise
+
+    def __init__(self, dataset, *args, **kwargs):
+        super().__init__()
+
+        assert isinstance(dataset, str)
+        if len(args):
+            assert len(args) == 1
+            assert isinstance(args[0], dict)
+            assert not kwargs
+            kwargs = args[0]
+
+        requests = self.requests(**kwargs)
+
+        self.client()  # Trigger password prompt before thraeding
+
+        nthreads = min(self.settings("number-of-download-threads"), len(requests))
+
+        if nthreads < 2:
+            self.path = [self._retrieve(dataset, r) for r in requests]
+        else:
+            with SoftThreadPool(nthreads=nthreads) as pool:
+                futures = [pool.submit(self._retrieve, dataset, r) for r in requests]
+
+                iterator = (f.result() for f in futures)
+                self.path = list(tqdm(iterator, leave=True, total=len(requests)))
+
+    def _retrieve(self, dataset, request):
+        def retrieve(target, args):
+            self.client().retrieve(args[0], args[1], target)
+
+        return self.cache_file(
+            retrieve,
+            (dataset, request),
+            extension=EXTENSIONS.get(request.get("format"), ".cache"),
+        )
+
+    @normalize("date", "date-list(%Y-%m-%d)")
+    @normalize("area", "bounding-box(list)")
+    def requests(self, **kwargs):
+        if "year" in kwargs:
+            if "month" not in kwargs:
+                kwargs["month"] = [f"{i+1:02}" for i in range(0, 12)]
+            if "day" not in kwargs:
+                kwargs["day"] = [f"{i+1:02}" for i in range(0, 31)]
+
+        split_on = kwargs.pop("split_on", None)
+        if split_on is None or not isinstance(kwargs.get(split_on), (list, tuple)):
+            return [kwargs]
+
+        result = []
+
+        for v in kwargs[split_on]:
+            r = dict(**kwargs)
+            r[split_on] = v
+            result.append(r)
+
+        return result
+
+
+source = WekeoCdsRetriever
diff --git a/earthkit/data/wrappers/xarray.py b/earthkit/data/wrappers/xarray.py
index 4d088ea..189e7c6 100644
--- a/earthkit/data/wrappers/xarray.py
+++ b/earthkit/data/wrappers/xarray.py
@@ -145,7 +145,7 @@ def wrapper(data, *args, **kwargs):
 
     if ds is not None:
         fs = netcdf.XArrayFieldList(ds, **kwargs)
-        if len(fs) > 0:
+        if fs.has_fields():
             return fs
         else:
             return XArrayDatasetWrapper(ds, *args, **kwargs)
diff --git a/environment.yml b/environment.yml
index 48bb9e4..2637183 100644
--- a/environment.yml
+++ b/environment.yml
@@ -20,6 +20,7 @@ dependencies:
 - jupyterlab
 - ecmwf-api-client>=1.6.1
 - cdsapi
+- hda
 - pip:
   - git+https://github.com/ecmwf/multiurl
   - git+https://github.com/ecmwf/pyfdb
diff --git a/setup.cfg b/setup.cfg
index ba175aa..1a8ff01 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -29,6 +29,7 @@ install_requires =
     dask
     entrypoints
     filelock
+    hda
     jinja2
     markdown
     multiurl


Test patch:
diff --git a/earthkit/data/testing.py b/earthkit/data/testing.py
index 61bb044..7dd8904 100644
--- a/earthkit/data/testing.py
+++ b/earthkit/data/testing.py
@@ -80,6 +80,7 @@ def modules_installed(*modules):
 
 NO_MARS = not os.path.exists(os.path.expanduser("~/.ecmwfapirc"))
 NO_CDS = not os.path.exists(os.path.expanduser("~/.cdsapirc"))
+NO_HDA = not os.path.exists(os.path.expanduser("~/.hdarc"))
 IN_GITHUB = os.environ.get("GITHUB_WORKFLOW") is not None
 try:
     import ecmwf.opendata  # noqa
diff --git a/tests/documentation/test_notebooks.py b/tests/documentation/test_notebooks.py
index fcaba58..c0456d3 100644
--- a/tests/documentation/test_notebooks.py
+++ b/tests/documentation/test_notebooks.py
@@ -22,7 +22,7 @@ from earthkit.data.testing import MISSING, earthkit_file
 
 EXAMPLES = earthkit_file("docs", "examples")
 
-SKIP = ["fdb.ipynb", "mars.ipynb", "cds.ipynb", "ads.ipynb"]
+SKIP = ["fdb.ipynb", "mars.ipynb", "cds.ipynb", "ads.ipynb", "wekeo.ipynb"]
 
 
 def notebooks_list():
diff --git a/tests/netcdf/test_netcdf_output.py b/tests/netcdf/test_netcdf_output.py
new file mode 100644
index 0000000..bfa58be
--- /dev/null
+++ b/tests/netcdf/test_netcdf_output.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import os
+
+import pytest
+
+from earthkit.data import from_source
+from earthkit.data.core.temporary import temp_file
+from earthkit.data.testing import earthkit_examples_file
+
+
+def test_netcdf_fieldlist_save():
+    ds = from_source("file", earthkit_examples_file("test.nc"))
+    assert len(ds) == 2
+
+    tmp = temp_file()
+    ds.save(tmp.path)
+    assert os.path.exists(tmp.path)
+    r_tmp = from_source("file", tmp.path)
+    assert len(r_tmp) == 2
+
+
+def test_netcdf_fieldlist_subset_save():
+    ds = from_source("file", earthkit_examples_file("test.nc"))
+    assert len(ds) == 2
+    r = ds[1]
+
+    tmp = temp_file()
+    with pytest.raises(NotImplementedError):
+        r.save(tmp.path)
+
+
+def test_netcdf_fieldlist_multi_subset_save():
+    ds1 = from_source("file", earthkit_examples_file("test.nc"))
+    ds2 = from_source("file", earthkit_examples_file("tuv_pl.nc"))
+
+    ds = ds1 + ds2
+    assert len(ds) == 20
+
+    tmp = temp_file()
+    ds.save(tmp.path)
+    assert os.path.exists(tmp.path)
+    r_tmp = from_source("file", tmp.path)
+    assert len(r_tmp) == 20
+
+
+def test_netcdf_fieldlist_multi_subset_save_bad():
+    ds1 = from_source("file", earthkit_examples_file("test.nc"))
+    ds2 = from_source("file", earthkit_examples_file("tuv_pl.nc"))
+
+    ds = ds1 + ds2[1:5]
+    assert len(ds) == 6
+
+    tmp = temp_file()
+    with pytest.raises(NotImplementedError):
+        ds.save(tmp.path)
diff --git a/tests/numpy_fs/numpy_fs_fixtures.py b/tests/numpy_fs/numpy_fs_fixtures.py
new file mode 100644
index 0000000..37f1e90
--- /dev/null
+++ b/tests/numpy_fs/numpy_fs_fixtures.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import os
+
+import numpy as np
+
+from earthkit.data import from_source
+from earthkit.data.core.fieldlist import FieldList
+from earthkit.data.core.temporary import temp_file
+from earthkit.data.testing import earthkit_examples_file
+
+
+def load_numpy_fs(num):
+    assert num in [1, 2, 3]
+    files = ["test.grib", "test6.grib", "tuv_pl.grib"]
+    files = files[:num]
+
+    ds_in = []
+    md = []
+    for fname in files:
+        ds_in.append(from_source("file", earthkit_examples_file(fname)))
+        md += ds_in[-1].metadata("param")
+
+    ds = []
+    for x in ds_in:
+        ds.append(
+            FieldList.from_numpy(
+                x.values, [m.override(edition=1) for m in x.metadata()]
+            )
+        )
+
+    return (*ds, md)
+
+
+def check_numpy_fs(ds, ds_input, md_full):
+    assert len(ds_input) in [1, 2, 3]
+
+    assert len(ds) == len(md_full)
+    assert ds.metadata("param") == md_full
+    assert np.allclose(ds[0].values, ds_input[0][0].values)
+
+    # check slice
+    r = ds[1]
+    assert r.metadata("param") == "msl"
+
+    if len(ds_input) > 1:
+        r = ds[1:3]
+        assert len(r) == 2
+        assert r.metadata("param") == ["msl", "t"]
+        assert r[0].metadata("param") == "msl"
+        assert r[1].metadata("param") == "t"
+        assert np.allclose(r[0].values, ds_input[0][1].values)
+        assert np.allclose(r[1].values, ds_input[1][0].values)
+
+        # check sel
+        r = ds.sel(shortName="msl")
+        assert len(r) == 1
+        assert r.metadata("shortName") == ["msl"]
+        assert r[0].metadata("param") == "msl"
+        assert np.allclose(r[0].values, ds_input[0][1].values)
+
+    if len(ds_input) == 3:
+        r = ds[1:13:4]
+        assert len(r) == 3
+        assert r.metadata("param") == ["msl", "t", "u"]
+        assert r[0].metadata("param") == "msl"
+        assert r[1].metadata("param") == "t"
+        assert r[2].metadata("param") == "u"
+
+
+def check_save_to_disk(ds, len_ref, meta_ref):
+    tmp = temp_file()
+    ds.save(tmp.path)
+    assert os.path.exists(tmp.path)
+    r_tmp = from_source("file", tmp.path)
+    assert len(r_tmp) == len_ref
+    assert r_tmp.metadata("shortName") == meta_ref
+    r_tmp = None
diff --git a/tests/numpy_fs/test_numpy_fs.py b/tests/numpy_fs/test_numpy_fs.py
index 523949b..c5e4105 100644
--- a/tests/numpy_fs/test_numpy_fs.py
+++ b/tests/numpy_fs/test_numpy_fs.py
@@ -9,20 +9,23 @@
 # nor does it submit to any jurisdiction.
 #
 
-import logging
 import os
+import sys
 
 import numpy as np
+import pytest
 
 from earthkit.data import from_source
 from earthkit.data.core.fieldlist import FieldList
 from earthkit.data.core.temporary import temp_file
 from earthkit.data.testing import earthkit_examples_file
 
-LOG = logging.getLogger(__name__)
+here = os.path.dirname(__file__)
+sys.path.insert(0, here)
+from numpy_fs_fixtures import check_numpy_fs  # noqa: E402
 
 
-def test_numpy_list_grib_single_field():
+def test_numpy_fs_grib_single_field():
     ds = from_source("file", earthkit_examples_file("test.grib"))
 
     assert ds[0].metadata("shortName") == "2t"
@@ -54,7 +57,7 @@ def test_numpy_list_grib_single_field():
     _check_field(r_tmp)
 
 
-def test_numpy_list_grib_multi_field():
+def test_numpy_fs_grib_multi_field():
     ds = from_source("file", earthkit_examples_file("test.grib"))
 
     assert ds[0].metadata("shortName") == "2t"
@@ -85,6 +88,31 @@ def test_numpy_list_grib_multi_field():
         assert f.metadata("name") == "2 metre dewpoint temperature", f"name {i}"
 
 
+def test_numpy_fs_grib_from_list_of_arrays():
+    ds = from_source("file", earthkit_examples_file("test.grib"))
+    md_full = ds.metadata("param")
+    assert len(ds) == 2
+
+    v = [ds[0].values, ds[1].values]
+    md = [f.metadata().override(generatingProcessIdentifier=150) for f in ds]
+    r = FieldList.from_numpy(v, md)
+
+    check_numpy_fs(r, [ds], md_full)
+
+
+def test_numpy_fs_grib_from_list_of_arrays_bad():
+    ds = from_source("file", earthkit_examples_file("test.grib"))
+
+    v = ds[0].values
+    md = [f.metadata().override(generatingProcessIdentifier=150) for f in ds]
+
+    with pytest.raises(ValueError):
+        _ = FieldList.from_numpy(v, md)
+
+    with pytest.raises(ValueError):
+        _ = FieldList.from_numpy([v], md)
+
+
 if __name__ == "__main__":
     from earthkit.data.testing import main
 
diff --git a/tests/numpy_fs/test_numpy_fs_concat.py b/tests/numpy_fs/test_numpy_fs_concat.py
index eae187f..b15b543 100644
--- a/tests/numpy_fs/test_numpy_fs_concat.py
+++ b/tests/numpy_fs/test_numpy_fs_concat.py
@@ -10,109 +10,47 @@
 #
 
 import os
+import sys
 
-import numpy as np
 import pytest
 
 from earthkit.data import from_source
 from earthkit.data.core.fieldlist import FieldList
-from earthkit.data.core.temporary import temp_file
-from earthkit.data.testing import earthkit_examples_file
-
-
-def _check_save_to_disk(ds, len_ref, meta_ref):
-    tmp = temp_file()
-    ds.save(tmp.path)
-    assert os.path.exists(tmp.path)
-    r_tmp = from_source("file", tmp.path)
-    assert len(r_tmp) == len_ref
-    assert r_tmp.metadata("shortName") == meta_ref
-    r_tmp = None
-
-
-def _check_contents(ds, ds_input, md_full):
-    assert len(ds_input) in [2, 3]
-
-    assert len(ds) == len(md_full)
-    assert ds.metadata("param") == md_full
-
-    # check slice
-    r = ds[1]
-    assert r.metadata("param") == "msl"
-
-    r = ds[1:3]
-    assert len(r) == 2
-    assert r.metadata("param") == ["msl", "t"]
-    assert r[0].metadata("param") == "msl"
-    assert r[1].metadata("param") == "t"
-    assert np.allclose(r[0].values, ds_input[0][1].values)
-    assert np.allclose(r[1].values, ds_input[1][0].values)
-    with pytest.raises(ValueError):
-        _ = r.values
-
-    # check sel
-    r = ds.sel(shortName="msl")
-    assert len(r) == 1
-    assert r.metadata("shortName") == ["msl"]
-    assert r[0].metadata("param") == "msl"
-    assert np.allclose(r[0].values, ds_input[0][1].values)
-
-    if len(ds_input) == 3:
-        r = ds[1:13:4]
-        assert len(r) == 3
-        assert r.metadata("param") == ["msl", "t", "u"]
-        assert r[0].metadata("param") == "msl"
-        assert r[1].metadata("param") == "t"
-        assert r[2].metadata("param") == "u"
-
-
-def _prepare_ds(num):
-    assert num in [1, 2, 3]
-    files = ["test.grib", "test6.grib", "tuv_pl.grib"]
-    files = files[:num]
-
-    ds_in = []
-    md = []
-    for fname in files:
-        ds_in.append(from_source("file", earthkit_examples_file(fname)))
-        md += ds_in[-1].metadata("param")
-
-    ds = []
-    for x in ds_in:
-        ds.append(
-            FieldList.from_numpy(
-                x.values, [m.override(edition=1) for m in x.metadata()]
-            )
-        )
-
-    return (*ds, md)
+
+here = os.path.dirname(__file__)
+sys.path.insert(0, here)
+from numpy_fs_fixtures import (  # noqa: E402
+    check_numpy_fs,
+    check_save_to_disk,
+    load_numpy_fs,
+)
 
 
 @pytest.mark.parametrize("mode", ["oper", "multi"])
-def test_numpy_list_grib_concat_2a(mode):
-    ds1, ds2, md = _prepare_ds(2)
+def test_numpy_fs_grib_concat_2a(mode):
+    ds1, ds2, md = load_numpy_fs(2)
 
     if mode == "oper":
         ds = ds1 + ds2
     else:
         ds = from_source("multi", ds1, ds2)
 
-    _check_contents(ds, [ds1, ds2], md)
-    _check_save_to_disk(ds, 8, md)
+    check_numpy_fs(ds, [ds1, ds2], md)
+    check_save_to_disk(ds, 8, md)
 
 
-def test_numpy_list_grib_concat_2b():
-    ds1, ds2, md = _prepare_ds(2)
+def test_numpy_fs_grib_concat_2b():
+    ds1, ds2, md = load_numpy_fs(2)
     ds1_ori = ds1
     ds1 += ds2
 
-    _check_contents(ds1, [ds1_ori, ds2], md)
-    _check_save_to_disk(ds1, 8, md)
+    check_numpy_fs(ds1, [ds1_ori, ds2], md)
+    check_save_to_disk(ds1, 8, md)
 
 
 @pytest.mark.parametrize("mode", ["oper", "multi"])
-def test_numpy_list_grib_concat_3a(mode):
-    ds1, ds2, ds3, md = _prepare_ds(3)
+def test_numpy_fs_grib_concat_3a(mode):
+    ds1, ds2, ds3, md = load_numpy_fs(3)
 
     if mode == "oper":
         ds = ds1 + ds2
@@ -121,69 +59,69 @@ def test_numpy_list_grib_concat_3a(mode):
         ds = from_source("multi", ds1, ds2)
         ds = from_source("multi", ds, ds3)
 
-    _check_contents(ds, [ds1, ds2, ds3], md)
-    _check_save_to_disk(ds, 26, md)
+    check_numpy_fs(ds, [ds1, ds2, ds3], md)
+    check_save_to_disk(ds, 26, md)
 
 
 @pytest.mark.parametrize("mode", ["oper", "multi"])
-def test_numpy_list_grib_concat_3b(mode):
-    ds1, ds2, ds3, md = _prepare_ds(3)
+def test_numpy_fs_grib_concat_3b(mode):
+    ds1, ds2, ds3, md = load_numpy_fs(3)
 
     if mode == "oper":
         ds = ds1 + ds2 + ds3
     else:
         ds = from_source("multi", ds1, ds2, ds3)
 
-    _check_contents(ds, [ds1, ds2, ds3], md)
-    _check_save_to_disk(ds, 26, md)
+    check_numpy_fs(ds, [ds1, ds2, ds3], md)
+    check_save_to_disk(ds, 26, md)
 
 
-def test_numpy_list_grib_from_empty_1():
+def test_numpy_fs_grib_from_empty_1():
     ds_e = FieldList()
-    ds, md = _prepare_ds(1)
+    ds, md = load_numpy_fs(1)
     ds1 = ds_e + ds
     assert id(ds1) == id(ds)
     assert len(ds1) == 2
     assert ds1.metadata("param") == md
-    _check_save_to_disk(ds1, 2, md)
+    check_save_to_disk(ds1, 2, md)
 
 
-def test_numpy_list_grib_from_empty_2():
+def test_numpy_fs_grib_from_empty_2():
     ds_e = FieldList()
-    ds, md = _prepare_ds(1)
+    ds, md = load_numpy_fs(1)
     ds1 = ds + ds_e
     assert id(ds1) == id(ds)
     assert len(ds1) == 2
     assert ds1.metadata("param") == md
-    _check_save_to_disk(ds1, 2, md)
+    check_save_to_disk(ds1, 2, md)
 
 
-def test_numpy_list_grib_from_empty_3():
+def test_numpy_fs_grib_from_empty_3():
     ds_e = FieldList()
-    ds1, ds2, md = _prepare_ds(2)
+    ds1, ds2, md = load_numpy_fs(2)
     ds = ds_e + ds1 + ds2
 
-    _check_contents(ds, [ds1, ds2], md)
-    _check_save_to_disk(ds, 8, md)
+    check_numpy_fs(ds, [ds1, ds2], md)
+    check_save_to_disk(ds, 8, md)
 
 
-def test_numpy_list_grib_from_empty_4():
+def test_numpy_fs_grib_from_empty_4():
     ds = FieldList()
-    ds1, md = _prepare_ds(1)
+    ds1, md = load_numpy_fs(1)
     ds += ds1
     assert id(ds) == id(ds1)
     assert len(ds) == 2
     assert ds.metadata("param") == md
-    _check_save_to_disk(ds, 2, md)
+    check_save_to_disk(ds, 2, md)
 
 
-def test_numpy_list_grib_from_empty_5():
+def test_numpy_fs_grib_from_empty_5():
     ds = FieldList()
-    ds1, ds2, md = _prepare_ds(2)
+    ds1, ds2, md = load_numpy_fs(2)
     ds += ds1 + ds2
 
-    _check_contents(ds, [ds1, ds2], md)
-    _check_save_to_disk(ds, 8, md)
+    check_numpy_fs(ds, [ds1, ds2], md)
+    check_save_to_disk(ds, 8, md)
 
 
 if __name__ == "__main__":
diff --git a/tests/readers/test_csv_reader.py b/tests/readers/test_csv_reader.py
index eaa9994..64e3615 100644
--- a/tests/readers/test_csv_reader.py
+++ b/tests/readers/test_csv_reader.py
@@ -13,11 +13,11 @@ import mimetypes
 
 import pytest
 
-import earthkit.data as cml
+from earthkit.data import from_source
 
 
 def test_csv_1():
-    s = cml.from_source(
+    s = from_source(
         "dummy-source",
         "csv",
         headers=["a", "b", "c"],
@@ -28,11 +28,17 @@ def test_csv_1():
         ],
     )
 
-    print(s.to_pandas())
+    df = s.to_pandas()
+    assert len(df) == 3
+    assert list(df.columns) == ["a", "b", "c"]
+
+    ds = s.to_xarray()
+    assert len(ds) == 3
+    assert list(ds.variables) == ["index", "a", "b", "c"]
 
 
 def test_csv_2():
-    s = cml.from_source(
+    s = from_source(
         "dummy-source",
         "csv",
         headers=["a", "b", "c"],
@@ -43,11 +49,13 @@ def test_csv_2():
         ],
     )
 
-    print(s.to_pandas())
+    df = s.to_pandas()
+    assert len(df) == 3
+    assert list(df.columns) == ["a", "b", "c"]
 
 
 def test_csv_3():
-    s = cml.from_source(
+    s = from_source(
         "dummy-source",
         "csv",
         headers=["a", "b", "c"],
@@ -58,11 +66,13 @@ def test_csv_3():
         ],
     )
 
-    print(s.to_pandas())
+    df = s.to_pandas()
+    assert len(df) == 3
+    assert list(df.columns) == ["a", "b", "c"]
 
 
 def test_csv_4():
-    s = cml.from_source(
+    s = from_source(
         "dummy-source",
         "csv",
         headers=["a", "b", "c"],
@@ -74,7 +84,9 @@ def test_csv_4():
         ],
     )
 
-    print(s.to_pandas())
+    df = s.to_pandas()
+    assert len(df) == 3
+    assert list(df.columns) == ["a", "b", "c"]
 
 
 @pytest.mark.skipif(True, reason="Test not yet implemented")
@@ -92,12 +104,12 @@ def test_csv_icoads():
         "type": "ofb",
     }
 
-    source = cml.from_source("mars", **r)
+    source = from_source("mars", **r)
     print(source)
 
 
-def test_csv_text():
-    s = cml.from_source(
+def test_csv_text_file():
+    s = from_source(
         "dummy-source",
         "csv",
         headers=["a", "b", "c"],
@@ -110,7 +122,32 @@ def test_csv_text():
         extension=".txt",
     )
 
-    print(s.to_pandas())
+    df = s.to_pandas()
+    assert len(df) == 3
+    assert list(df.columns) == ["a", "b", "c"]
+
+
+def test_csv_with_comment():
+    s = from_source(
+        "dummy-source",
+        "csv",
+        headers=["a", "b", "c"],
+        quote_strings=True,
+        lines=[
+            [1, "x", 3],
+            [4, "y", 6],
+            [7, "z", 9],
+        ],
+        comment_line="This is a comment",
+    )
+
+    df = s.to_pandas(pandas_read_csv_kwargs={"comment": "#"})
+    assert len(df) == 3
+    assert list(df.columns) == ["a", "b", "c"]
+
+    ds = s.to_xarray(pandas_read_csv_kwargs={"comment": "#"})
+    assert len(ds) == 3
+    assert list(ds.variables) == ["index", "a", "b", "c"]
 
 
 def test_csv_mimetypes():
diff --git a/tests/readers/test_netcdf_reader.py b/tests/readers/test_netcdf_reader.py
index 8eeddf7..1ebe5d1 100644
--- a/tests/readers/test_netcdf_reader.py
+++ b/tests/readers/test_netcdf_reader.py
@@ -19,8 +19,10 @@ import pytest
 from earthkit.data import from_source
 from earthkit.data.readers.netcdf import NetCDFField
 from earthkit.data.testing import (
+    NO_CDS,
     earthkit_examples_file,
     earthkit_file,
+    earthkit_remote_test_data_file,
     earthkit_test_data_file,
 )
 
@@ -98,9 +100,9 @@ def test_dummy_netcdf_4():
 
 
 @pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_CDS, reason="No access to CDS")
 def test_netcdf_multi_cds():
-    if not os.path.exists(os.path.expanduser("~/.cdsapirc")):
-        pytest.skip("No ~/.cdsapirc")
     s1 = from_source(
         "cds",
         "reanalysis-era5-single-levels",
@@ -223,6 +225,37 @@ def test_get_fields_missing_standard_name_attr_in_coord_array():
         assert len(fs) == 2
 
 
+@pytest.mark.no_eccodes
+def test_netcdf_non_fieldlist():
+    ek_ch4_l2 = from_source(
+        "url",
+        earthkit_remote_test_data_file(
+            "test-data/20210101-C3S-L2_GHG-GHG_PRODUCTS-TANSO2-GOSAT2-SRFP-DAILY-v2.0.0.nc"
+        )
+        # Data from this CDS request:
+        # "cds",
+        # "satellite-methane",
+        # {
+        #     "processing_level": "level_2",
+        #     "sensor_and_algorithm": "tanso2_fts2_srfp",
+        #     "year": "2021",
+        #     "month": "01",
+        #     "day": "01",
+        #     "version": "2.0.0",
+        # },
+    )
+    # TODO: add more conditions to this test when it is clear what methods it should have
+    ek_ch4_l2.to_xarray()
+
+
+@pytest.mark.no_eccodes
+def test_netcdf_lazy_fieldlist_scan():
+    ds = from_source("file", earthkit_examples_file("test.nc"))
+    assert ds._fields is None
+    assert len(ds) == 2
+    assert len(ds._fields) == 2
+
+
 if __name__ == "__main__":
     from earthkit.data.testing import main
 
diff --git a/tests/sources/test_cds.py b/tests/sources/test_cds.py
index d3941c7..e83b34d 100644
--- a/tests/sources/test_cds.py
+++ b/tests/sources/test_cds.py
@@ -81,6 +81,27 @@ def test_cds_netcdf():
     assert len(s) == 2
 
 
+@pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_CDS, reason="No access to CDS")
+def test_cds_netcdf_selection_limited():
+    s = from_source(
+        "cds",
+        "satellite-albedo",
+        {
+            "variable": "albb_bh",
+            "satellite": "noaa_7",
+            "sensor": "avhrr",
+            "product_version": "v2",
+            "horizontal_resolution": "4km",
+            "year": "1983",
+            "month": "01",
+            "nominal_day": "10",
+        },
+    )
+    assert len(s) == 9
+
+
 if __name__ == "__main__":
     from earthkit.data.testing import main
 
diff --git a/tests/sources/test_url.py b/tests/sources/test_url.py
index 0319dde..b0e34a7 100644
--- a/tests/sources/test_url.py
+++ b/tests/sources/test_url.py
@@ -15,8 +15,12 @@ import sys
 import pytest
 
 from earthkit.data import from_source, settings
-from earthkit.data.core.temporary import temp_directory
-from earthkit.data.testing import earthkit_file, network_off
+from earthkit.data.core.temporary import temp_directory, temp_file
+from earthkit.data.testing import (
+    earthkit_file,
+    earthkit_remote_test_data_file,
+    network_off,
+)
 
 
 @pytest.mark.skipif(  # TODO: fix
@@ -129,6 +133,17 @@ def test_url_part_file_source():
         assert f.read() == b"GRIB7777GRIB7777"
 
 
+def test_url_netcdf_source_save():
+    ds = from_source(
+        "url",
+        earthkit_remote_test_data_file("examples/test.nc"),
+    )
+
+    tmp = temp_file()
+    ds.save(tmp.path)
+    assert os.path.exists(tmp.path)
+
+
 if __name__ == "__main__":
     test_part_url()
     # from earthkit.data.testing import main
diff --git a/tests/sources/test_wekeo.py b/tests/sources/test_wekeo.py
new file mode 100644
index 0000000..370f837
--- /dev/null
+++ b/tests/sources/test_wekeo.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import pytest
+
+from earthkit.data import from_source
+from earthkit.data.testing import NO_HDA
+
+
+@pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_HDA, reason="No access to WEKEO")
+def test_wekeo_download():
+    s = from_source(
+        "wekeo",
+        "EO:CLMS:DAT:CGLS_GLOBAL_NDVI300_V1_333M",
+        request={
+            "datasetId": "EO:CLMS:DAT:CGLS_GLOBAL_NDVI300_V1_333M",
+            "dateRangeSelectValues": [
+                {
+                    "name": "dtrange",
+                    "start": "2014-01-01T00:00:00.000Z",
+                    "end": "2014-01-01T23:59:59.999Z",
+                }
+            ],
+        },
+    )
+    assert len(s) == 1
+
+
+if __name__ == "__main__":
+    from earthkit.data.testing import main
+
+    main(__file__)
diff --git a/tests/sources/test_wekeocds.py b/tests/sources/test_wekeocds.py
new file mode 100644
index 0000000..0e5e9fa
--- /dev/null
+++ b/tests/sources/test_wekeocds.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import pytest
+
+from earthkit.data import from_source
+from earthkit.data.testing import NO_HDA
+
+
+@pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_HDA, reason="No access to WEKEO")
+def test_wekeo_grib_1():
+    s = from_source(
+        "wekeocds",
+        "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
+        variable=["2m_temperature", "mean_sea_level_pressure"],
+        product_type=["reanalysis"],
+        year=["2012"],
+        month=["12"],
+        day=["12"],
+        time=["13:00"],
+        format="grib",
+    )
+    assert len(s) == 2
+
+
+@pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_HDA, reason="No access to CDS")
+def test_wekeo_grib_2():
+    s = from_source(
+        "wekeocds",
+        "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
+        variable=["2m_temperature", "mean_sea_level_pressure"],
+        product_type=["reanalysis"],
+        year=["2012"],
+        month=["12"],
+        day=["12"],
+        time=["13:00"],
+        format="grib",
+        split_on="variable",
+    )
+    assert len(s) == 2
+
+
+@pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_HDA, reason="No access to CDS")
+def test_wekeo_grib_3():
+    s = from_source(
+        "wekeocds",
+        "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
+        variable=["2m_temperature", "mean_sea_level_pressure"],
+        product_type=["reanalysis"],
+        year=["2012"],
+        month=["12"],
+        day=["12", "13", "14", "15"],
+        time=["13:00"],
+        format="grib",
+    )
+    assert len(s) == 8
+
+
+@pytest.mark.long_test
+@pytest.mark.download
+@pytest.mark.skipif(NO_HDA, reason="No access to CDS")
+def test_wekeo_netcdf():
+    s = from_source(
+        "wekeocds",
+        "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
+        variable=["2m_temperature", "mean_sea_level_pressure"],
+        product_type=["reanalysis"],
+        year=["2012"],
+        month=["12"],
+        day=["12"],
+        time=["13:00"],
+        format="netcdf",
+    )
+    assert len(s) == 2
+
+
+if __name__ == "__main__":
+    from earthkit.data.testing import main
+
+    main(__file__)
diff --git a/tests/wrappers/test_xarray.py b/tests/wrappers/test_xarray.py
index fc8e739..3088132 100644
--- a/tests/wrappers/test_xarray.py
+++ b/tests/wrappers/test_xarray.py
@@ -9,15 +9,14 @@
 # nor does it submit to any jurisdiction.
 #
 
-
-import logging
+import pytest
 
 from earthkit.data import from_object, wrappers
+from earthkit.data.testing import earthkit_examples_file
 from earthkit.data.wrappers import xarray as xr_wrapper
 
-LOG = logging.getLogger(__name__)
-
 
+@pytest.mark.no_eccodes
 def test_dataset_wrapper():
     import xarray as xr
 
@@ -29,6 +28,7 @@ def test_dataset_wrapper():
     assert isinstance(_wrapper, xr_wrapper.XArrayDatasetWrapper)
 
 
+@pytest.mark.no_eccodes
 def test_dataarray_wrapper():
     import xarray as xr
 
@@ -38,3 +38,13 @@ def test_dataarray_wrapper():
     assert isinstance(_wrapper, xr_wrapper.XArrayDataArrayWrapper)
     _wrapper = from_object(xr.DataArray())
     assert isinstance(_wrapper, xr_wrapper.XArrayDataArrayWrapper)
+
+
+@pytest.mark.no_eccodes
+def test_xarray_lazy_fieldlist_scan():
+    import xarray as xr
+
+    ds = from_object(xr.open_dataset(earthkit_examples_file("test.nc")))
+    assert ds._fields is None
+    assert len(ds) == 2
+    assert len(ds._fields) == 2