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

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# ------------------------------------------------------------------------------ 

18 

19"""Console output helpers related to formatting.""" 

20 

21import re 

22import sys 

23from contextlib import contextmanager 

24from math import ceil, floor 

25 

26from . import glyphs 

27from .apidoc import api_end, api_start 

28 

29_API_START = api_start(globals()) 

30# ------------------------------- 

31 

32 

33HEADER_WIDTH = 35 #: Character length of headers and footers printed by PICOS. 

34 

35 

36def print_header(title, subtitle=None, symbol="-", width=HEADER_WIDTH): 

37 """Print a header line.""" 

38 w = "{:d}".format(width) 

39 

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() 

43 

44 

45def print_footer(caption, symbol="=", width=HEADER_WIDTH): 

46 """Print a footer line.""" 

47 middle = "[ {} ]".format(caption) 

48 

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 

56 

57 print(footer) 

58 sys.stdout.flush() 

59 

60 

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. 

65 

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 

79 

80 

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 

87 

88 

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 

95 

96 

97def doc_cat(docstring, append): 

98 """Append to a docstring.""" 

99 if not docstring: 

100 raise ValueError("Empty base docstring.") 

101 

102 if not append: 

103 return docstring 

104 

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 

107 

108 append = "\n".join(" "*i + line for line in append.splitlines()) 

109 

110 return docstring.rstrip() + "\n\n" + append 

111 

112 

113def detect_range(sequence, asQuadruple=False, asStringTemplate=False, 

114 shortString=False): 

115 """Return a Python range mirroring the given integer sequence. 

116 

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. 

130 

131 :Example: 

132 

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' 

157 

158 :Example: 

159 

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.") 

179 

180 if shortString and not asStringTemplate: 

181 raise ValueError("Enabling 'shortString' requires 'asStringTemplate'.") 

182 

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) 

190 

191 first = sequence[0] 

192 last = sequence[-1] 

193 next = last + (1 if first <= last else -1) 

194 length = len(sequence) 

195 

196 if not isinstance(first, int) or not isinstance(last, int): 

197 raise TypeError("Not an integer container.") 

198 

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) 

207 

208 # Determine potential range. 

209 range_ = range(first, next, step) 

210 

211 if len(range_) != len(sequence): 

212 raise ValueError("Cannot be mirrored by a Python range.") 

213 

214 for position, number in enumerate(range_): 

215 if sequence[position] != number: 

216 raise ValueError("Cannot be mirrored by a Python range.") 

217 

218 if asQuadruple or asStringTemplate: 

219 # Compute inner and outer shift. 

220 innerShift = first // step 

221 outerShift = first % step 

222 

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 

227 

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) 

242 

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 

248 

249 return glyph 

250 else: 

251 return range_ 

252 

253 

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 

265 

266 if key: 

267 return sorted(strings, key=lambda x: natsplit(key(x))) 

268 else: 

269 return sorted(strings, key=natsplit) 

270 

271 

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. 

276 

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. 

280 

281 The function is designed to take a number of symbolic string representations 

282 of math expressions that differ only with respect to indices. 

283 

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. 

297 

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. 

301 

302 :Example: 

303 

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], "" 

319 

320 for string in strings: 

321 if not isinstance(string, str): 

322 raise TypeError("First argument must be a list of strings.") 

323 

324 def split_with_context(string, match, context): 

325 pattern = r"(?<={0})?{1}(?={0})?".format(context, match) 

326 return re.split(pattern, string) 

327 

328 def findall_with_context(string, match, context): 

329 pattern = r"(?<={0})?{1}(?={0})?".format(context, match) 

330 return re.findall(pattern, string) 

331 

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.") 

338 

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)) 

345 

346 # Verify that slots are always surrounded by (empty) skeleton strings. 

347 assert len(skeleton) == len(slotToValues) + 1 

348 

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] 

358 

359 # We next build a mapping from slots to (few) placeholder indices. 

360 slotToIndex = {} 

361 nextIndex = 0 

362 

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 

376 

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 

384 

385 if values in valsToIndex: 

386 slotToIndex[slot] = valsToIndex[values] 

387 else: 

388 slotToIndex[slot] = nextIndex 

389 valsToIndex[values] = nextIndex 

390 nextIndex += 1 

391 

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 

396 

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) 

404 

405 template += skeleton[slot] + ph 

406 template += skeleton[-1] 

407 

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] 

413 

414 if index in indices: 

415 continue 

416 else: 

417 indices.add(index) 

418 

419 usedPHs.append(placeholder(slot)) 

420 

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))) 

428 

429 # Make sure used placeholders and their domains match in number. 

430 assert len(usedPHs) == len(domains) 

431 

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)) 

442 

443 data = glyphs.element(usedPHs, domain) 

444 

445 return template, data 

446 

447 

448def arguments(strings, sep=", ", empty=""): 

449 """A wrapper around :func:`parameterized_string` for argument lists. 

450 

451 :param list(str) strings: 

452 String descriptions of the arguments. 

453 

454 :param str sep: 

455 Separator. 

456 

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] 

466 

467 try: 

468 template, data = parameterized_string(strings) 

469 except ValueError: 

470 pass 

471 else: 

472 if data: 

473 return glyphs.sep(template, data) 

474 

475 return glyphs.fromto(strings[0] + sep, sep + strings[-1]) 

476 

477 

478# -------------------------------------- 

479__all__ = api_end(_API_START, globals())