|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | from functools import cached_property |
4 | | -from typing import List, Optional |
| 4 | +from typing import List, Optional, Tuple |
5 | 5 |
|
6 | 6 | from ..exception import FrictionlessException |
7 | 7 | from ..schema import Field |
@@ -155,31 +155,42 @@ def _get_extra_labels(self) -> List[str]: |
155 | 155 | return self.__labels[len(self.__fields) :] |
156 | 156 | return [] |
157 | 157 |
|
158 | | - def _get_missing_fields(self) -> List[Field]: |
159 | | - """Returns schema fields that don't have a corresponding label. |
| 158 | + def _get_missing_fields(self) -> List[Tuple[int, Field]]: |
| 159 | + """Returns (field_number, field) pairs for schema fields that don't |
| 160 | + have a corresponding label. |
160 | 161 |
|
161 | 162 | Without `schema_sync`, fields beyond the labels count are considered |
162 | 163 | missing. With `schema_sync`, only required fields whose name is not |
163 | 164 | among the labels are missing. |
| 165 | +
|
| 166 | + The field_number is `len(labels) + offset + 1` in both modes: under |
| 167 | + no-sync the missing fields are precisely the tail of the schema, so |
| 168 | + this matches their position; under sync the missing fields have no |
| 169 | + natural position in the data, so we place them after the labels by |
| 170 | + convention. |
164 | 171 | """ |
165 | 172 | fields = self.__fields |
166 | 173 | labels = self.__labels |
167 | | - if not self.__schema_sync: |
168 | | - if len(fields) > len(labels): |
169 | | - return fields[len(labels) :] |
170 | | - return [] |
171 | 174 |
|
172 | | - normalized_labels = [self.__normalize(label) for label in labels] |
| 175 | + if not self.__schema_sync: |
| 176 | + missing = fields[len(labels) :] if len(fields) > len(labels) else [] |
| 177 | + else: |
| 178 | + normalized_labels = [self.__normalize(label) for label in labels] |
| 179 | + |
| 180 | + def required_and_missing(field: Field) -> bool: |
| 181 | + required = field.required or ( |
| 182 | + field.schema is not None |
| 183 | + and field.name in field.schema.primary_key |
| 184 | + ) |
| 185 | + return ( |
| 186 | + required |
| 187 | + and self.__normalize(field.name) not in normalized_labels |
| 188 | + ) |
173 | 189 |
|
174 | | - def required_and_missing(field: Field) -> bool: |
175 | | - required = field.required or ( |
176 | | - field.schema is not None and field.name in field.schema.primary_key |
177 | | - ) |
178 | | - return ( |
179 | | - required and self.__normalize(field.name) not in normalized_labels |
180 | | - ) |
| 190 | + missing = [field for field in fields if required_and_missing(field)] |
181 | 191 |
|
182 | | - return [field for field in fields if required_and_missing(field)] |
| 192 | + start = len(labels) + 1 |
| 193 | + return [(start + offset, field) for offset, field in enumerate(missing)] |
183 | 194 |
|
184 | 195 | def __find_field_by_name(self, name: str) -> Optional[Field]: |
185 | 196 | target = self.__normalize(name) |
@@ -232,24 +243,28 @@ def __process(self): |
232 | 243 | ) |
233 | 244 |
|
234 | 245 | # Missing fields |
235 | | - missing_fields = self._get_missing_fields() |
236 | | - if missing_fields: |
237 | | - missing_ids = {id(field) for field in missing_fields} |
238 | | - for field_number, field in enumerate(fields, start=1): |
239 | | - if field is None or id(field) not in missing_ids: |
240 | | - continue |
241 | | - self.__errors.append( |
242 | | - errors.MissingLabelError( |
243 | | - note="", |
244 | | - labels=list(map(str, labels)), |
245 | | - row_numbers=self.__row_numbers, |
246 | | - label="", |
247 | | - field_name=field.name, |
248 | | - field_number=field_number, |
249 | | - ) |
| 246 | + for field_number, field in self._get_missing_fields(): |
| 247 | + self.__errors.append( |
| 248 | + errors.MissingLabelError( |
| 249 | + note="", |
| 250 | + labels=list(map(str, labels)), |
| 251 | + row_numbers=self.__row_numbers, |
| 252 | + label="", |
| 253 | + field_name=field.name, |
| 254 | + field_number=field_number, |
250 | 255 | ) |
| 256 | + ) |
251 | 257 |
|
252 | 258 | # Iterate items |
| 259 | + # Under schema_sync, labels and fields are matched by name (not by |
| 260 | + # position), so the positional comparisons below (blank label vs |
| 261 | + # field at the same index, incorrect label vs field name at the same |
| 262 | + # index) don't apply. Duplicate labels are still invalid, but they |
| 263 | + # are rejected earlier by get_expected_fields(), which raises a |
| 264 | + # FrictionlessException — so detecting them here would be redundant. |
| 265 | + if self.__schema_sync: |
| 266 | + return |
| 267 | + |
253 | 268 | field_number = 0 |
254 | 269 | for field, label in zip(fields, labels): |
255 | 270 | field_number += 1 |
|
0 commit comments