Repository: frictionlessdata/frictionless-py

Issue:
Add an option to ignore cases in headers
# Overview

The `non-matching-header` test can be too strict, if the headings in the data are a different case from the schema. Curiously, the `_check_with_ordering()` method applies the `_slugify()` method in the check, but the `_check_without_ordering()` doesn't. It's not clear why - I think it's just an inconsistency, but I'm not sure of the rationale behind this function. I'd suggest either the test is case-insensitive by default (possibly with a warning if the only discrepancy is case), or have an option to disable case checking that's more user-friendly than creating a custom check to replace it.

---

Please preserve this line to notify @roll (lead of this repository)


Discussion:

Patch:
diff --git a/frictionless/config.py b/frictionless/config.py
index 823f981b..27589796 100644
--- a/frictionless/config.py
+++ b/frictionless/config.py
@@ -43,6 +43,7 @@ DEFAULT_COMPRESSION_PATH = ""
 DEFAULT_HEADER = True
 DEFAULT_HEADER_ROWS = [1]
 DEFAULT_HEADER_JOIN = " "
+DEFAULT_HEADER_CASE = True
 DEFAULT_MISSING_VALUES = [""]
 DEFAULT_LIMIT_MEMORY = 1000
 DEFAULT_INFER_VOLUME = 100
diff --git a/frictionless/dialects.py b/frictionless/dialects.py
index 1377ccc8..30e60233 100644
--- a/frictionless/dialects.py
+++ b/frictionless/dialects.py
@@ -16,16 +16,25 @@ class Dialect(Metadata):
         header? (bool): whether there is a header row
         headerRows? (int[]): row numbers of header rows
         headerJoin? (str): a multiline header joiner
+        headerCase? (bool): case sensitive header
 
     Raises:
         FrictionlessException: raise any error that occurs during the process
 
     """
 
-    def __init__(self, descriptor=None, header=None, header_rows=None, header_join=None):
+    def __init__(
+        self,
+        descriptor=None,
+        header=None,
+        header_rows=None,
+        header_join=None,
+        header_case=None,
+    ):
         self.setinitial("header", header)
         self.setinitial("headerRows", header_rows)
         self.setinitial("headerJoin", header_join)
+        self.setinitial("headerCase", header_case)
         super().__init__(descriptor)
 
     @Metadata.property
@@ -52,6 +61,14 @@ class Dialect(Metadata):
         """
         return self.get("headerJoin", config.DEFAULT_HEADER_JOIN)
 
+    @Metadata.property
+    def header_case(self):
+        """
+        Returns:
+            str: header case sensitive
+        """
+        return self.get("headerCase", config.DEFAULT_HEADER_CASE)
+
     # Expand
 
     def expand(self):
@@ -59,6 +76,7 @@ class Dialect(Metadata):
         self.setdefault("header", self.header)
         self.setdefault("headerRows", self.header_rows)
         self.setdefault("headerJoin", self.header_join)
+        self.setdefault("headerCase", self.header_case)
 
     # Metadata
 
@@ -70,6 +88,7 @@ class Dialect(Metadata):
             "header": {"type": "boolean"},
             "headerRows": {"type": "array", "items": {"type": "number"}},
             "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
         },
     }
 
@@ -87,7 +106,6 @@ class CsvDialect(Dialect):
         null_sequence? (str): csv null sequence
         skip_initial_space? (bool): csv skip initial space
         comment_char? (str): csv comment char
-        case_sensitive_header? (bool): csv case sensitive header
 
     Raises:
         FrictionlessException: raise any error that occurs during the process
@@ -106,10 +124,10 @@ class CsvDialect(Dialect):
         null_sequence=None,
         skip_initial_space=None,
         comment_char=None,
-        case_sensitive_header=None,
         header=None,
         header_rows=None,
         header_join=None,
+        header_case=None,
     ):
         self.setinitial("delimiter", delimiter)
         self.setinitial("lineTerminator", line_terminator)
@@ -119,12 +137,12 @@ class CsvDialect(Dialect):
         self.setinitial("nullSequence", null_sequence)
         self.setinitial("skipInitialSpace", skip_initial_space)
         self.setinitial("commentChar", comment_char)
-        self.setinitial("caseSensitiveHeader", case_sensitive_header)
         super().__init__(
             descriptor=descriptor,
             header=header,
             header_rows=header_rows,
             header_join=header_join,
+            header_case=header_case,
         )
 
     @Metadata.property
@@ -191,14 +209,6 @@ class CsvDialect(Dialect):
         """
         return self.get("commentChar")
 
-    @Metadata.property
-    def case_sensitive_header(self):
-        """
-        Returns:
-            bool: case sensitive header
-        """
-        return self.get("caseSensitiveHeader", False)
-
     # Expand
 
     def expand(self):
@@ -209,7 +219,6 @@ class CsvDialect(Dialect):
         self.setdefault("quoteChar", self.quote_char)
         self.setdefault("doubleQuote", self.double_quote)
         self.setdefault("skipInitialSpace", self.skip_initial_space)
-        self.setdefault("caseSensitiveHeader", self.case_sensitive_header)
 
     # Import/Export
 
@@ -243,6 +252,7 @@ class CsvDialect(Dialect):
             "header": {"type": "boolean"},
             "headerRows": {"type": "array", "items": {"type": "number"}},
             "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
         },
     }
 
@@ -275,6 +285,7 @@ class ExcelDialect(Dialect):
         header=None,
         header_rows=None,
         header_join=None,
+        header_case=None,
     ):
         self.setinitial("sheet", sheet)
         self.setinitial("workbookCache", workbook_cache)
@@ -286,6 +297,7 @@ class ExcelDialect(Dialect):
             header=header,
             header_rows=header_rows,
             header_join=header_join,
+            header_case=header_case,
         )
 
     @Metadata.property
@@ -352,6 +364,7 @@ class ExcelDialect(Dialect):
             "header": {"type": "boolean"},
             "headerRows": {"type": "array", "items": {"type": "number"}},
             "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
         },
     }
 
@@ -369,18 +382,6 @@ class InlineDialect(Dialect):
 
     """
 
-    metadata_profile = {  # type: ignore
-        "type": "object",
-        "additionalProperties": False,
-        "properties": {
-            "keys": {"type": "array"},
-            "keyed": {"type": "boolean"},
-            "header": {"type": "boolean"},
-            "headerRows": {"type": "array", "items": {"type": "number"}},
-            "headerJoin": {"type": "string"},
-        },
-    }
-
     def __init__(
         self,
         descriptor=None,
@@ -390,6 +391,7 @@ class InlineDialect(Dialect):
         header=None,
         header_rows=None,
         header_join=None,
+        header_case=None,
     ):
         self.setinitial("keys", keys)
         self.setinitial("keyed", keyed)
@@ -398,6 +400,7 @@ class InlineDialect(Dialect):
             header=header,
             header_rows=header_rows,
             header_join=header_join,
+            header_case=header_case,
         )
 
     @Metadata.property
@@ -422,6 +425,21 @@ class InlineDialect(Dialect):
         """Expand metadata"""
         self.setdefault("keyed", self.keyed)
 
+    # Metadata
+
+    metadata_profile = {  # type: ignore
+        "type": "object",
+        "additionalProperties": False,
+        "properties": {
+            "keys": {"type": "array"},
+            "keyed": {"type": "boolean"},
+            "header": {"type": "boolean"},
+            "headerRows": {"type": "array", "items": {"type": "number"}},
+            "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
+        },
+    }
+
 
 class JsonDialect(Dialect):
     """Json dialect representation
@@ -447,6 +465,7 @@ class JsonDialect(Dialect):
         header=None,
         header_rows=None,
         header_join=None,
+        header_case=None,
     ):
         self.setinitial("keys", keys)
         self.setinitial("keyed", keyed)
@@ -456,6 +475,7 @@ class JsonDialect(Dialect):
             header=header,
             header_rows=header_rows,
             header_join=header_join,
+            header_case=header_case,
         )
 
     @Metadata.property
@@ -501,5 +521,6 @@ class JsonDialect(Dialect):
             "header": {"type": "boolean"},
             "headerRows": {"type": "array", "items": {"type": "number"}},
             "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
         },
     }
diff --git a/frictionless/header.py b/frictionless/header.py
index f066aebf..5f16f356 100644
--- a/frictionless/header.py
+++ b/frictionless/header.py
@@ -17,7 +17,7 @@ class Header(list):
 
     """
 
-    def __init__(self, cells, *, schema, field_positions):
+    def __init__(self, cells, *, schema, field_positions, ignore_case=False):
         assert len(field_positions) in (len(cells), len(schema.fields))
 
         # Set attributes
@@ -104,7 +104,8 @@ class Header(list):
 
             # Non-matching Header
             if cell:
-                if field.name != cell:
+                name = field.name
+                if name.lower() != cell.lower() if ignore_case else name != cell:
                     self.__errors.append(
                         errors.NonMatchingHeaderError(
                             note="",
diff --git a/frictionless/plugins/bigquery.py b/frictionless/plugins/bigquery.py
index e0eea157..878702a3 100644
--- a/frictionless/plugins/bigquery.py
+++ b/frictionless/plugins/bigquery.py
@@ -28,7 +28,6 @@ class BigqueryPlugin(Plugin):
     API      | Usage
     -------- | --------
     Public   | `from frictionless.plugins.bigquery import BigqueryPlugin`
-
     """
 
     def create_dialect(self, resource, *, descriptor):
@@ -64,7 +63,6 @@ class BigqueryDialect(Dialect):
 
     Raises:
         FrictionlessException: raise any error that occurs during the process
-
     """
 
     def __init__(
@@ -77,6 +75,7 @@ class BigqueryDialect(Dialect):
         header=None,
         header_rows=None,
         header_join=None,
+        header_case=None,
     ):
         self.setinitial("project", project)
         self.setinitial("dataset", dataset)
@@ -86,6 +85,7 @@ class BigqueryDialect(Dialect):
             header=header,
             header_rows=header_rows,
             header_join=header_join,
+            header_case=header_case,
         )
 
     @Metadata.property
@@ -113,6 +113,7 @@ class BigqueryDialect(Dialect):
             "header": {"type": "boolean"},
             "headerRows": {"type": "array", "items": {"type": "number"}},
             "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
         },
     }
 
@@ -126,7 +127,6 @@ class BigqueryParser(Parser):
     API      | Usage
     -------- | --------
     Public   | `from frictionless.plugins.bigquery import BigqueryParser`
-
     """
 
     loading = False
diff --git a/frictionless/plugins/ckan.py b/frictionless/plugins/ckan.py
index 595b4dc5..f9db72e1 100644
--- a/frictionless/plugins/ckan.py
+++ b/frictionless/plugins/ckan.py
@@ -21,10 +21,15 @@ class CkanPlugin(Plugin):
 
     API      | Usage
     -------- | --------
-    Public   | `from frictionless.plugins.bigquery import CkanPlugin`
-
+    Public   | `from frictionless.plugins.ckan import CkanPlugin`
     """
 
+    def create_dialect(self, resource, *, descriptor):
+        pass
+
+    def create_parser(self, resource):
+        pass
+
     def create_storage(self, name, **options):
         pass
 
@@ -37,7 +42,12 @@ class CkanPlugin(Plugin):
 
 
 class CkanStorage(Storage):
-    """Ckan storage implementation"""
+    """Ckan storage implementation
+
+    API      | Usage
+    -------- | --------
+    Public   | `from frictionless.plugins.ckan import CkanStorage`
+    """
 
     def __init__(self, base_url, dataset_id=None, api_key=None):
         base_path = "/api/3/action"
diff --git a/frictionless/plugins/elastic.py b/frictionless/plugins/elastic.py
index b9e2212b..a4d51ce1 100644
--- a/frictionless/plugins/elastic.py
+++ b/frictionless/plugins/elastic.py
@@ -23,9 +23,14 @@ class ElasticPlugin(Plugin):
     API      | Usage
     -------- | --------
     Public   | `from frictionless.plugins.elastic import ElasticPlugin`
-
     """
 
+    def create_dialect(self, resource, *, descriptor):
+        pass
+
+    def create_parser(self, resource):
+        pass
+
     def create_storage(self, name, **options):
         pass
 
@@ -38,7 +43,12 @@ class ElasticPlugin(Plugin):
 
 
 class Storage(object):
-    """Elastic storage implementation"""
+    """Elastic storage implementation
+
+    API      | Usage
+    -------- | --------
+    Public   | `from frictionless.plugins.elastic import ElasticStorage`
+    """
 
     def __init__(self, es=None):
         es_core = helpers.import_from_plugin("elasticsearch", plugin="elasticsearch")
diff --git a/frictionless/plugins/html.py b/frictionless/plugins/html.py
index c5e23511..abaf9c10 100644
--- a/frictionless/plugins/html.py
+++ b/frictionless/plugins/html.py
@@ -54,6 +54,7 @@ class HtmlDialect(Dialect):
         header=None,
         header_rows=None,
         header_join=None,
+        header_case=None,
     ):
         self.setinitial("selector", selector)
         super().__init__(
@@ -61,6 +62,7 @@ class HtmlDialect(Dialect):
             header=header,
             header_rows=header_rows,
             header_join=header_join,
+            header_case=header_case,
         )
 
     @Metadata.property
@@ -88,6 +90,7 @@ class HtmlDialect(Dialect):
             "header": {"type": "boolean"},
             "headerRows": {"type": "array", "items": {"type": "number"}},
             "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
         },
     }
 
diff --git a/frictionless/plugins/ods.py b/frictionless/plugins/ods.py
index e2c62bd8..8486f613 100644
--- a/frictionless/plugins/ods.py
+++ b/frictionless/plugins/ods.py
@@ -57,6 +57,7 @@ class OdsDialect(Dialect):
         header=None,
         header_rows=None,
         header_join=None,
+        header_case=None,
     ):
         self.setinitial("sheet", sheet)
         super().__init__(
@@ -64,6 +65,7 @@ class OdsDialect(Dialect):
             header=header,
             header_rows=header_rows,
             header_join=header_join,
+            header_case=header_case,
         )
 
     @Metadata.property
@@ -91,6 +93,7 @@ class OdsDialect(Dialect):
             "header": {"type": "boolean"},
             "headerRows": {"type": "array", "items": {"type": "number"}},
             "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
         },
     }
 
diff --git a/frictionless/plugins/spss.py b/frictionless/plugins/spss.py
index 9983986a..cb6dbd20 100644
--- a/frictionless/plugins/spss.py
+++ b/frictionless/plugins/spss.py
@@ -24,7 +24,6 @@ class SpssPlugin(Plugin):
     API      | Usage
     -------- | --------
     Public   | `from frictionless.plugins.spss import SpssPlugin`
-
     """
 
     def create_dialect(self, resource, *, descriptor):
@@ -105,12 +104,11 @@ class SpssStorage(Storage):
 
     API      | Usage
     -------- | --------
-    Draft    | `from frictionless.plugins.spss import SpssStorage`
+    Public   | `from frictionless.plugins.spss import SpssStorage`
 
     Parameters:
         basepath? (str): A path to a dir for reading/writing SAV files.
             Defaults to current dir.
-
     """
 
     def __init__(self, *, basepath=None):
diff --git a/frictionless/plugins/sql.py b/frictionless/plugins/sql.py
index 6fe5f5f8..f6026866 100644
--- a/frictionless/plugins/sql.py
+++ b/frictionless/plugins/sql.py
@@ -71,6 +71,7 @@ class SqlDialect(Dialect):
         header=None,
         header_rows=None,
         header_join=None,
+        header_case=None,
     ):
         self.setinitial("table", table)
         self.setinitial("order_by", order_by)
@@ -80,6 +81,7 @@ class SqlDialect(Dialect):
             header=header,
             header_rows=header_rows,
             header_join=header_join,
+            header_case=header_case,
         )
 
     @Metadata.property
@@ -107,6 +109,7 @@ class SqlDialect(Dialect):
             "header": {"type": "boolean"},
             "headerRows": {"type": "array", "items": {"type": "number"}},
             "headerJoin": {"type": "string"},
+            "headerCase": {"type": "boolean"},
         },
     }
 
diff --git a/frictionless/table.py b/frictionless/table.py
index 13bdb07f..aa00a5db 100644
--- a/frictionless/table.py
+++ b/frictionless/table.py
@@ -556,12 +556,19 @@ class Table:
             note = "Schemas with duplicate field names are not supported"
             raise exceptions.FrictionlessException(errors.SchemaError(note=note))
 
-        # Store state/stats
+        # Store stats
+        self.__resource.stats["fields"] = len(schema.fields)
+
+        # Store state
         self.__sample = sample
         self.__field_positions = field_positions
         self.__sample_positions = sample_positions
-        self.__header = Header(header, schema=schema, field_positions=field_positions)
-        self.__resource.stats["fields"] = len(schema.fields)
+        self.__header = Header(
+            header,
+            schema=schema,
+            field_positions=field_positions,
+            ignore_case=not dialect.header_case,
+        )
 
     def __read_data_stream_infer_header(self, header_data):
         dialect = self.__resource.dialect


Test patch:
diff --git a/tests/test_package.py b/tests/test_package.py
index f201615d..539e8a47 100644
--- a/tests/test_package.py
+++ b/tests/test_package.py
@@ -281,11 +281,11 @@ def test_package_expand_resource_dialect():
                     "header": True,
                     "headerRows": [1],
                     "headerJoin": " ",
+                    "headerCase": True,
                     "lineTerminator": "\r\n",
                     "quoteChar": '"',
                     "doubleQuote": True,
                     "skipInitialSpace": False,
-                    "caseSensitiveHeader": False,
                 },
                 "profile": "data-resource",
             }
diff --git a/tests/test_resource.py b/tests/test_resource.py
index 2baef210..2929f653 100644
--- a/tests/test_resource.py
+++ b/tests/test_resource.py
@@ -476,12 +476,12 @@ def test_resource_expand_with_dialect():
             "header": True,
             "headerRows": [1],
             "headerJoin": " ",
+            "headerCase": True,
             "delimiter": "custom",
             "lineTerminator": "\r\n",
             "doubleQuote": True,
             "quoteChar": '"',
             "skipInitialSpace": False,
-            "caseSensitiveHeader": False,
         },
     }
 
diff --git a/tests/test_table.py b/tests/test_table.py
index e253c3cb..97d23601 100644
--- a/tests/test_table.py
+++ b/tests/test_table.py
@@ -1,7 +1,7 @@
 import io
 import sys
 import pytest
-from frictionless import Table, Query, controls, dialects, exceptions
+from frictionless import Table, Query, Schema, Field, controls, dialects, exceptions
 
 
 # General
@@ -167,90 +167,6 @@ def test_table_source_error_data():
     assert error.note == "all data items must be lists"
 
 
-# Headers
-
-
-def test_table_headers():
-    with Table("data/table.csv") as table:
-        assert table.header == ["id", "name"]
-        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
-
-
-def test_table_headers_unicode():
-    with Table("data/table-unicode-headers.csv") as table:
-        assert table.header == ["id", "国人"]
-        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
-
-
-def test_table_headers_stream_context_manager():
-    source = open("data/table.csv", mode="rb")
-    with Table(source, format="csv") as table:
-        assert table.header == ["id", "name"]
-        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
-
-
-def test_table_headers_inline():
-    source = [[], ["id", "name"], ["1", "english"], ["2", "中国人"]]
-    with Table(source, headers=2) as table:
-        assert table.header == ["id", "name"]
-        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
-
-
-def test_table_headers_json_keyed():
-    source = "text://[" '{"id": 1, "name": "english"},' '{"id": 2, "name": "中国人"}]'
-    with Table(source, format="json") as table:
-        assert table.header == ["id", "name"]
-        assert table.read_data() == [[1, "english"], [2, "中国人"]]
-
-
-def test_table_headers_inline_keyed():
-    source = [{"id": "1", "name": "english"}, {"id": "2", "name": "中国人"}]
-    with Table(source) as table:
-        assert table.header == ["id", "name"]
-        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
-
-
-def test_table_headers_inline_keyed_headers_is_none():
-    source = [{"id": "1", "name": "english"}, {"id": "2", "name": "中国人"}]
-    with Table(source, headers=False) as table:
-        assert table.header == []
-        assert table.read_data() == [["id", "name"], ["1", "english"], ["2", "中国人"]]
-
-
-def test_table_headers_xlsx_multiline():
-    source = "data/multiline-headers.xlsx"
-    dialect = dialects.ExcelDialect(fill_merged_cells=True)
-    with Table(source, dialect=dialect, headers=[1, 2, 3, 4, 5]) as table:
-        assert table.header == [
-            "Region",
-            "Caloric contribution (%)",
-            "Cumulative impact of changes on cost of food basket from previous quarter",
-            "Cumulative impact of changes on cost of food basket from baseline (%)",
-        ]
-        assert table.read_data() == [["A", "B", "C", "D"]]
-
-
-def test_table_headers_csv_multiline_headers_join():
-    source = "text://k1\nk2\nv1\nv2\nv3"
-    with Table(source, format="csv", headers=[[1, 2], ":"]) as table:
-        assert table.header == ["k1:k2"]
-        assert table.read_data() == [["v1"], ["v2"], ["v3"]]
-
-
-def test_table_headers_csv_multiline_headers_duplicates():
-    source = "text://k1\nk1\nv1\nv2\nv3"
-    with Table(source, format="csv", headers=[1, 2]) as table:
-        assert table.header == ["k1"]
-        assert table.read_data() == [["v1"], ["v2"], ["v3"]]
-
-
-def test_table_headers_strip_and_non_strings():
-    source = [[" header ", 2, 3, None], ["value1", "value2", "value3", "value4"]]
-    with Table(source, headers=1) as table:
-        assert table.header == ["header", "2", "3", ""]
-        assert table.read_data() == [["value1", "value2", "value3", "value4"]]
-
-
 # Scheme
 
 
@@ -631,6 +547,25 @@ def test_table_dialect_bad_property():
     assert error.note.count("bad")
 
 
+def test_table_dialect_header_case_default():
+    schema = Schema(fields=[Field(name="ID"), Field(name="NAME")])
+    with Table("data/table.csv", schema=schema) as table:
+        assert table.schema.field_names == ["ID", "NAME"]
+        assert table.header == ["id", "name"]
+        assert table.header.valid is False
+        assert table.header.errors[0].code == "non-matching-header"
+        assert table.header.errors[1].code == "non-matching-header"
+
+
+def test_table_dialect_header_case_is_false():
+    dialect = dialects.Dialect(header_case=False)
+    schema = Schema(fields=[Field(name="ID"), Field(name="NAME")])
+    with Table("data/table.csv", dialect=dialect, schema=schema) as table:
+        assert table.schema.field_names == ["ID", "NAME"]
+        assert table.header == ["id", "name"]
+        assert table.header.valid is True
+
+
 # Query
 
 
@@ -916,6 +851,90 @@ def test_table_limit_offset_rows():
         assert table.read_data() == [["3", "c"], ["4", "d"]]
 
 
+# Header
+
+
+def test_table_header():
+    with Table("data/table.csv") as table:
+        assert table.header == ["id", "name"]
+        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
+
+
+def test_table_header_unicode():
+    with Table("data/table-unicode-headers.csv") as table:
+        assert table.header == ["id", "国人"]
+        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
+
+
+def test_table_header_stream_context_manager():
+    source = open("data/table.csv", mode="rb")
+    with Table(source, format="csv") as table:
+        assert table.header == ["id", "name"]
+        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
+
+
+def test_table_header_inline():
+    source = [[], ["id", "name"], ["1", "english"], ["2", "中国人"]]
+    with Table(source, headers=2) as table:
+        assert table.header == ["id", "name"]
+        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
+
+
+def test_table_header_json_keyed():
+    source = "text://[" '{"id": 1, "name": "english"},' '{"id": 2, "name": "中国人"}]'
+    with Table(source, format="json") as table:
+        assert table.header == ["id", "name"]
+        assert table.read_data() == [[1, "english"], [2, "中国人"]]
+
+
+def test_table_header_inline_keyed():
+    source = [{"id": "1", "name": "english"}, {"id": "2", "name": "中国人"}]
+    with Table(source) as table:
+        assert table.header == ["id", "name"]
+        assert table.read_data() == [["1", "english"], ["2", "中国人"]]
+
+
+def test_table_header_inline_keyed_headers_is_none():
+    source = [{"id": "1", "name": "english"}, {"id": "2", "name": "中国人"}]
+    with Table(source, headers=False) as table:
+        assert table.header == []
+        assert table.read_data() == [["id", "name"], ["1", "english"], ["2", "中国人"]]
+
+
+def test_table_header_xlsx_multiline():
+    source = "data/multiline-headers.xlsx"
+    dialect = dialects.ExcelDialect(fill_merged_cells=True)
+    with Table(source, dialect=dialect, headers=[1, 2, 3, 4, 5]) as table:
+        assert table.header == [
+            "Region",
+            "Caloric contribution (%)",
+            "Cumulative impact of changes on cost of food basket from previous quarter",
+            "Cumulative impact of changes on cost of food basket from baseline (%)",
+        ]
+        assert table.read_data() == [["A", "B", "C", "D"]]
+
+
+def test_table_header_csv_multiline_headers_join():
+    source = "text://k1\nk2\nv1\nv2\nv3"
+    with Table(source, format="csv", headers=[[1, 2], ":"]) as table:
+        assert table.header == ["k1:k2"]
+        assert table.read_data() == [["v1"], ["v2"], ["v3"]]
+
+
+def test_table_header_csv_multiline_headers_duplicates():
+    source = "text://k1\nk1\nv1\nv2\nv3"
+    with Table(source, format="csv", headers=[1, 2]) as table:
+        assert table.header == ["k1"]
+        assert table.read_data() == [["v1"], ["v2"], ["v3"]]
+
+
+def test_table_header_strip_and_non_strings():
+    source = [[" header ", 2, 3, None], ["value1", "value2", "value3", "value4"]]
+    with Table(source, headers=1) as table:
+        assert table.header == ["header", "2", "3", ""]
+        assert table.read_data() == [["value1", "value2", "value3", "value4"]]
+
+
 # Schema