Skip to content

Commit 3a774a9

Browse files
refactor: use LabelMatching for label<>field matching
1 parent 6d5fc73 commit 3a774a9

2 files changed

Lines changed: 65 additions & 29 deletions

File tree

frictionless/table/header.py

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .. import errors, helpers, types
88
from ..exception import FrictionlessException
99
from ..schema import Field
10+
from .label_matching import LabelMatching
1011

1112
# The `fieldsMatch` modes are told apart by which mismatch they tolerate: a
1213
# label with no matching field, or a declared field with no matching label
@@ -25,7 +26,7 @@ class Header(List[str]): # type: ignore
2526
> Constructor of this object is not Public API
2627
2728
> Deprecated: using a `Header` directly as a list is deprecated, as the list
28-
> ambiguously holds the names of the *schema* fieldss. Use its properties instead:
29+
> ambiguously holds the names of the *schema* fields. Use its properties instead:
2930
> `labels` for the header row as read from the data source, `fields`/`field_names`
3031
> for the schema fields.
3132
@@ -64,6 +65,7 @@ def __init__(
6465
self.__labels = labels
6566
self.__errors: List[errors.HeaderError] = []
6667
self.__expected_fields: Optional[List[Field]] = None
68+
self.__matching = LabelMatching(labels, self.__fields, ignore_case=ignore_case)
6769
self.__process()
6870

6971
# Deprecated
@@ -201,7 +203,7 @@ def get_expected_fields(self) -> List[Field]:
201203

202204
expected: List[Field] = []
203205
for label in self.__labels:
204-
field = self.__find_field_by_name(label)
206+
field = self.__matching.matching_field(label)
205207
if field is None:
206208
field = Field.from_descriptor({"name": label, "type": "any"})
207209
expected.append(field)
@@ -233,7 +235,7 @@ def _get_extra_labels(self) -> List[Tuple[int, str]]:
233235
return [
234236
(number, label)
235237
for number, label in enumerate(labels, start=1)
236-
if self.__find_field_by_name(label) is None
238+
if self.__matching.matching_field(label) is None
237239
]
238240

239241
def _get_missing_fields(self) -> List[Tuple[int, Field]]:
@@ -257,39 +259,19 @@ def _get_missing_fields(self) -> List[Tuple[int, Field]]:
257259
if not self.__matches_by_name:
258260
missing = fields[len(labels) :] if len(fields) > len(labels) else []
259261
else:
260-
normalized_labels = [self.__normalize(label) for label in labels]
261-
262-
def is_absent(field: Field) -> bool:
263-
return self.__normalize(field.name) not in normalized_labels
264262

265263
def is_required(field: Field) -> bool:
266264
return field.required or (
267265
field.schema is not None and field.name in field.schema.primary_key
268266
)
269267

270-
missing = [field for field in fields if is_absent(field)]
268+
missing = self.__matching.unmatched_fields
271269
if self.__fields_match in TOLERATES_MISSING_FIELDS:
272270
missing = [field for field in missing if is_required(field)]
273271

274272
start = len(labels) + 1
275273
return [(start + offset, field) for offset, field in enumerate(missing)]
276274

277-
def __has_matching_field(self) -> bool:
278-
"""Whether at least one label corresponds to a schema field"""
279-
return any(
280-
self.__find_field_by_name(label) is not None for label in self.__labels
281-
)
282-
283-
def __find_field_by_name(self, name: str) -> Optional[Field]:
284-
target = self.__normalize(name)
285-
for f in self.__fields:
286-
if self.__normalize(f.name) == target:
287-
return f
288-
return None
289-
290-
def __normalize(self, s: str) -> str:
291-
return s.lower() if self.__ignore_case else s
292-
293275
# Convert
294276

295277
def to_str(self):
@@ -330,11 +312,7 @@ def __process(self):
330312
)
331313

332314
# Unmatched header
333-
if (
334-
self.__fields_match == "partial"
335-
and fields
336-
and not self.__has_matching_field()
337-
):
315+
if self.__fields_match == "partial" and fields and not self.__matching.has_match:
338316
self.__errors.append(
339317
errors.UnmatchedHeaderError(
340318
note="",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
from typing import Dict, List, Optional
4+
5+
from ..schema import Field
6+
7+
8+
class LabelMatching:
9+
"""Pairs the labels read from the data source with the schema fields, by name.
10+
11+
Parameters:
12+
labels (str[]): the header row as read from the data source
13+
fields (Field[]): the fields declared in the schema, in schema order
14+
ignore_case (bool): compare labels and field names case-insensitively
15+
"""
16+
17+
def __init__(
18+
self,
19+
labels: List[str],
20+
fields: List[Field],
21+
*,
22+
ignore_case: bool = False,
23+
) -> None:
24+
self.__labels = labels
25+
self.__fields = fields
26+
self.__ignore_case = ignore_case
27+
28+
# Keyed by normalized name, in schema order; the first field wins in
29+
# case of duplicates under normalization, so the duplicate fields are
30+
# lost
31+
fields_by_key: Dict[str, Field] = {}
32+
for field in fields:
33+
fields_by_key.setdefault(self.__normalize(field.name), field)
34+
35+
self.__fields_by_key = fields_by_key
36+
37+
def matching_field(self, label: str) -> Optional[Field]:
38+
"""Returns the field the given label matches, or None if there is none"""
39+
return self.__fields_by_key.get(self.__normalize(label))
40+
41+
@property
42+
def unmatched_fields(self) -> List[Field]:
43+
"""The fields no label matches, in schema order"""
44+
matched = {self.__normalize(label) for label in self.__labels}
45+
return [
46+
field
47+
for field in self.__fields
48+
if self.__normalize(field.name) not in matched
49+
]
50+
51+
@property
52+
def has_match(self) -> bool:
53+
"""Whether at least one label matches a schema field"""
54+
return any(self.matching_field(label) is not None for label in self.__labels)
55+
56+
def __normalize(self, name: str) -> str:
57+
"""The normalized value a label and a field name are compared through"""
58+
return name.lower() if self.__ignore_case else name

0 commit comments

Comments
 (0)