Coverage for picos/formatting.py: 83.73%
209 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-26 07:46 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-26 07:46 +0000
1# ------------------------------------------------------------------------------
2# Copyright (C) 2019 Maximilian Stahlberg
3#
4# This file is part of PICOS.
5#
6# PICOS is free software: you can redistribute it and/or modify it under the
7# terms of the GNU General Public License as published by the Free Software
8# Foundation, either version 3 of the License, or (at your option) any later
9# version.
10#
11# PICOS is distributed in the hope that it will be useful, but WITHOUT ANY
12# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
13# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License along with
16# this program. If not, see <http://www.gnu.org/licenses/>.
17# ------------------------------------------------------------------------------
19"""Console output helpers related to formatting."""
21import re
22import sys
23from contextlib import contextmanager
24from math import ceil, floor
26from . import glyphs
27from .apidoc import api_end, api_start
29_API_START = api_start(globals())
30# -------------------------------
33HEADER_WIDTH = 35 #: Character length of headers and footers printed by PICOS.
36def print_header(title, subtitle=None, symbol="-", width=HEADER_WIDTH):
37 """Print a header line."""
38 w = "{:d}".format(width)
40 print(("{0}\n{1:^"+w+"}\n{2}{0}").format(symbol * width, title,
41 ("{:^"+w+"}\n").format("{}".format(subtitle)) if subtitle else ""))
42 sys.stdout.flush()
45def print_footer(caption, symbol="=", width=HEADER_WIDTH):
46 """Print a footer line."""
47 middle = "[ {} ]".format(caption)
49 if width < len(middle) + 2:
50 footer = symbol * width
51 else:
52 s = (width - len(middle))
53 l = int(floor(s / 2.0))
54 r = int(ceil(s / 2.0))
55 footer = symbol * l + middle + symbol * r
57 print(footer)
58 sys.stdout.flush()
61@contextmanager
62def box(title, caption, subtitle=None, symbol="-", width=HEADER_WIDTH,
63 show=True):
64 """Print both a header above and a footer below the context.
66 :param str title: The (long) main title printed at the top.
67 :param str caption: The (short) caption printed at the bottom.
68 :param str subtitle: The (long) subtitle printed at the top.
69 :param str symbol: A single character used to draw lines.
70 :param int width: The width of the box.
71 :param bool show: Whether anything should be printed at all.
72 """
73 if show:
74 print_header(title, subtitle, symbol, width)
75 yield
76 print_footer(caption, symbol, width)
77 else:
78 yield
81@contextmanager
82def picos_box(show=True):
83 """Print a PICOS header above and a PICOS footer below the context."""
84 from .__init__ import __version__ as picosVer
85 with box("PICOS {}".format(picosVer), "PICOS", symbol="=", show=show):
86 yield
89@contextmanager
90def solver_box(longName, shortName, subSolver=None, show=True):
91 """Print a solver header above and a solver footer below the context."""
92 subtitle = "via {}".format(subSolver) if subSolver else None
93 with box(longName, shortName, subtitle, show=show):
94 yield
97def doc_cat(docstring, append):
98 """Append to a docstring."""
99 if not docstring:
100 raise ValueError("Empty base docstring.")
102 if not append:
103 return docstring
105 lines = [line for line in docstring.splitlines() if line.strip()]
106 i = len(lines[1]) - len(lines[1].lstrip()) if len(lines) > 1 else 0
108 append = "\n".join(" "*i + line for line in append.splitlines())
110 return docstring.rstrip() + "\n\n" + append
113def detect_range(sequence, asQuadruple=False, asStringTemplate=False,
114 shortString=False):
115 """Return a Python range mirroring the given integer sequence.
117 :param sequence: An integer sequence that can be mirrored by a Python range.
118 :param bool asQuadruple: Whether to return a quadruple with factor, inner
119 shift, outer shift, and length, formally ``(a, i, o, n)`` such that
120 ``[a*(x+i)+o for x in range(n)]`` mirrors the input sequence.
121 :param bool asStringTemplate: Whether to return a format string that, if
122 instanciated with numbers from ``0`` to ``len(sequence) - 1``, yields
123 math expression strings that describe the input sequence members.
124 :param bool shortString: Whether to return condensed string templates that
125 are designed to be instanciated with an index character string. Requires
126 asStringTemplate to be ``True``.
127 :raises TypeError: If the input is not an integer sequence.
128 :raises ValueError: If the input cannot be mirrored by a Python range.
129 :returns: A range object, a quadruple of numbers, or a format string.
131 :Example:
133 >>> from picos.formatting import detect_range as dr
134 >>> R = range(7,30,5)
135 >>> S = list(R)
136 >>> S
137 [7, 12, 17, 22, 27]
138 >>> # By default, returns a matching range object:
139 >>> dr(S)
140 range(7, 28, 5)
141 >>> dr(S) == R
142 True
143 >>> # Sequence elements can also be decomposed w.r.t. range(len(S)):
144 >>> a, i, o, n = dr(S, asQuadruple=True)
145 >>> [a*(x+i)+o for x in range(n)] == S
146 True
147 >>> # The same decomposition can be returned in a string representation:
148 >>> dr(S, asStringTemplate=True)
149 '5·({} + 1) + 2'
150 >>> # Short string representations are designed to accept index names:
151 >>> dr(S, asStringTemplate=True, shortString=True).format("i")
152 '5(i+1)+2'
153 >>> dr(range(0,100,5), asStringTemplate=True, shortString=True).format("i")
154 '5i'
155 >>> dr(range(10,100), asStringTemplate=True, shortString=True).format("i")
156 'i+10'
158 :Example:
160 >>> # This works with decreasing ranges as well.
161 >>> R2 = range(10,4,-2)
162 >>> S2 = list(R2)
163 >>> S2
164 [10, 8, 6]
165 >>> dr(S2)
166 range(10, 5, -2)
167 >>> dr(S2) == R2
168 True
169 >>> a, i, o, n = dr(S2, asQuadruple=True)
170 >>> [a*(x+i)+o for x in range(n)] == S2
171 True
172 >>> T = dr(S2, asStringTemplate=True, shortString=True)
173 >>> [T.format(i) for i in range(len(S2))]
174 ['-2(0-5)', '-2(1-5)', '-2(2-5)']
175 """
176 if asQuadruple and asStringTemplate:
177 raise ValueError(
178 "Can only return a quadruple or a string template, not both.")
180 if shortString and not asStringTemplate:
181 raise ValueError("Enabling 'shortString' requires 'asStringTemplate'.")
183 if len(sequence) == 0:
184 if asQuadruple:
185 return 0, 0, 0, 0
186 elif asStringTemplate:
187 return ""
188 else:
189 return range(0)
191 first = sequence[0]
192 last = sequence[-1]
193 next = last + (1 if first <= last else -1)
194 length = len(sequence)
196 if not isinstance(first, int) or not isinstance(last, int):
197 raise TypeError("Not an integer container.")
199 # Determine potential integer step size.
200 if length > 1:
201 step = (last - first) / (length - 1)
202 else:
203 step = 1
204 if int(step) != step:
205 raise ValueError("Cannot be mirrored by a Python range.")
206 step = int(step)
208 # Determine potential range.
209 range_ = range(first, next, step)
211 if len(range_) != len(sequence):
212 raise ValueError("Cannot be mirrored by a Python range.")
214 for position, number in enumerate(range_):
215 if sequence[position] != number:
216 raise ValueError("Cannot be mirrored by a Python range.")
218 if asQuadruple or asStringTemplate:
219 # Compute inner and outer shift.
220 innerShift = first // step
221 outerShift = first % step
223 # Verify our finding.
224 assert last // step + 1 - innerShift == length
225 assert step*(0 + innerShift) + outerShift == first
226 assert step*((length - 1) + innerShift) + outerShift == last
228 if asQuadruple:
229 return step, innerShift, outerShift, length
230 elif shortString:
231 string = "{{}}{:+d}".format(innerShift) if innerShift else "{}"
232 if step != 1 and innerShift:
233 string = "{}({})".format("-" if step == -1 else step, string)
234 elif step != 1:
235 string = "{}{}".format("-" if step == -1 else step, string)
236 string = "{}{:+d}".format(string, outerShift) \
237 if outerShift else string
238 # TODO: Something like the following is needed in case replacement
239 # happens for a factor in a multiplication.
240 # if (innerShift and step == 1) or outerShift:
241 # string = "({})".format(string)
243 return string
244 else:
245 glyph = glyphs.add("{}", innerShift) if innerShift else "{}"
246 glyph = glyphs.mul(step, glyph) if step != 1 else glyph
247 glyph = glyphs.add(glyph, outerShift) if outerShift else glyph
249 return glyph
250 else:
251 return range_
254def natsorted(strings, key=None):
255 """Sort the given list of strings naturally with respect to numbers."""
256 def natsplit(string):
257 parts = re.split(r"([+-]?\d*\.?\d+)", string)
258 split = []
259 for part in parts:
260 try:
261 split.append(float(part))
262 except ValueError:
263 split.append(part)
264 return split
266 if key:
267 return sorted(strings, key=lambda x: natsplit(key(x)))
268 else:
269 return sorted(strings, key=natsplit)
272def parameterized_string(
273 strings, replace=r"-?\d+", context=r"\W", placeholders="ijklpqr",
274 fallback="?"):
275 """Find a string template for the given (algebraic) strings.
277 Given a list of strings with similar structure, finds a single string with
278 placeholders and an expression that denotes how to instantiate the
279 placeholders in order to obtain each string in the list.
281 The function is designed to take a number of symbolic string representations
282 of math expressions that differ only with respect to indices.
284 :param list strings:
285 The list of strings to compare.
286 :param str replace:
287 A regular expression describing the bits to replace with placeholders.
288 :param str context:
289 A regular expression describing context characters that need to be
290 present next to the bits to be replaced with placeholders.
291 :param placeholders:
292 An iterable of placeholder strings. Usually a string, so that each of
293 its characters becomes a placeholder.
294 :param str fallback:
295 A fallback placeholder string, if the given placeholders are not
296 sufficient.
298 :returns:
299 A tuple of two strings, the first being the template string and the
300 second being a description of the placeholders used.
302 :Example:
304 >>> from picos.formatting import parameterized_string as ps
305 >>> ps(["A[{}]".format(i) for i in range(5, 31)])
306 ('A[i+5]', 'i ∈ [0…25]')
307 >>> ps(["A[{}]".format(i) for i in range(5, 31, 5)])
308 ('A[5(i+1)]', 'i ∈ [0…5]')
309 >>> S=["A[0]·B[2]·C[3]·D[5]·F[0]",
310 ... "A[1]·B[1]·C[6]·D[6]·F[0]",
311 ... "A[2]·B[0]·C[9]·D[9]·F[0]"]
312 >>> ps(S)
313 ('A[i]·B[-(i-2)]·C[3(i+1)]·D[j]·F[0]', '(i,j) ∈ zip([0…2],[5,6,9])')
314 """
315 if len(strings) == 0:
316 return "", ""
317 elif len(strings) == 1:
318 return strings[0], ""
320 for string in strings:
321 if not isinstance(string, str):
322 raise TypeError("First argument must be a list of strings.")
324 def split_with_context(string, match, context):
325 pattern = r"(?<={0})?{1}(?={0})?".format(context, match)
326 return re.split(pattern, string)
328 def findall_with_context(string, match, context):
329 pattern = r"(?<={0})?{1}(?={0})?".format(context, match)
330 return re.findall(pattern, string)
332 # The skeleton of a string is the part not matched by 'replace' and
333 # surrounded by 'context', and it must be the same for all strings.
334 skeleton = split_with_context(strings[0], replace, context)
335 for string in strings[1:]:
336 if skeleton != split_with_context(string, replace, context):
337 raise ValueError("Strings do not have a common skeleton.")
339 # The slots are the parts that are matched by 'replace' and surrounded by
340 # 'context' and should be filled with the placeholders.
341 slotToValues = []
342 for string in strings:
343 slotToValues.append(findall_with_context(string, replace, context))
344 slotToValues = list(zip(*slotToValues))
346 # Verify that slots are always surrounded by (empty) skeleton strings.
347 assert len(skeleton) == len(slotToValues) + 1
349 # Find slots with the same value in each string; add them to the skeleton.
350 for slot in range(len(slotToValues)):
351 if len(set(slotToValues[slot])) == 1:
352 skeleton[slot + 1] =\
353 skeleton[slot] + slotToValues[slot][0] + skeleton[slot + 1]
354 skeleton[slot] = None
355 slotToValues[slot] = None
356 skeleton = [s for s in skeleton if s is not None]
357 slotToValues = [v for v in slotToValues if v is not None]
359 # We next build a mapping from slots to (few) placeholder indices.
360 slotToIndex = {}
361 nextIndex = 0
363 # Find slots whose values form a range, and build string templates that lift
364 # a placeholder to a formula denoting sequence elements (e.g. "i" → "2i+1").
365 # All such slots share the first placeholder (with index 0).
366 slotToTemplate = {}
367 for slot, values in enumerate(slotToValues):
368 try:
369 slotToTemplate[slot] = detect_range([int(v) for v in values],
370 asStringTemplate=True, shortString=True)
371 except ValueError:
372 pass
373 else:
374 slotToIndex[slot] = 0
375 nextIndex = 1
377 # Find slots with identical value in each string and assign them the same
378 # placeholder.
379 valsToIndex = {}
380 for slot, values in enumerate(slotToValues):
381 if slot in slotToIndex:
382 # The slot holds a range.
383 continue
385 if values in valsToIndex:
386 slotToIndex[slot] = valsToIndex[values]
387 else:
388 slotToIndex[slot] = nextIndex
389 valsToIndex[values] = nextIndex
390 nextIndex += 1
392 # Define a function that maps slots to their placeholder symbols.
393 def placeholder(slot):
394 index = slotToIndex[slot]
395 return placeholders[index] if index < len(placeholders) else fallback
397 # Assemble the string template (with values replaced by placeholders).
398 template = ""
399 for slot in range(len(slotToIndex)):
400 if slot in slotToTemplate:
401 ph = slotToTemplate[slot].format(placeholder(slot))
402 else:
403 ph = placeholder(slot)
405 template += skeleton[slot] + ph
406 template += skeleton[-1]
408 # Collect the placeholdes that were used, and their domains.
409 usedPHs, domains = [], []
410 indices = set()
411 for slot, index in slotToIndex.items():
412 values = slotToValues[slot]
414 if index in indices:
415 continue
416 else:
417 indices.add(index)
419 usedPHs.append(placeholder(slot))
421 if slot in slotToTemplate:
422 domains.append(glyphs.intrange(0, len(values) - 1))
423 elif len(values) > 4:
424 domains.append(glyphs.intrange(
425 ",".join(values[:2]) + ",", "," + ",".join(values[-2:])))
426 else:
427 domains.append(glyphs.interval(",".join(values)))
429 # Make sure used placeholders and their domains match in number.
430 assert len(usedPHs) == len(domains)
432 # Assemble occuring placeholders and ther joint domain (the data).
433 if len(domains) == 0:
434 data = ""
435 else:
436 if len(domains) == 1:
437 usedPHs = usedPHs[0]
438 domain = domains[0]
439 else:
440 usedPHs = "({})".format(",".join(usedPHs))
441 domain = "zip({})".format(",".join(domains))
443 data = glyphs.element(usedPHs, domain)
445 return template, data
448def arguments(strings, sep=", ", empty=""):
449 """A wrapper around :func:`parameterized_string` for argument lists.
451 :param list(str) strings:
452 String descriptions of the arguments.
454 :param str sep:
455 Separator.
457 :param str empty:
458 Representation of an empty argument list.
459 """
460 if len(strings) == 0:
461 return empty
462 elif len(strings) == 1:
463 return strings[0]
464 elif len(strings) == 2:
465 return strings[0] + sep + strings[1]
467 try:
468 template, data = parameterized_string(strings)
469 except ValueError:
470 pass
471 else:
472 if data:
473 return glyphs.sep(template, data)
475 return glyphs.fromto(strings[0] + sep, sep + strings[-1])
478# --------------------------------------
479__all__ = api_end(_API_START, globals())