pynmrstar 3.3.6__pp311-pypy311_pp73-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pynmrstar might be problematic. Click here for more details.

pynmrstar/loop.py ADDED
@@ -0,0 +1,1218 @@
1
+ import json
2
+ import warnings
3
+ from copy import deepcopy
4
+ from csv import reader as csv_reader, writer as csv_writer
5
+ from io import StringIO
6
+ from itertools import chain
7
+ from pathlib import Path
8
+ from typing import TextIO, BinaryIO, Union, List, Optional, Any, Dict, Callable, Tuple
9
+
10
+ from pynmrstar import definitions, utils, entry as entry_mod
11
+ from pynmrstar._internal import _json_serialize, _interpret_file
12
+ from pynmrstar._types import DataInput
13
+ from pynmrstar.exceptions import InvalidStateError
14
+ from pynmrstar.parser import Parser
15
+ from pynmrstar.schema import Schema
16
+
17
+
18
+ class Loop(object):
19
+ """A BMRB loop object. Create using the class methods, see below."""
20
+
21
+ def __contains__(self, item: Any) -> bool:
22
+ """ Check if the loop contains one or more tags. """
23
+
24
+ # Prepare for processing
25
+ if isinstance(item, (list, tuple)):
26
+ to_process: List[str] = list(item)
27
+ elif isinstance(item, str):
28
+ to_process = [item]
29
+ else:
30
+ return False
31
+
32
+ lc_tags = self._lc_tags
33
+ for tag in to_process:
34
+ if utils.format_tag_lc(tag) not in lc_tags:
35
+ return False
36
+ return True
37
+
38
+ def __eq__(self, other) -> bool:
39
+ """Returns True if this loop is equal to another loop, False if
40
+ it is different."""
41
+
42
+ if not isinstance(other, Loop):
43
+ return False
44
+
45
+ return (self.category, self._tags, self.data) == \
46
+ (other.category, other._tags, other.data)
47
+
48
+ def __getitem__(self, item: Union[int, str, List[str], Tuple[str]]) -> list:
49
+ """Get the indicated row from the data array."""
50
+
51
+ try:
52
+ return self.data[item]
53
+ except TypeError:
54
+ if isinstance(item, tuple):
55
+ item = list(item)
56
+ return self.get_tag(tags=item)
57
+
58
+ def __init__(self, **kwargs) -> None:
59
+ """ You should not directly instantiate a Loop using this method.
60
+ Instead use the class methods:
61
+
62
+ :py:meth:`Loop.from_scratch`, :py:meth:`Loop.from_string`,
63
+ :py:meth:`Loop.from_template`, :py:meth:`Loop.from_file`,
64
+ :py:meth:`Loop.from_json`"""
65
+
66
+ # Initialize our local variables
67
+ self._tags: List[str] = []
68
+ self.data: List[List[Any]] = []
69
+ self.category: Optional[str] = None
70
+ self.source: str = "unknown"
71
+
72
+ star_buffer: StringIO = StringIO("")
73
+
74
+ # Update our source if it provided
75
+ if 'source' in kwargs:
76
+ self.source = kwargs['source']
77
+
78
+ # Update our category if provided
79
+ if 'category' in kwargs:
80
+ self.category = utils.format_category(kwargs['category'])
81
+ return
82
+
83
+ # They initialized us wrong
84
+ if len(kwargs) == 0:
85
+ raise ValueError("You should not directly instantiate a Loop using this method. Instead use the "
86
+ "class methods: Loop.from_scratch(), Loop.from_string(), Loop.from_template(), "
87
+ "Loop.from_file(), and Loop.from_json().")
88
+
89
+ # Parsing from a string
90
+ if 'the_string' in kwargs:
91
+ # Parse from a string by wrapping it in StringIO
92
+ star_buffer = StringIO(kwargs['the_string'])
93
+ self.source = "from_string()"
94
+ # Parsing from a file
95
+ elif 'file_name' in kwargs:
96
+ star_buffer = _interpret_file(kwargs['file_name'])
97
+ self.source = f"from_file('{kwargs['file_name']}')"
98
+ # Creating from template (schema)
99
+ elif 'tag_prefix' in kwargs:
100
+
101
+ tags = Loop._get_tags_from_schema(kwargs['tag_prefix'], all_tags=kwargs['all_tags'],
102
+ schema=kwargs['schema'])
103
+ for tag in tags:
104
+ self.add_tag(tag)
105
+
106
+ return
107
+
108
+ # If we are reading from a CSV file, go ahead and parse it
109
+ if 'csv' in kwargs and kwargs['csv']:
110
+ csv_file = csv_reader(star_buffer)
111
+ self.add_tag(next(csv_file))
112
+ for row in csv_file:
113
+ self.add_data(row,
114
+ convert_data_types=kwargs.get('convert_data_types', False),
115
+ schema=kwargs.get('schema', None))
116
+ self.source = f"from_csv('{kwargs['csv']}')"
117
+ return
118
+
119
+ tmp_entry = entry_mod.Entry.from_scratch(0)
120
+
121
+ # Load the BMRB entry from the file
122
+ star_buffer = StringIO(f"data_0 save_internaluseyoushouldntseethis_frame _internal.use internal "
123
+ f"{star_buffer.read()} save_")
124
+ parser = Parser(entry_to_parse_into=tmp_entry)
125
+ parser.parse(star_buffer.read(),
126
+ source=self.source,
127
+ convert_data_types=kwargs.get('convert_data_types', False),
128
+ raise_parse_warnings=kwargs.get('raise_parse_warnings', False),
129
+ schema=kwargs.get('schema', None))
130
+
131
+ # Check that there was only one loop here
132
+ if len(tmp_entry[0].loops) > 1:
133
+ raise ValueError("You attempted to parse one loop but the source you provided had more than one loop. "
134
+ "Please either parse all loops as a saveframe or only parse one loop. Loops detected: " +
135
+ str(tmp_entry[0].loops))
136
+
137
+ # Copy the first parsed saveframe into ourself
138
+ self._tags = tmp_entry[0][0].tags
139
+ self.data = tmp_entry[0][0].data
140
+ self.category = tmp_entry[0][0].category
141
+
142
+ def __iter__(self) -> list:
143
+ """ Yields each of the rows contained within the loop. """
144
+
145
+ for row in self.data:
146
+ yield row
147
+
148
+ def __len__(self) -> int:
149
+ """Return the number of rows of data."""
150
+
151
+ return len(self.data)
152
+
153
+ def __lt__(self, other) -> bool:
154
+ """Returns True if this loop sorts lower than the compared
155
+ loop, false otherwise."""
156
+
157
+ if not isinstance(other, Loop):
158
+ return NotImplemented
159
+
160
+ return self.category < other.category
161
+
162
+ def __repr__(self) -> str:
163
+ """Returns a description of the loop."""
164
+
165
+ return f"<pynmrstar.Loop '{self.category}'>"
166
+
167
+ def __setitem__(self, key: str, item: Any) -> None:
168
+ """Set all of the instances of a tag to the provided value.
169
+ If there are 5 rows of data in the loop, you will need to
170
+ assign a list with 5 elements."""
171
+
172
+ tag = utils.format_tag_lc(key)
173
+
174
+ # Check that their tag is in the loop
175
+ if tag not in self._lc_tags:
176
+ raise ValueError(f"Cannot assign to tag '{key}' as it does not exist in this loop.")
177
+
178
+ # Determine where to assign
179
+ tag_id = self._lc_tags[tag]
180
+
181
+ # Make sure they provide a list of the correct length
182
+ if len(self[key]) != len(item):
183
+ raise ValueError("To assign to a tag you must provide a list (or iterable) of a length equal to the "
184
+ f"number of values that currently exist for that tag. The tag '{key}' currently has"
185
+ f" {len(self[key])} values and you supplied {len(item)} values.")
186
+
187
+ # Do the assignment
188
+ for pos, row in enumerate(self.data):
189
+ row[tag_id] = item[pos]
190
+
191
+ def __str__(self, skip_empty_loops: bool = False, skip_empty_tags: bool = False) -> str:
192
+ """Returns the loop in STAR format as a string."""
193
+
194
+ # Check if there is any data in this loop
195
+ if len(self.data) == 0:
196
+ # They do not want us to print empty loops
197
+ if skip_empty_loops:
198
+ return ""
199
+ else:
200
+ # If we have no tags than return the empty loop
201
+ if len(self._tags) == 0:
202
+ return "\n loop_\n\n stop_\n"
203
+
204
+ if len(self._tags) == 0:
205
+ raise InvalidStateError("Impossible to print data if there are no associated tags. Error in loop "
206
+ f"'{self.category}' which contains data but hasn't had any tags added.")
207
+
208
+ # Make sure the tags and data match
209
+ self._check_tags_match_data()
210
+
211
+ # If skipping null tags, it's easier to filter out a loop with only real tags and then print
212
+ if skip_empty_tags:
213
+ has_data = [not all([_ in definitions.NULL_VALUES for _ in column]) for column in zip(*self.data)]
214
+ return self.filter([tag for x, tag in enumerate(self._tags) if has_data[x]]).format()
215
+
216
+ # Start the loop
217
+ return_chunks = ["\n loop_\n"]
218
+ # Print the tags
219
+ format_string = " %-s\n"
220
+
221
+ # Check to make sure our category is set
222
+ if self.category is None:
223
+ raise InvalidStateError("The category was never set for this loop. Either add a tag with the category "
224
+ "intact, specify it when generating the loop, or set it using Loop.set_category().")
225
+
226
+ # Print the categories
227
+ if self.category is None:
228
+ for tag in self._tags:
229
+ return_chunks.append(format_string % tag)
230
+ else:
231
+ for tag in self._tags:
232
+ return_chunks.append(format_string % (self.category + "." + tag))
233
+
234
+ return_chunks.append("\n")
235
+
236
+ if len(self.data) != 0:
237
+
238
+ # Make a copy of the data
239
+ working_data = []
240
+ title_widths = [4]*len(self.data[0])
241
+
242
+ # Put quotes as needed on the data
243
+ for row_pos, row in enumerate(self.data):
244
+ clean_row = []
245
+ for col_pos, x in enumerate(row):
246
+ try:
247
+ clean_val = utils.quote_value(x)
248
+ clean_row.append(clean_val)
249
+ length = len(clean_val) + 3
250
+ if length > title_widths[col_pos] and "\n" not in clean_val:
251
+ title_widths[col_pos] = length
252
+
253
+ except ValueError:
254
+ raise InvalidStateError('Cannot generate NMR-STAR for entry, as empty strings are not valid '
255
+ 'tag values in NMR-STAR. Please either replace the empty strings with'
256
+ ' None objects, or set pynmrstar.definitions.STR_CONVERSION_DICT['
257
+ '\'\'] = None.\n'
258
+ f'Loop: {self.category} Row: {row_pos} Column: {col_pos}')
259
+
260
+ working_data.append(clean_row)
261
+
262
+ # Generate the format string
263
+ format_string = " " + "%-*s" * len(self._tags) + " \n"
264
+
265
+ # Print the data, with the tags sized appropriately
266
+ for datum in working_data:
267
+ for pos, item in enumerate(datum):
268
+ if "\n" in item:
269
+ datum[pos] = "\n;\n%s;\n" % item
270
+
271
+ # Print the data (combine the tags' widths with their data)
272
+ tag_width_list = [d for d in zip(title_widths, datum)]
273
+ return_chunks.append(format_string % tuple(chain.from_iterable(tag_width_list)))
274
+
275
+ # Close the loop
276
+ return "".join(return_chunks) + "\n stop_\n"
277
+
278
+ @property
279
+ def _lc_tags(self) -> Dict[str, int]:
280
+ return {_[1].lower(): _[0] for _ in enumerate(self._tags)}
281
+
282
+ @property
283
+ def empty(self) -> bool:
284
+ """ Check if the loop has no data. """
285
+
286
+ for row in self.data:
287
+ for col in row:
288
+ if col not in definitions.NULL_VALUES:
289
+ return False
290
+
291
+ return True
292
+
293
+ @property
294
+ def tags(self) -> List[str]:
295
+ return self._tags
296
+
297
+ @classmethod
298
+ def from_file(cls,
299
+ the_file: Union[str, Path, TextIO, BinaryIO],
300
+ csv: bool = False,
301
+ convert_data_types: bool = False,
302
+ raise_parse_warnings: bool = False,
303
+ schema: Schema = None):
304
+ """Create a loop by loading in a file. Specify csv=True if
305
+ the file is a CSV file. If the_file starts with http://,
306
+ https://, or ftp:// then we will use those protocols to attempt
307
+ to open the file. the_file can be a string path, pathlib.Path object,
308
+ or an open file handle.
309
+
310
+ Setting convert_data_types to True will automatically convert
311
+ the data loaded from the file into the corresponding python type as
312
+ determined by loading the standard BMRB schema. This would mean that
313
+ all floats will be represented as decimal.Decimal objects, all integers
314
+ will be python int objects, strings and vars will remain strings, and
315
+ dates will become datetime.date objects. When printing str() is called
316
+ on all objects. Other that converting uppercase "E"s in scientific
317
+ notation floats to lowercase "e"s this should not cause any change in
318
+ the way re-printed NMR-STAR objects are displayed. Specify a custom
319
+ schema object to use using the schema parameter.
320
+
321
+ Setting raise_parse_warnings to True will result in the raising of a
322
+ ParsingError rather than logging a warning when non-valid (but
323
+ ignorable) issues are found."""
324
+
325
+ return cls(file_name=the_file,
326
+ csv=csv,
327
+ convert_data_types=convert_data_types,
328
+ raise_parse_warnings=raise_parse_warnings,
329
+ schema=schema)
330
+
331
+ @classmethod
332
+ def from_json(cls, json_dict: Union[dict, str]):
333
+ """Create a loop from JSON (serialized or unserialized JSON)."""
334
+
335
+ # If they provided a string, try to load it using JSON
336
+ if not isinstance(json_dict, dict):
337
+ try:
338
+ json_dict = json.loads(json_dict)
339
+ except (TypeError, ValueError):
340
+ raise ValueError("The JSON you provided was neither a Python dictionary nor a JSON string.")
341
+
342
+ # Make sure it has the correct keys
343
+ for check in ['tags', 'category', 'data']:
344
+ if check not in json_dict:
345
+ raise ValueError(f"The JSON you provide must be a dictionary and must contain the key '{check}' - even"
346
+ f" if the key points to None.")
347
+
348
+ # Create a loop from scratch and populate it
349
+ ret = Loop.from_scratch()
350
+ ret._tags = json_dict['tags']
351
+ ret.category = json_dict['category']
352
+ ret.data = json_dict['data']
353
+ ret.source = "from_json()"
354
+
355
+ # Return the new loop
356
+ return ret
357
+
358
+ @classmethod
359
+ def from_scratch(cls,
360
+ category: str = None,
361
+ source: str = "from_scratch()"):
362
+ """Create an empty saveframe that you can programmatically add
363
+ to. You may also pass the tag prefix as the second argument. If
364
+ you do not pass the tag prefix it will be set the first time you
365
+ add a tag."""
366
+
367
+ return cls(category=category, source=source)
368
+
369
+ @classmethod
370
+ def from_string(cls,
371
+ the_string: str,
372
+ csv: bool = False,
373
+ convert_data_types: bool = False,
374
+ raise_parse_warnings: bool = False,
375
+ schema: Schema = None):
376
+ """Create a loop by parsing a string. Specify csv=True if
377
+ the string is in CSV format and not NMR-STAR format.
378
+
379
+ Setting convert_data_types to True will automatically convert
380
+ the data loaded from the file into the corresponding python type as
381
+ determined by loading the standard BMRB schema. This would mean that
382
+ all floats will be represented as decimal.Decimal objects, all integers
383
+ will be python int objects, strings and vars will remain strings, and
384
+ dates will become datetime.date objects. When printing str() is called
385
+ on all objects. Other that converting uppercase "E"s in scientific
386
+ notation floats to lowercase "e"s this should not cause any change in
387
+ the way re-printed NMR-STAR objects are displayed. Specify a custom
388
+ schema object to use using the schema parameter.
389
+
390
+ Setting raise_parse_warnings to True will result in the raising of a
391
+ ParsingError rather than logging a warning when non-valid (but
392
+ ignorable) issues are found."""
393
+
394
+ return cls(the_string=the_string,
395
+ csv=csv,
396
+ convert_data_types=convert_data_types,
397
+ raise_parse_warnings=raise_parse_warnings,
398
+ schema=schema)
399
+
400
+ @classmethod
401
+ def from_template(cls, tag_prefix: str,
402
+ all_tags: bool = False,
403
+ schema: Schema = None):
404
+ """ Create a loop that has all of the tags from the schema present.
405
+ No values will be assigned. Specify the tag prefix of the loop.
406
+
407
+ The optional argument all_tags forces all tags to be included
408
+ rather than just the mandatory tags."""
409
+
410
+ schema = utils.get_schema(schema)
411
+ return cls(tag_prefix=tag_prefix,
412
+ all_tags=all_tags,
413
+ schema=schema,
414
+ source=f"from_template({schema.version})")
415
+
416
+ @staticmethod
417
+ def _get_tags_from_schema(category: str, schema: Schema = None, all_tags: bool = False) -> List[str]:
418
+ """ Returns the tags from the schema for the category of this
419
+ loop. """
420
+
421
+ schema = utils.get_schema(schema)
422
+
423
+ # Put the _ on the front for them if necessary
424
+ if not category.startswith("_"):
425
+ category = "_" + category
426
+ if not category.endswith("."):
427
+ category = category + "."
428
+
429
+ tags = []
430
+
431
+ for item in schema.schema_order:
432
+ # The tag is in the loop
433
+ if item.lower().startswith(category.lower()):
434
+
435
+ # Unconditional add
436
+ if all_tags:
437
+ tags.append(item)
438
+ # Conditional add
439
+ else:
440
+ if schema.schema[item.lower()]["public"] != "I":
441
+ tags.append(item)
442
+ if len(tags) == 0:
443
+ raise InvalidStateError(f"The tag prefix '{category}' has no corresponding tags in the dictionary.")
444
+
445
+ return tags
446
+
447
+ def _check_tags_match_data(self) -> bool:
448
+ """ Ensures that each row of the data has the same number of
449
+ elements as there are tags for the loop. This is necessary to
450
+ print or do some other operations on loops that count on the values
451
+ matching. """
452
+
453
+ # Make sure that if there is data, it is the same width as the
454
+ # tag names
455
+ if len(self.data) > 0:
456
+ for x, row in enumerate(self.data):
457
+ if len(self._tags) != len(row):
458
+ raise InvalidStateError(f"The number of tags must match the width of the data. Error in loop "
459
+ f"'{self.category}'. In this case, there are {len(self._tags)} tags, and "
460
+ f"row number {x} has {len(row)} tags.")
461
+
462
+ return True
463
+
464
+ def add_data(
465
+ self,
466
+ data: DataInput,
467
+ rearrange: bool = False,
468
+ convert_data_types: bool = False,
469
+ schema: Union[Schema, None] = None,
470
+ ) -> None:
471
+ """
472
+ Add data to a loop.
473
+
474
+ You can supply *data* in **four** canonical formats. Formats #1 and #2
475
+ are the most convenient; the others are retained mainly for legacy code.
476
+
477
+ 1. **Row‑oriented dictionaries** *(preferred)*
478
+ • a **list** of dictionaries,
479
+ where each dictionary represents one row::
480
+
481
+ [{'name': 'Jeff', 'location': 'Connecticut'},
482
+ {'name': 'Chad', 'location': 'Madison'}]
483
+
484
+ 2. **Column‑oriented dictionary of lists**
485
+ A dictionary mapping each tag to a list of values (or to a single
486
+ scalar)::
487
+
488
+ {'name': ['Jeff', 'Chad'],
489
+ 'location': ['Connecticut', 'Madison']}
490
+
491
+ All value‑lists must be the same length; that length determines the
492
+ number of rows created.
493
+
494
+ 3. **Matrix of values**
495
+ A list of lists whose inner order exactly matches the loop’s current
496
+ tag order::
497
+
498
+ [['Jeff', 'Connecticut'],
499
+ ['Chad', 'Madison']]
500
+
501
+ 4. **Flat list of values**
502
+ A single list whose length is either:
503
+ • exactly the number of tags (adds one row), or
504
+ • any multiple of that length **when** ``rearrange=True``::
505
+
506
+ ['Jeff', 'Connecticut'] # one row
507
+ ['Jeff', 'Connecticut', 'Chad', 'Madison'] # two rows (requires rearrange=True)
508
+
509
+ This form is discouraged and kept only for backward compatibility.
510
+
511
+ Parameters
512
+ ----------
513
+ data
514
+ The data to add, in any of the formats described above.
515
+ rearrange
516
+ Only used with format #4. When ``True`` the flat list is split into
517
+ evenly sized rows. Rarely needed outside of old parsers.
518
+ convert_data_types
519
+ If ``True`` each value is converted to the type defined in *schema*
520
+ before insertion.
521
+ schema
522
+ A :class:`pynmrstar.Schema` instance used when
523
+ ``convert_data_types`` is ``True``.
524
+ """
525
+
526
+ if not data:
527
+ raise ValueError('No valid data provided.')
528
+
529
+ pending_data: List = []
530
+ lc_tag_index: Dict[str, int] = self._lc_tags
531
+
532
+ def format_two_to_one(format_two: Dict[str, List]):
533
+ max_length = max([len(_) for _ in format_two.values()])
534
+ keys = format_two.keys()
535
+ for row_id in range(0, max_length):
536
+ row_dict = {}
537
+ for key in keys:
538
+ try:
539
+ row_dict[key] = format_two[key][row_id]
540
+ except IndexError:
541
+ pass
542
+ yield row_dict
543
+
544
+ # Data format #1 and #2
545
+ if (isinstance(data, list) and isinstance(data[0], dict)) or \
546
+ isinstance(data, dict) and all([isinstance(_, list) for _ in data.values()]):
547
+
548
+ # Handle format #2 by converting it to #1
549
+ if isinstance(data, dict):
550
+ data = format_two_to_one(data)
551
+
552
+ for pos, row in enumerate(data):
553
+ current_row = [None]*len(self._tags)
554
+ for tag, value in row.items():
555
+ try:
556
+ tag_index = lc_tag_index[utils.format_tag_lc(tag)]
557
+ except KeyError:
558
+ raise ValueError(f'In row {pos} of your provided data, a tag was supplied which was not'
559
+ f" already present in the loop. Invalid tag: '{tag}'")
560
+ current_row[tag_index] = value
561
+ pending_data.append(current_row)
562
+ # Type 4 - a list of lists
563
+ elif isinstance(data, list) and isinstance(data[0], list):
564
+ for pos, row in enumerate(data):
565
+ if len(row) != len(self.tags):
566
+ raise ValueError('One of the lists you provided is not the correct length to match the number '
567
+ f'of tags present in the loop. Error on row {pos} with values: {row}')
568
+ pending_data = data
569
+ # Type 3 - a list of values
570
+ elif isinstance(data, list):
571
+ if rearrange:
572
+ # Break their data into chunks based on the number of tags
573
+ pending_data = [data[x:x + len(self._tags)] for x in range(0, len(data), len(self._tags))]
574
+ if len(pending_data[-1]) != len(self._tags):
575
+ raise ValueError(f"The number of data elements in the list you provided is not an even multiple of "
576
+ f"the number of tags which are set in the loop. Please either add missing tags "
577
+ f"using Loop.add_tag() or modify the list of tag values you are adding to be an "
578
+ f"even multiple of the number of tags. Error in loop '{self.category}'.")
579
+ else:
580
+ # Add one row of data
581
+ if len(data) != len(self._tags):
582
+ raise ValueError("The list must have the same number of elements as the number of tags when adding "
583
+ "a single row of values! Insert tag names first by calling Loop.add_tag().")
584
+ # Add the user data
585
+ pending_data.append(data)
586
+ else:
587
+ raise ValueError("Your data did not match one of the supported types. Please review the documentation for "
588
+ "proper usage of this function.")
589
+
590
+ # Auto convert data types if option set
591
+ if convert_data_types:
592
+ schema = utils.get_schema(schema)
593
+ for row in pending_data:
594
+ for tag_id, datum in enumerate(row):
595
+ row[tag_id] = schema.convert_tag(f"{self.category}.{self._tags[tag_id]}", datum)
596
+
597
+ # Add the data at the very end to ensure that errors are caught before we mutate the data
598
+ self.data.extend(pending_data)
599
+
600
+ def add_data_by_tag(self, tag_name: str, value) -> None:
601
+ """Deprecated: It is recommended to use add_data() instead for most use
602
+ cases.
603
+
604
+ Add data to the loop one element at a time, based on tag.
605
+ Useful when adding data from SANS parsers."""
606
+
607
+ warnings.warn("Deprecated: It is recommended to use Loop.add_data() instead for most use cases.",
608
+ DeprecationWarning)
609
+
610
+ # Make sure the category matches - if provided
611
+ if "." in tag_name:
612
+ supplied_category = utils.format_category(str(tag_name))
613
+ if supplied_category.lower() != self.category.lower():
614
+ raise ValueError(f"Category provided in your tag '{supplied_category}' does not match this loop's "
615
+ f"category '{self.category}'.")
616
+
617
+ pos = self.tag_index(tag_name)
618
+ if pos is None:
619
+ raise ValueError(f"The tag '{tag_name}' to which you are attempting to add data does not yet exist. Create "
620
+ f"the tags using Loop.add_tag() before adding data.")
621
+ if len(self.data) == 0:
622
+ self.data.append([])
623
+ if len(self.data[-1]) == len(self._tags):
624
+ self.data.append([])
625
+ if len(self.data[-1]) != pos:
626
+ raise ValueError("You cannot add data out of tag order.")
627
+ self.data[-1].append(value)
628
+
629
+ def add_missing_tags(self, schema: Schema = None, all_tags: bool = False) -> None:
630
+ """ Automatically adds any missing tags (according to the schema),
631
+ sorts the tags, and renumbers the tags by ordinal. """
632
+
633
+ self.add_tag(Loop._get_tags_from_schema(self.category, schema=schema, all_tags=all_tags),
634
+ ignore_duplicates=True, update_data=True)
635
+ self.sort_tags()
636
+
637
+ # See if we can sort the rows (in addition to tags)
638
+ try:
639
+ self.sort_rows("Ordinal")
640
+ except ValueError:
641
+ pass
642
+ except TypeError:
643
+ ordinal_idx = self.tag_index("Ordinal")
644
+
645
+ # If we are in another row, assign to the previous row
646
+ for pos, row in enumerate(self.data):
647
+ row[ordinal_idx] = pos + 1
648
+
649
+ def add_tag(self, name: Union[str, List[str]], ignore_duplicates: bool = False, update_data: bool = False) -> None:
650
+ """Add a tag to the tag name list. Does a bit of validation
651
+ and parsing. Set ignore_duplicates to true to ignore attempts
652
+ to add the same tag more than once rather than raise an
653
+ exception.
654
+
655
+ You can also pass a list of tag names to add more than one
656
+ tag at a time.
657
+
658
+ Adding a tag will update the data array to match by adding
659
+ None values to the rows if you specify update_data=True."""
660
+
661
+ # If they have passed multiple tags to add, call ourself
662
+ # on each of them in succession
663
+ if isinstance(name, (list, tuple)):
664
+ for item in name:
665
+ self.add_tag(item, ignore_duplicates=ignore_duplicates, update_data=update_data)
666
+ return
667
+
668
+ name = name.strip()
669
+
670
+ if "." in name:
671
+ if name[0] != ".":
672
+ category = name[0:name.index(".")]
673
+ if category[:1] != "_":
674
+ category = "_" + category
675
+
676
+ if self.category is None:
677
+ self.category = category
678
+ elif self.category.lower() != category.lower():
679
+ raise ValueError("One loop cannot have tags with different categories (or tags that don't "
680
+ f"match the loop category)! The loop category is '{self.category}' while "
681
+ f"the category in the tag was '{category}'.")
682
+ name = name[name.index(".") + 1:]
683
+ else:
684
+ name = name[1:]
685
+
686
+ # Ignore duplicate tags
687
+ if self.tag_index(name) is not None:
688
+ if ignore_duplicates:
689
+ return
690
+ else:
691
+ raise ValueError(f"There is already a tag with the name '{name}' in the loop '{self.category}'.")
692
+ if name in definitions.NULL_VALUES:
693
+ raise ValueError(f"Cannot use a null-equivalent value as a tag name. Invalid tag name: '{name}'")
694
+ if "." in name:
695
+ raise ValueError(f"There cannot be more than one '.' in a tag name. Invalid tag name: '{name}'")
696
+ for char in str(name):
697
+ if char in utils.definitions.WHITESPACE:
698
+ raise ValueError(f"Tag names can not contain whitespace characters. Invalid tag name: '{name}")
699
+
700
+ # Add the tag
701
+ self._tags.append(name)
702
+
703
+ # Add None's to the rows of data
704
+ if update_data:
705
+
706
+ for row in self.data:
707
+ row.append(None)
708
+
709
+ def clear_data(self) -> None:
710
+ """Erases all data in this loop. Does not erase the tag names
711
+ or loop category."""
712
+
713
+ self.data = []
714
+
715
+ def compare(self, other) -> List[str]:
716
+ """Returns the differences between two loops as a list. Order of
717
+ loops being compared does not make a difference on the specific
718
+ errors detected."""
719
+
720
+ diffs = []
721
+
722
+ # Check if this is literally the same object
723
+ if self is other:
724
+ return []
725
+ # Check if the other object is our string representation
726
+ if isinstance(other, str):
727
+ if str(self) == other:
728
+ return []
729
+ else:
730
+ return ['String was not exactly equal to loop.']
731
+ elif not isinstance(other, Loop):
732
+ return ['Other object is not of class Loop.']
733
+
734
+ # We need to do this in case of an extra "\n" on the end of one tag
735
+ if str(other) == str(self):
736
+ return []
737
+
738
+ # Do STAR comparison
739
+ try:
740
+ # Check category of loops
741
+ if str(self.category).lower() != str(other.category).lower():
742
+ diffs.append(f"\t\tCategory of loops does not match: '{self.category}' vs '{other.category}'.")
743
+
744
+ # Check tags of loops
745
+ if ([x.lower() for x in self._tags] !=
746
+ [x.lower() for x in other.tags]):
747
+ diffs.append(f"\t\tLoop tag names do not match for loop with category '{self.category}'.")
748
+
749
+ # No point checking if data is the same if the tag names aren't
750
+ else:
751
+ # Only sort the data if it is not already equal
752
+ if self.data != other.data:
753
+
754
+ # Check data of loops
755
+ self_data = sorted(deepcopy(self.data))
756
+ other_data = sorted(deepcopy(other.data))
757
+
758
+ if self_data != other_data:
759
+ diffs.append(f"\t\tLoop data does not match for loop with category '{self.category}'.")
760
+
761
+ except AttributeError as err:
762
+ diffs.append(f"\t\tAn exception occurred while comparing: '{err}'.")
763
+
764
+ return diffs
765
+
766
+ def delete_tag(self, tag: Union[str, List[str]]) -> None:
767
+ """ Deprecated. Please use `py:meth:pynmrstar.Loop.remove_tag` instead. """
768
+
769
+ warnings.warn('Please use remove_tag() instead.', DeprecationWarning)
770
+ return self.remove_tag(tag)
771
+
772
+ def delete_data_by_tag_value(self, tag: str, value: Any, index_tag: str = None) -> List[List[Any]]:
773
+ """ Deprecated. Please use `py:meth:pynmrstar.Loop.remove_data_by_tag_value` instead. """
774
+
775
+ warnings.warn('Please use remove_data_by_tag_value() instead.', DeprecationWarning)
776
+ return self.remove_data_by_tag_value(tag, value, index_tag)
777
+
778
+ def filter(self, tag_list: Union[str, List[str], Tuple[str]], ignore_missing_tags: bool = False):
779
+ """ Returns a new loop containing only the specified tags.
780
+ Specify ignore_missing_tags=True to bypass missing tags rather
781
+ than raising an error."""
782
+
783
+ result = Loop.from_scratch()
784
+ valid_tags = []
785
+
786
+ # If they only provide one tag make it a list
787
+ if not isinstance(tag_list, (list, tuple)):
788
+ tag_list = [tag_list]
789
+
790
+ # Make sure all the tags specified exist
791
+ for tag in tag_list:
792
+
793
+ # Handle an invalid tag
794
+ tag_match_index = self.tag_index(tag)
795
+ if tag_match_index is None:
796
+ if not ignore_missing_tags:
797
+ raise KeyError(f"Cannot filter tag '{tag}' as it isn't present in this loop.")
798
+ continue
799
+
800
+ valid_tags.append(tag)
801
+ result.add_tag(self._tags[tag_match_index])
802
+
803
+ # Add the data for the tags to the new loop
804
+ results = self.get_tag(valid_tags)
805
+
806
+ # If there is only a single tag, we can't add data the same way
807
+ if len(valid_tags) == 1:
808
+ for item in results:
809
+ result.add_data([item])
810
+ else:
811
+ for row in results:
812
+ # We know it's a row because we didn't specify dict_result=True to get_tag()
813
+ assert isinstance(row, list)
814
+ result.add_data(row)
815
+
816
+ # Assign the category of the new loop
817
+ if result.category is None:
818
+ result.category = self.category
819
+
820
+ return result
821
+
822
+ def format(self, skip_empty_loops: bool = True, skip_empty_tags: bool = False) -> str:
823
+ """ The same as calling str(Loop), except that you can pass options
824
+ to customize how the loop is printed.
825
+
826
+ skip_empty_loops will omit printing loops with no tags at all. (A loop with null tags is not "empty".)
827
+ skip_empty_tags will omit tags in the loop which have no non-null values."""
828
+
829
+ return self.__str__(skip_empty_loops=skip_empty_loops, skip_empty_tags=skip_empty_tags)
830
+
831
+ def get_data_as_csv(self, header: bool = True, show_category: bool = True) -> str:
832
+ """Return the data contained in the loops, properly CSVd, as a
833
+ string. Set header to False to omit the header. Set
834
+ show_category to false to omit the loop category from the
835
+ headers."""
836
+
837
+ csv_buffer = StringIO()
838
+ csv_writer_object = csv_writer(csv_buffer)
839
+
840
+ if header:
841
+ if show_category:
842
+ csv_writer_object.writerow(
843
+ [str(self.category) + "." + str(x) for x in self._tags])
844
+ else:
845
+ csv_writer_object.writerow([str(x) for x in self._tags])
846
+
847
+ for row in self.data:
848
+
849
+ data = []
850
+ for piece in row:
851
+ data.append(piece)
852
+
853
+ csv_writer_object.writerow(data)
854
+
855
+ csv_buffer.seek(0)
856
+ return csv_buffer.read().replace('\r\n', '\n')
857
+
858
+ def get_json(self, serialize: bool = True) -> Union[dict, str]:
859
+ """ Returns the loop in JSON format. If serialize is set to
860
+ False a dictionary representation of the loop that is
861
+ serializeable is returned."""
862
+
863
+ loop_dict = {
864
+ "category": self.category,
865
+ "tags": self._tags,
866
+ "data": self.data
867
+ }
868
+
869
+ if serialize:
870
+ return json.dumps(loop_dict, default=_json_serialize)
871
+ else:
872
+ return loop_dict
873
+
874
+ def get_tag_names(self) -> List[str]:
875
+ """ Return the tag names for this entry with the category
876
+ included. Throws ValueError if the category was never set.
877
+
878
+ To get the tags without the category, just access them directly
879
+ using the "tags" attribute.
880
+
881
+ To fetch tag values use get_tag()."""
882
+
883
+ if not self.category:
884
+ raise InvalidStateError("You never set the category of this loop. You must set the category before calling "
885
+ "this method, either by setting the loop category directly when creating the loop "
886
+ "using the Loop.from_scratch() class method, by calling loop.set_category(), or by "
887
+ "adding a fully qualified tag which includes the loop category (for example, "
888
+ "adding '_Citation_author.Family_name' rather than just 'Family_name').")
889
+
890
+ return [self.category + "." + x for x in self._tags]
891
+
892
+ def get_tag(self,
893
+ tags: Optional[Union[str, List[str]]] = None,
894
+ whole_tag: bool = False,
895
+ dict_result: bool = False) -> Union[List[Any], List[Dict[str, Any]]]:
896
+ """Provided a tag name (or a list of tag names) return the selected tags by row as
897
+ a list of lists. Leave tags unset to fetch all tags.
898
+
899
+ If whole_tag=True return the full tag name along with the tag
900
+ value, or if dict_result=True, as the tag key.
901
+
902
+ If dict_result=True, return the tags as a list of dictionaries
903
+ in which the tag value points to the tag. Uses the specified capitalization
904
+ of the tag unless whole_tag is True, in which case it will use the capitalization
905
+ found in the loop."""
906
+
907
+ # All tags
908
+ if tags is None:
909
+ if not dict_result:
910
+ return self.data
911
+ else:
912
+ tags = self._tags
913
+ # Turn single elements into lists
914
+ if not isinstance(tags, list):
915
+ tags = [tags]
916
+
917
+ # Make a copy of the tags to fetch - don't modify the
918
+ # list that was passed
919
+ lower_tags = deepcopy(tags)
920
+
921
+ # Strip the category if they provide it (also validate
922
+ # it during the process)
923
+ for pos, item in enumerate([str(x) for x in lower_tags]):
924
+ if "." in item and utils.format_category(item).lower() != self.category.lower():
925
+ raise ValueError(f"Cannot fetch data with tag '{item}' because the category does not match the "
926
+ f"category of this loop '{self.category}'.")
927
+ lower_tags[pos] = utils.format_tag_lc(item)
928
+
929
+ # Make a lower case copy of the tags
930
+ tags_lower = [x.lower() for x in self._tags]
931
+
932
+ # Map tag name to tag position in list
933
+ tag_mapping = dict(zip(reversed(tags_lower), reversed(range(len(tags_lower)))))
934
+
935
+ # Make sure their fields are actually present in the entry
936
+ tag_ids = []
937
+ for pos, query in enumerate(lower_tags):
938
+ if str(query) in tag_mapping:
939
+ tag_ids.append(tag_mapping[query])
940
+ elif isinstance(query, int):
941
+ tag_ids.append(query)
942
+ else:
943
+ raise KeyError(f"Could not locate the tag with name or ID: '{tags[pos]}' in loop '{self.category}'.")
944
+
945
+ # First build the tags as a list
946
+ if not dict_result:
947
+
948
+ # Use a list comprehension to pull the correct tags out of the rows
949
+ if whole_tag:
950
+ result = [[[self.category + "." + self._tags[col_id], row[col_id]]
951
+ for col_id in tag_ids] for row in self.data]
952
+ else:
953
+ result = [[row[col_id] for col_id in tag_ids] for row in self.data]
954
+
955
+ # Strip the extra list if only one tag
956
+ if len(lower_tags) == 1:
957
+ return [x[0] for x in result]
958
+ else:
959
+ return result
960
+ # Make a dictionary
961
+ else:
962
+ if whole_tag:
963
+ result = [dict((self.category + "." + self._tags[col_id], row[col_id]) for col_id in tag_ids) for
964
+ row in self.data]
965
+ else:
966
+ result = [dict((tags[pos], row[col_id]) for pos, col_id in enumerate(tag_ids)) for row in self.data]
967
+
968
+ return result
969
+
970
+ def print_tree(self) -> None:
971
+ """Prints a summary, tree style, of the loop."""
972
+
973
+ print(repr(self))
974
+
975
+ def remove_data_by_tag_value(self, tag: str, value: Any, index_tag: str = None) -> List[List[Any]]:
976
+ """Removes all rows which contain the provided value in the
977
+ provided tag name. If index_tag is provided, that tag is
978
+ renumbered starting with 1. Returns the deleted rows."""
979
+
980
+ # Make sure the category matches - if provided
981
+ if "." in tag:
982
+ supplied_category = utils.format_category(str(tag))
983
+ if supplied_category.lower() != self.category.lower():
984
+ raise ValueError(f"The category provided in your tag '{supplied_category}' does not match this loop's "
985
+ f"category '{self.category}'.")
986
+
987
+ search_tag = self.tag_index(tag)
988
+ if search_tag is None:
989
+ raise ValueError(f"The tag you provided '{tag}' isn't in this loop!")
990
+
991
+ deleted = []
992
+
993
+ # Delete all rows in which the user-provided tag matched
994
+ cur_row = 0
995
+ while cur_row < len(self.data):
996
+ if self.data[cur_row][search_tag] == value:
997
+ deleted.append(self.data.pop(cur_row))
998
+ continue
999
+ cur_row += 1
1000
+
1001
+ # Re-number if they so desire
1002
+ if index_tag is not None:
1003
+ self.renumber_rows(index_tag)
1004
+
1005
+ return deleted
1006
+
1007
+ def remove_tag(self, tag: Union[str, List[str]]) -> None:
1008
+ """Removes one or more tags from the loop based on tag name. Also removes any data for the given tag.
1009
+ Provide either a tag or list of tags."""
1010
+
1011
+ if not isinstance(tag, list):
1012
+ tag = [tag]
1013
+
1014
+ # Check if the tags exist first
1015
+ for each_tag in tag:
1016
+ if self.tag_index(each_tag) is None:
1017
+ raise KeyError(f"There is no tag with name '{each_tag}' to remove in loop '{self.category}'.")
1018
+
1019
+ # Calculate the tag position each time, because it will change as the previous tag is deleted
1020
+ for each_tag in tag:
1021
+ tag_position: int = self.tag_index(each_tag)
1022
+ del self._tags[tag_position]
1023
+ for row in self.data:
1024
+ del row[tag_position]
1025
+
1026
+ def renumber_rows(self, index_tag: str, start_value: int = 1, maintain_ordering: bool = False):
1027
+ """Renumber a given tag incrementally. Set start_value to
1028
+ initial value if 1 is not acceptable. Set maintain_ordering to
1029
+ preserve sequence with offset.
1030
+
1031
+ E.g. 2,3,3,5 would become 1,2,2,4."""
1032
+
1033
+ # Make sure the category matches
1034
+ if "." in str(index_tag):
1035
+ supplied_category = utils.format_category(str(index_tag))
1036
+ if supplied_category.lower() != self.category.lower():
1037
+ raise ValueError(f"Category provided in your tag '{supplied_category}' does not match this loop's "
1038
+ f"category '{self.category}'.")
1039
+
1040
+ # Determine which tag ID to renumber
1041
+ renumber_tag = self.tag_index(index_tag)
1042
+
1043
+ # The tag to replace in is the tag they specify
1044
+ if renumber_tag is None:
1045
+ # Or, perhaps they specified an integer to represent the tag?
1046
+ try:
1047
+ renumber_tag = int(index_tag)
1048
+ except ValueError:
1049
+ raise ValueError(f"The renumbering tag you provided '{index_tag}' isn't in this loop!")
1050
+
1051
+ # Do nothing if we have no data
1052
+ if len(self.data) == 0:
1053
+ return
1054
+
1055
+ # Make sure the tags and data match
1056
+ self._check_tags_match_data()
1057
+
1058
+ if maintain_ordering:
1059
+ # If they have a string buried somewhere in the row, we'll
1060
+ # have to restore the original values
1061
+ data_copy = deepcopy(self.data)
1062
+ offset = 0
1063
+ for pos in range(0, len(self.data)):
1064
+ try:
1065
+ if pos == 0:
1066
+ offset = start_value - int(self.data[0][renumber_tag])
1067
+ new_data = int(self.data[pos][renumber_tag]) + offset
1068
+
1069
+ if isinstance(self.data[pos][renumber_tag], str):
1070
+ self.data[pos][renumber_tag] = str(new_data)
1071
+ else:
1072
+ self.data[pos][renumber_tag] = new_data
1073
+ except ValueError:
1074
+ self.data = data_copy
1075
+ raise ValueError("You can't renumber a row containing anything that can't be coerced into an "
1076
+ "integer using maintain_ordering. I.e. what am I suppose to renumber "
1077
+ f"'{self.data[pos][renumber_tag]}' to?")
1078
+
1079
+ # Simple renumbering algorithm if we don't need to maintain the ordering
1080
+ else:
1081
+ for pos in range(0, len(self.data)):
1082
+ if isinstance(self.data[pos][renumber_tag], str):
1083
+ self.data[pos][renumber_tag] = str(pos + start_value)
1084
+ else:
1085
+ self.data[pos][renumber_tag] = pos + start_value
1086
+
1087
+ def set_category(self, category: str) -> None:
1088
+ """ Set the category of the loop. Useful if you didn't know the
1089
+ category at loop creation time."""
1090
+
1091
+ self.category = utils.format_category(category)
1092
+
1093
+ def sort_tags(self, schema: Schema = None) -> None:
1094
+ """ Rearranges the tag names and data in the loop to match the order
1095
+ from the schema. Uses the BMRB schema unless one is provided."""
1096
+
1097
+ schema = utils.get_schema(schema)
1098
+ current_order = self.get_tag_names()
1099
+
1100
+ # Sort the tags
1101
+ def sort_key(_) -> int:
1102
+ return schema.tag_key(_)
1103
+
1104
+ sorted_order = sorted(current_order, key=sort_key)
1105
+
1106
+ # Don't touch the data if the tags are already in order
1107
+ if sorted_order == current_order:
1108
+ return
1109
+ else:
1110
+ self.data = self.get_tag(sorted_order)
1111
+ self._tags = [utils.format_tag(x) for x in sorted_order]
1112
+
1113
+ def sort_rows(self, tags: Union[str, List[str]], key: Callable = None) -> None:
1114
+ """ Sort the data in the rows by their values for a given tag
1115
+ or tags. Specify the tags using their names or ordinals.
1116
+ Accepts a list or an int/float. By default we will sort
1117
+ numerically. If that fails we do a string sort. Supply a
1118
+ function as key and we will order the elements based on the
1119
+ keys it provides. See the help for sorted() for more details. If
1120
+ you provide multiple tags to sort by, they are interpreted as
1121
+ increasing order of sort priority."""
1122
+
1123
+ # Do nothing if we have no data
1124
+ if len(self.data) == 0:
1125
+ return
1126
+
1127
+ # This will determine how we sort
1128
+ sort_ordinals = []
1129
+
1130
+ if isinstance(tags, list):
1131
+ processing_list = tags
1132
+ else:
1133
+ processing_list = [tags]
1134
+
1135
+ # Process their input to determine which tags to operate on
1136
+ for cur_tag in [str(x) for x in processing_list]:
1137
+
1138
+ # Make sure the category matches
1139
+ if "." in cur_tag:
1140
+ supplied_category = utils.format_category(cur_tag)
1141
+ if supplied_category.lower() != self.category.lower():
1142
+ raise ValueError(f"The category provided in your tag '{supplied_category}' does not match this "
1143
+ f"loop's category '{self.category}'.")
1144
+
1145
+ renumber_tag = self.tag_index(cur_tag)
1146
+
1147
+ # They didn't specify a valid tag
1148
+ if renumber_tag is None:
1149
+ # Perhaps they specified an integer to represent the tag?
1150
+ try:
1151
+ renumber_tag = int(cur_tag)
1152
+ except ValueError:
1153
+ raise ValueError(f"The sorting tag you provided '{cur_tag}' isn't in this loop!")
1154
+
1155
+ sort_ordinals.append(renumber_tag)
1156
+
1157
+ # Do the sort(s)
1158
+ for tag in sort_ordinals:
1159
+ # Going through each tag, first attempt to sort as integer.
1160
+ # Then fallback to string sort.
1161
+ try:
1162
+ if key is None:
1163
+ tmp_data = sorted(self.data, key=lambda _, pos=tag: float(_[pos]))
1164
+ else:
1165
+ tmp_data = sorted(self.data, key=key)
1166
+ except ValueError:
1167
+ if key is None:
1168
+ tmp_data = sorted(self.data, key=lambda _, pos=tag: _[pos])
1169
+ else:
1170
+ tmp_data = sorted(self.data, key=key)
1171
+ self.data = tmp_data
1172
+
1173
+ def tag_index(self, tag_name: str) -> Optional[int]:
1174
+ """ Helper method to do a case-insensitive check for the presence
1175
+ of a given tag in this loop. Returns the index of the tag if found
1176
+ and None if not found.
1177
+
1178
+ This is useful if you need to get the index of a certain tag to
1179
+ iterate through the data and modify it."""
1180
+
1181
+ try:
1182
+ return self._lc_tags[utils.format_tag_lc(str(tag_name))]
1183
+ except KeyError:
1184
+ return None
1185
+
1186
+ def validate(self, validate_schema: bool = True, schema: 'Schema' = None,
1187
+ validate_star: bool = True, category: str = None) -> List[str]:
1188
+ """Validate a loop in a variety of ways. Returns a list of
1189
+ errors found. 0-length list indicates no errors found. By
1190
+ default all validation modes are enabled.
1191
+
1192
+ validate_schema - Determines if the entry is validated against
1193
+ the NMR-STAR schema. You can pass your own custom schema if desired,
1194
+ otherwise the schema will be fetched from the BMRB servers.
1195
+
1196
+ validate_star - Determines if the STAR syntax checks are ran."""
1197
+
1198
+ errors = []
1199
+
1200
+ if validate_schema:
1201
+ # Get the default schema if we are not passed a schema
1202
+ my_schema = utils.get_schema(schema)
1203
+
1204
+ # Check the data
1205
+ for row_num, row in enumerate(self.data):
1206
+ for pos, datum in enumerate(row):
1207
+ errors.extend(my_schema.val_type(f"{self.category}.{self._tags[pos]}", datum, category=category))
1208
+
1209
+ if validate_star:
1210
+ # Check for wrong data size
1211
+ num_cols = len(self._tags)
1212
+ for row_num, row in enumerate(self.data):
1213
+ # Make sure the width matches
1214
+ if len(row) != num_cols:
1215
+ errors.append(f"Loop '{self.category}' data width does not match it's tag width on "
1216
+ f"row '{row_num}'.")
1217
+
1218
+ return errors