Coverage for pygeodesy/iters.py: 97%

204 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2025-05-29 12:40 -0400

1 

2# -*- coding: utf-8 -*- 

3 

4u'''Iterators with options. 

5 

6Iterator classes L{LatLon2PsxyIter} and L{PointsIter} to iterate 

7over iterables, lists, sets, tuples, etc. with optional loop-back to 

8the initial items, skipping of duplicate items and copying of the 

9iterated items. 

10''' 

11 

12from pygeodesy.basics import _isin, islistuple, issubclassof, \ 

13 len2, map2, _passarg, typename 

14# from pygeodesy.constants import _1_0 # from .utily 

15from pygeodesy.errors import _IndexError, LenError, PointsError, \ 

16 _TypeError, _ValueError 

17# from pygeodesy.internals import _passarg, typename # from .basics 

18from pygeodesy.interns import _0_, _composite_, _few_, _latlon_, \ 

19 _points_, _too_ 

20from pygeodesy.lazily import _ALL_DOCS, _ALL_LAZY, _ALL_MODS as _MODS 

21from pygeodesy.named import _Named, property_RO, Fmt 

22from pygeodesy.namedTuples import Point3Tuple, Points2Tuple 

23# from pygeodesy.props import property_RO # from .named 

24# from pygeodesy.streprs import Fmt # from .named 

25from pygeodesy.units import Int, Radius 

26from pygeodesy.utily import degrees2m, _Wrap, _1_0 

27 

28__all__ = _ALL_LAZY.iters 

29__version__ = '25.05.19' 

30 

31_items_ = 'items' 

32_iterNumpy2len = 1 # adjustable for testing purposes 

33_NOTHING = object() # unique 

34 

35 

36class _BaseIter(_Named): 

37 '''(INTERNAL) Iterator over items with loop-back and de-duplication. 

38 

39 @see: Luciano Ramalho, "Fluent Python", O'Reilly, 2016 p. 418+, 2022 p. 600+ 

40 ''' 

41 _closed = True 

42 _copies = () 

43 _dedup = False 

44 _Error = LenError 

45 _items = None 

46 _len = 0 

47 _loop = () 

48 _looped = False 

49 _name = _items_ 

50 _prev = _NOTHING 

51 _wrap = False 

52 

53 def __init__(self, items, loop=0, dedup=False, Error=None, **name): 

54 '''New iterator over an iterable of B{C{items}}. 

55 

56 @arg items: Iterable (any C{type}, except composites). 

57 @kwarg loop: Number of loop-back items, also initial enumerate and 

58 iterate index (non-negative C{int}). 

59 @kwarg dedup: Skip duplicate items (C{bool}). 

60 @kwarg Error: Error to raise (L{LenError}). 

61 @kwarg name: Optional C{B{name}="items"} (C{str}). 

62 

63 @raise Error: Invalid B{C{items}} or sufficient number of B{C{items}}. 

64 

65 @raise TypeError: Composite B{C{items}}. 

66 ''' 

67 if dedup: 

68 self._dedup = True 

69 if issubclassof(Error, Exception): 

70 self._Error = Error 

71 if name: 

72 self.rename(name) 

73 

74 if islistuple(items): # range in Python 2 

75 self._items = items 

76 elif _MODS.booleans.isBoolean(items): 

77 raise _TypeError(points=_composite_) 

78# XXX if hasattr(items, 'next') or hasattr(items, '__length_hint__'): 

79# XXX # handle reversed, iter, etc. items types 

80 self._iter = iter(items) 

81 self._indx = -1 

82 if Int(loop) > 0: 

83 try: 

84 self._loop = tuple(self.next for _ in range(loop)) 

85 if self.loop != loop: 

86 raise RuntimeError() # force Error 

87 except (RuntimeError, StopIteration): 

88 raise self._Error(self.name, self.loop, txt=_too_(_few_)) 

89 

90 @property_RO 

91 def copies(self): 

92 '''Get the saved copies, if any (C{tuple} or C{list}) and only I{once}. 

93 ''' 

94 cs = self._copies 

95 if cs: 

96 self._copies = () 

97 return cs 

98 

99 @property_RO 

100 def dedup(self): 

101 '''Get the de-duplication setting (C{bool}). 

102 ''' 

103 return self._dedup 

104 

105 def enumerate(self, closed=False, copies=False, dedup=False): 

106 '''Yield all items, each as a 2-tuple C{(index, item)}. 

107 

108 @kwarg closed: Loop back to the first B{C{point(s)}}. 

109 @kwarg copies: Make a copy of all B{C{items}} (C{bool}). 

110 @kwarg dedup: Set de-duplication in loop-back (C{bool}). 

111 ''' 

112 for item in self.iterate(closed=closed, copies=copies, dedup=dedup): 

113 yield self._indx, item 

114 

115 def __getitem__(self, index): 

116 '''Get the item(s) at the given B{C{index}} or C{slice}. 

117 

118 @raise IndexError: Invalid B{C{index}}, beyond B{C{loop}}. 

119 ''' 

120 t = self._items or self._copies or self._loop 

121 try: # Luciano Ramalho, "Fluent Python", O'Reilly, 2016 p. 293+, 2022 p. 408+ 

122 if isinstance(index, slice): 

123 return t[index.start:index.stop:index.step] 

124 else: 

125 return t[index] 

126 except IndexError as x: 

127 t = Fmt.SQUARE(self.name, index) 

128 raise _IndexError(str(x), txt=t, cause=x) 

129 

130 def __iter__(self): # PYCHOK no cover 

131 '''Make this iterator C{iterable}. 

132 ''' 

133 # Luciano Ramalho, "Fluent Python", O'Reilly, 2016 p. 421, 2022 p. 604+ 

134 return self.iterate() # XXX or self? 

135 

136 def iterate(self, closed=False, copies=False, dedup=False): 

137 '''Yield all items, each as C{item}. 

138 

139 @kwarg closed: Loop back to the first B{C{point(s)}}. 

140 @kwarg copies: Make a copy of all B{C{items}} (C{bool}). 

141 @kwarg dedup: Set de-duplication in loop-back (C{bool}). 

142 

143 @raise Error: Using C{B{closed}=True} without B{C{loop}}-back. 

144 ''' 

145 if closed and not self.loop: 

146 raise self._Error(closed=closed, loop=self.loop) 

147 

148 if copies: 

149 if self._items: 

150 self._copies = self._items 

151 self._items = _copy = None 

152 else: 

153 self._copies = list(self._loop) 

154 _copy = self._copies.append 

155 else: # del B{C{items}} reference 

156 self._items = _copy = None 

157 

158 self._closed = closed 

159 self._looped = False 

160 if self._iter: 

161 try: 

162 _next_ = self.next_ 

163 if _copy: 

164 while True: 

165 item = _next_(dedup=dedup) 

166 _copy(item) 

167 yield item 

168 else: 

169 while True: 

170 yield _next_(dedup=dedup) 

171 except StopIteration: 

172 self._iter = () # del self._iter, prevent re-iterate 

173 

174 def __len__(self): 

175 '''Get the number of items seen so far. 

176 ''' 

177 return self._len 

178 

179 @property_RO 

180 def loop(self): 

181 '''Get the B{C{loop}} setting (C{int}), C{0} for non-loop-back. 

182 ''' 

183 return len(self._loop) 

184 

185 @property_RO 

186 def looped(self): 

187 '''In this C{Iter}ator in loop-back? (C{bool}). 

188 ''' 

189 return self._looped 

190 

191 @property_RO 

192 def next(self): 

193 '''Get the next item. 

194 ''' 

195 return self._next_dedup() if self._dedup else self._next(False) 

196 

197# __next__ # NO __next__ AND __iter__ ... see Luciano Ramalho, 

198# # "Fluent Python", O'Reilly, 2016 p. 426, 2022 p. 610 

199 

200 def next_(self, dedup=False): 

201 '''Return the next item. 

202 

203 @kwarg dedup: Set de-duplication for loop-back (C{bool}). 

204 ''' 

205 return self._next_dedup() if self._dedup else self._next(dedup) 

206 

207 def _next(self, dedup): 

208 '''Return the next item, regardless. 

209 

210 @arg dedup: Set de-duplication for loop-back (C{bool}). 

211 ''' 

212 try: 

213 self._indx += 1 

214 self._len = self._indx # max(_len, _indx) 

215 self._prev = item = next(self._iter) 

216 return item 

217 except StopIteration: 

218 pass 

219 if self._closed and self._loop: # loop back 

220 self._dedup = bool(dedup or self._dedup) 

221 self._indx = 0 

222 self._iter = iter(self._loop) 

223 self._loop = () 

224 self._looped = True 

225 return next(self._iter) 

226 

227 def _next_dedup(self): 

228 '''Return the next item, different from the previous one. 

229 ''' 

230 prev = self._prev 

231 item = self._next(True) 

232 if prev is not _NOTHING: 

233 while item == prev: 

234 item = self._next(True) 

235 return item 

236 

237 

238class PointsIter(_BaseIter): 

239 '''Iterator for C{points} with optional loop-back and copies. 

240 ''' 

241 _base = None 

242 _Error = PointsError 

243 _name = _points_ 

244 

245 def __init__(self, points, loop=0, base=None, dedup=False, wrap=False, **name): 

246 '''New L{PointsIter} iterator. 

247 

248 @arg points: C{Iterable} or C{list}, C{sequence}, C{set}, C{tuple}, 

249 etc. (C{point}s). 

250 @kwarg loop: Number of loop-back points, also initial C{enumerate} and 

251 C{iterate} index (non-negative C{int}). 

252 @kwarg base: Optional B{C{points}} instance for type checking (C{any}). 

253 @kwarg dedup: Skip duplicate points (C{bool}). 

254 @kwarg wrap: If C{True}, wrap or I{normalize} the enum-/iterated 

255 B{C{points}} (C{bool}). 

256 @kwarg name: Optional C{B{name}="points"} (C{str}). 

257 

258 @raise PointsError: Insufficient number of B{C{points}}. 

259 

260 @raise TypeError: Some B{C{points}} are not B{C{base}}. 

261 ''' 

262 _BaseIter.__init__(self, points, loop=loop, dedup=dedup, **name) 

263 

264 if base and not (isNumpy2(points) or isTuple2(points)): 

265 self._base = base 

266 if wrap: 

267 self._wrap = True 

268 

269 def enumerate(self, closed=False, copies=False): # PYCHOK signature 

270 '''Iterate and yield each point as a 2-tuple C{(index, point)}. 

271 

272 @kwarg closed: Loop back to the first B{C{point(s)}}, de-dup'ed (C{bool}). 

273 @kwarg copies: Save a copy of all B{C{points}} (C{bool}). 

274 

275 @raise PointsError: Insufficient number of B{C{points}} or using 

276 C{B{closed}=True} without B{C{loop}}-back. 

277 

278 @raise TypeError: Some B{C{points}} are not B{C{base}}-compatible. 

279 ''' 

280 for p in self.iterate(closed=closed, copies=copies): 

281 yield self._indx, p 

282 

283 def iterate(self, closed=False, copies=False): # PYCHOK signature 

284 '''Iterate through all B{C{points}} starting at index C{loop}. 

285 

286 @kwarg closed: Loop back to the first B{C{point(s)}}, de-dup'ed (C{bool}). 

287 @kwarg copies: Save a copy of all B{C{points}} (C{bool}). 

288 

289 @raise PointsError: Insufficient number of B{C{points}} or using 

290 C{B{closed}=True} without B{C{loop}}-back. 

291 

292 @raise TypeError: Some B{C{points}} are not B{C{base}}-compatible. 

293 ''' 

294 if self._base: 

295 _oth = self._base.others 

296 _fmt = Fmt.SQUARE(points=0).replace 

297 else: 

298 _oth = _fmt = None 

299 

300 n = self.loop if self._iter else 0 

301 _p = _Wrap.point if self._wrap else _passarg # and _Wrap.normal is not None 

302 for p in _BaseIter.iterate(self, closed=closed, copies=copies, dedup=closed): 

303 if _oth: 

304 _oth(p, name=_fmt(_0_, str(self._indx)), up=2) 

305 yield _p(p) 

306 n += 1 

307 if n < (4 if closed else 2): 

308 raise self._Error(self.name, n, txt=_too_(_few_)) 

309 

310 

311class LatLon2PsxyIter(PointsIter): 

312 '''Iterate and convert for C{points} with optional loop-back and copies. 

313 ''' 

314 _deg2m = None 

315 _name = _latlon_ 

316 _radius = None # keep degrees 

317 _wrap = True 

318 

319 def __init__(self, points, loop=0, base=None, wrap=True, radius=None, 

320 dedup=False, **name): 

321 '''New L{LatLon2PsxyIter} iterator. 

322 

323 @note: The C{LatLon} latitude is considered the I{pseudo-y} and 

324 longitude the I{pseudo-x} coordinate, like L{LatLon2psxy}. 

325 

326 @arg points: C{Iterable} or C{list}, C{sequence}, C{set}, C{tuple}, 

327 etc. (C{LatLon}[]). 

328 @kwarg loop: Number of loop-back points, also initial C{enumerate} and 

329 C{iterate} index (non-negative C{int}). 

330 @kwarg base: Optional B{C{points}} instance for type checking (C{any}). 

331 @kwarg wrap: If C{True}, wrap or I{normalize} the enum-/iterated 

332 B{C{points}} (C{bool}). 

333 @kwarg radius: Mean earth radius (C{meter}) for conversion from 

334 C{degrees} to C{meter} (or C{radians} if C{B{radius}=1}). 

335 @kwarg dedup: Skip duplicate points (C{bool}). 

336 @kwarg name: Optional C{B{name}="latlon"} (C{str}). 

337 

338 @raise PointsError: Insufficient number of B{C{points}}. 

339 

340 @raise TypeError: Some B{C{points}} are not B{C{base}}-compatible. 

341 ''' 

342 PointsIter.__init__(self, points, loop=loop, base=base, dedup=dedup, **name) 

343 if not wrap: 

344 self._wrap = False 

345 if radius: 

346 self._radius = r = Radius(radius) 

347 self._deg2m = degrees2m(_1_0, r) 

348 

349 def __getitem__(self, index): 

350 '''Get the point(s) at the given B{C{index}} or C{slice}. 

351 

352 @raise IndexError: Invalid B{C{index}}, beyond B{C{loop}}. 

353 ''' 

354 ll = PointsIter.__getitem__(self, index) 

355 if isinstance(index, slice): 

356 return map2(self._point3Tuple, ll) 

357 else: 

358 return self._point3Tuple(ll) 

359 

360 def enumerate(self, closed=False, copies=False): # PYCHOK signature 

361 '''Iterate and yield each point as a 2-tuple C{(index, L{Point3Tuple})}. 

362 

363 @kwarg closed: Loop back to the first B{C{point(s)}}, de-dup'ed (C{bool}). 

364 @kwarg copies: Save a copy of all B{C{points}} (C{bool}). 

365 

366 @raise PointsError: Insufficient number of B{C{points}} or using 

367 C{B{closed}=True} without B{C{loop}}-back. 

368 

369 @raise TypeError: Some B{C{points}} are not B{C{base}}-compatible. 

370 ''' 

371 return PointsIter.enumerate(self, closed=closed, copies=copies) 

372 

373 def iterate(self, closed=False, copies=False): # PYCHOK signature 

374 '''Iterate the B{C{points}} starting at index B{C{loop}} and 

375 yield each as a L{Point3Tuple}C{(x, y, ll)}. 

376 

377 @kwarg closed: Loop back to the first B{C{point(s)}}, de-dup'ed (C{bool}). 

378 @kwarg copies: Save a copy of all B{C{points}} (C{bool}). 

379 

380 @raise PointsError: Insufficient number of B{C{points}} or using 

381 C{B{closed}=True} without B{C{loop}}-back. 

382 

383 @raise TypeError: Some B{C{points}} are not B{C{base}}-compatible. 

384 ''' 

385 if not _isin(self._deg2m, None, _1_0): 

386 _p3 = self._point3Tuple 

387 else: 

388 def _p3(ll): # PYCHOK redef 

389 return Point3Tuple(ll.lon, ll.lat, ll) 

390 

391 for ll in PointsIter.iterate(self, closed=closed, copies=copies): 

392 yield _p3(ll) 

393 

394 def _point3Tuple(self, ll): 

395 '''(INTERNAL) Create a L{Point3Tuple} for point B{C{ll}}. 

396 ''' 

397 x, y = ll.lon, ll.lat # note, x, y = lon, lat 

398 d = self._deg2m 

399 if d: # convert degrees 

400 x *= d 

401 y *= d 

402 return Point3Tuple(x, y, ll) 

403 

404 

405def _imdex2(closed, n): # PYCHOK by .clipy 

406 '''(INTERNAL) Return first and second index of C{range(B{n})}. 

407 ''' 

408 return (n-1, 0) if closed else (0, 1) 

409 

410 

411def isNumpy2(obj): 

412 '''Check for a B{C{Numpy2LatLon}} points wrapper. 

413 

414 @arg obj: The object (any C{type}). 

415 

416 @return: C{True} if B{C{obj}} is a B{C{Numpy2LatLon}} 

417 instance, C{False} otherwise. 

418 ''' 

419 # isinstance(self, (Numpy2LatLon, ...)) 

420 return getattr(obj, typename(isNumpy2), False) 

421 

422 

423def isPoints2(obj): 

424 '''Check for a B{C{LatLon2psxy}} points wrapper. 

425 

426 @arg obj: The object (any C{type}). 

427 

428 @return: C{True} if B{C{obj}} is a B{C{LatLon2psxy}} 

429 instance, C{False} otherwise. 

430 ''' 

431 # isinstance(self, (LatLon2psxy, ...)) 

432 return getattr(obj, typename(isPoints2), False) 

433 

434 

435def isTuple2(obj): 

436 '''Check for a B{C{Tuple2LatLon}} points wrapper. 

437 

438 @arg obj: The object (any). 

439 

440 @return: C{True} if B{C{obj}} is a B{C{Tuple2LatLon}} 

441 instance, C{False} otherwise. 

442 ''' 

443 # isinstance(self, (Tuple2LatLon, ...)) 

444 return getattr(obj, typename(isTuple2), False) 

445 

446 

447def iterNumpy2(obj): 

448 '''Iterate over Numpy2 wrappers or other sequences exceeding 

449 the threshold. 

450 

451 @arg obj: Points array, list, sequence, set, etc. (any). 

452 

453 @return: C{True} do, C{False} don't iterate. 

454 ''' 

455 try: 

456 return isNumpy2(obj) or len(obj) > _iterNumpy2len 

457 except TypeError: 

458 return False 

459 

460 

461def iterNumpy2over(n=None): 

462 '''Get or set the L{iterNumpy2} threshold. 

463 

464 @kwarg n: Optional, new threshold (C{int}). 

465 

466 @return: Previous threshold (C{int}). 

467 

468 @raise ValueError: Invalid B{C{n}}. 

469 ''' 

470 global _iterNumpy2len 

471 p = _iterNumpy2len 

472 if n is not None: 

473 try: 

474 i = int(n) 

475 if i > 0: 

476 _iterNumpy2len = i 

477 else: 

478 raise ValueError 

479 except (TypeError, ValueError): 

480 raise _ValueError(n=n) 

481 return p 

482 

483 

484def points2(points, closed=True, base=None, Error=PointsError): 

485 '''Check a path or polygon represented by points. 

486 

487 @arg points: The path or polygon points (C{LatLon}[]) 

488 @kwarg closed: Optionally, consider the polygon closed, 

489 ignoring any duplicate or closing final 

490 B{C{points}} (C{bool}). 

491 @kwarg base: Optionally, check all B{C{points}} against 

492 this base class, if C{None} don't check. 

493 @kwarg Error: Exception to raise (C{ValueError}). 

494 

495 @return: A L{Points2Tuple}C{(number, points)} with the number 

496 of points and the points C{list} or C{tuple}. 

497 

498 @raise PointsError: Insufficient number of B{C{points}}. 

499 

500 @raise TypeError: Some B{C{points}} are not B{C{base}} 

501 compatible or composite B{C{points}}. 

502 ''' 

503 if _MODS.booleans.isBoolean(points): 

504 raise Error(points=points, txt=_composite_) 

505 

506 n, points = len2(points) 

507 

508 if closed: 

509 # remove duplicate or closing final points 

510 while n > 1 and points[n-1] in (points[0], points[n-2]): 

511 n -= 1 

512 # XXX following line is unneeded if points 

513 # are always indexed as ... i in range(n) 

514 points = points[:n] # XXX numpy.array slice is a view! 

515 

516 if n < (3 if closed else 1): 

517 raise Error(points=n, txt=_too_(_few_)) 

518 

519 if base and not (isNumpy2(points) or isTuple2(points)): 

520 for i in range(n): 

521 base.others(points[i], name=Fmt.SQUARE(points=i)) 

522 

523 return Points2Tuple(n, points) 

524 

525 

526__all__ += _ALL_DOCS(_BaseIter) 

527 

528# **) MIT License 

529# 

530# Copyright (C) 2016-2025 -- mrJean1 at Gmail -- All Rights Reserved. 

531# 

532# Permission is hereby granted, free of charge, to any person obtaining a 

533# copy of this software and associated documentation files (the "Software"), 

534# to deal in the Software without restriction, including without limitation 

535# the rights to use, copy, modify, merge, publish, distribute, sublicense, 

536# and/or sell copies of the Software, and to permit persons to whom the 

537# Software is furnished to do so, subject to the following conditions: 

538# 

539# The above copyright notice and this permission notice shall be included 

540# in all copies or substantial portions of the Software. 

541# 

542# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 

543# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 

544# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 

545# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 

546# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 

547# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 

548# OTHER DEALINGS IN THE SOFTWARE.