"""Parsing for the indexed_range argument syntax. An indexed_range selects positions in one dimension of a grid, with an optional extent in the other dimension. The same syntax serves the table klammer's :hline and :vline arguments (index = boundary, subsets = how far along the line) and its :colspan and :rowspan arguments (index = row or column, subsets = the cells to merge). Which dimension the index selects is a property of the argument, not of the syntax. This module replaces the former sequences.py and span.py (see debris). """ import re syntax_description = """ An indexed_range is a selector, optionally followed by parenthesized subsets, written with no spaces: full extent () restricted extent The selector is a single index "3", a closed index range "2-5", an open index range "2-" (to the last index), or a name defined by the argument (for example "top" or "inner" for table lines). Subsets are separated by commas; each is an index "4", a closed range "1-4", or an open range "6-" (to the end). All indices are zero-origin. Examples: 3 index 3, full extent 2-5(0-2) indices 2 through 5, each restricted to 0 through 2 3(1-4,6-9) index 3, restricted to 1-4 and 6-9 head(1-) with table hline names: boundary 1, from column 1 on """.strip() class Range_error(Exception): def __init__(self, message): super().__init__(f"{message}\n\n{syntax_description}") item_rgx = re.compile(r"(?:(\d+)(-)?(\d*)|([A-Za-z]+))(?:\(([\d,\-]+)\))?$") subset_rgx = re.compile(r"(\d+)(-)?(\d*)$") def hline_names(count): """Boundary-name map for horizontal lines; count = row_count + 1.""" last = count - 1 return {"top": [0], "head": [1], "bottom": [last], "inner": list(range(1, last)), "all": list(range(count))} def vline_names(count): """Boundary-name map for vertical lines; count = column_count + 1.""" last = count - 1 return {"outer": [0, last], "inner": list(range(1, last)), "all": list(range(count))} class Indexed_range: """The selected extent for one primary-dimension index.""" def __init__(self, index, maxval): self.index = index self.maxval = maxval self.all = False # Full extent (no subsets given) self.ranges = [] # [[start, end], ...], inclusive def add_full(self): self.all = True self.ranges = [[0, self.maxval]] def add_ranges(self, ranges): if not self.all: self.ranges += ranges def has(self, i): return any(start <= i <= end for start, end in self.ranges) def items(self, invert=False): result = [] for start, end in self.ranges: for e in range(start, end + 1): result.append((e, self.index) if invert else (self.index, e)) return result def __str__(self): subsets = ",".join([f"{s}-{e}" for s, e in self.ranges]) return f"{self.index}({subsets})" def __repr__(self): return self.__str__() class Indexed_ranges: """A parsed indexed_range argument: Indexed_range entries by index. count - number of valid primary indices (0 .. count-1) maxval - largest valid subset value (the cross dimension) specs - the argument value: a list of items (from the argtype's python_cast), a whitespace-separated string, or None names - map of selector names to index lists (hline_names, ...) argument - argument name for error messages (":hline", ...) Items targeting the same index merge: their subsets are unioned, and a full-extent item absorbs any subsets. """ def __init__(self, count, maxval, specs, names=None, argument=""): self.count = count self.maxval = maxval self.names = names or {} self.argument = argument self.by_index = {} if specs is None: specs = [] elif isinstance(specs, str): specs = specs.split() for spec in specs: self.parse(spec) def error(self, message): argument = f"{self.argument} argument: " if self.argument else "" raise Range_error(f"{argument}{message}") def parse(self, spec): match = item_rgx.match(spec) if not match: self.error(f'"{spec}" is not a valid indexed_range.') number, hyphen, end, name, subsets = match.groups() if name is not None: if name not in self.names: known = " ".join(self.names) or "none" self.error(f'"{name}" is not a valid name here ' f"(valid names: {known}).") indices = self.names[name] else: start = int(number) if not hyphen: indices = [start] else: last = int(end) if end else self.count - 1 if start > last: self.error(f'In "{spec}", the index range start {start} ' f"is greater than its end {last}.") indices = list(range(start, last + 1)) for i in indices: if i >= self.count: self.error(f'In "{spec}", index {i} is out of range ' f"(0 through {self.count - 1}).") ranges = self.parse_subsets(spec, subsets) if subsets else None for i in indices: entry = self.by_index.setdefault(i, Indexed_range(i, self.maxval)) if ranges is None: entry.add_full() else: entry.add_ranges(ranges) def parse_subsets(self, spec, subsets): ranges = [] for part in subsets.split(","): match = subset_rgx.match(part) if not match: self.error(f'In "{spec}", "{part}" is not a valid subset.') number, hyphen, end = match.groups() start = int(number) if not hyphen: last = start else: last = int(end) if end else self.maxval if start > last: self.error(f'In "{spec}", the subset start {start} ' f"is greater than its end {last}.") if last > self.maxval: self.error(f'In "{spec}", {last} is out of range ' f"(0 through {self.maxval}).") ranges.append([start, last]) return ranges def __getitem__(self, index): return self.by_index.get(index) def __iter__(self): return iter(self.by_index) def has(self, index, i): entry = self[index] return entry.has(i) if entry else False def __str__(self): return " ".join([str(self.by_index[i]) for i in sorted(self.by_index)]) def __repr__(self): return self.__str__()