Streams of data

An object implement __next__ returns successive stream items on each call. When no more data are available, it raises a StopIteration exception. items.__next__() is equivalent to using the built-in next(items). An object implementing __next__ is neither an Iterable, nor an Iterator.
from typing import Iterable, Iterator
class HasNext:
    def __init__(self): self._last = 0
    def __next__(self):
        if self._last >= 3: raise StopIteration()
        self._last += 1; return self._last
print(isinstance(HasNext(), Iterable)) # False
print(isinstance(HasNext(), Iterator)) # False

Iterables

Iterables represent the stream of data. They implement provisioning an Iterator, via __iter__. They do not have to implement __next__, thus may not be Iterator themselves. isinstance(obj, Iterable) checks if the object either explicitly implements __iter__(), or inherits from collections.abc.Iterable. The following class defines an Iterable type, note that instances are "Iterables", not the type itself.
class Percents:
    def __iter__(self):
        return range(0, 100)
print(isinstance(Percents(), Iterable)) # True
print(isinstance(Percents(), Iterator)) # False

Iterators

Iterators implement both __next__ and __iter__. From the latter, all Iterators are Iterables, while the converse is not true. Iterators implement the traversal of a container, giving access to data elements. However, Iterators do not "perform iteration". Loops cannot iterate over instances from a class missing __iter__, save for the legacy __getitem__ exception. If the logic is contained in __next__, then a type can be made into an iterator by the sufficient def __iter__(self): return self.

class HasNextAndIter(HasNext):
    def __iter__(self): return self
print(isinstance(HasNextAndIter(), Iterable)) # True
print(isinstance(HasNextAndIter(), Iterator)) # True

iter() is a built-in that can be used to wrap any callable into an Iterator. For this, it requires a second arg: a sentinel value. The output of iter(callable, sentinel) yields from callable until the sentinel is encountered, which stops the iteration.

class AllNums:
    def __init__(self):
        self._i = 0
    def __call__(self):
        self._i += 1
        return self._i
before5 = iter(AllNums(), 4)
print(*before5) # 1 2 3

Star-Iterable

The star-Iterable syntax deconstructs items, for example, to fit into a variadic signature. print takes *args that are printed sequentially, thus star-Iterables allow printing all items without using a loop. Another good use is to map lists to their first elem. or empty list.
print(*HasNextAndIter()) # 1 2 3
print(*HasNextAndIter().__iter__()) # 1 2 3
list_empty, list_full = [], ["f", "u", "l", "l"]
print("First from empty:", *list_empty[:1])
# new line, no error
print("First from full:", *list_full[:1])
# "f" and new line
for-loops extract a target from a starred_list expression. The expression is evaluated once, and can be any that returns an Iterable object. This Iterable object is used to create an Iterator. The first item from the Iterator is assigned to target.

# works since instances are Iterable
for target in HasNextAndIter():
    print(target, end=', ')
print()

Legacy __getitem__

__getitem__() iteration is a legacy fallback for iterable objects not implementing __iter__(). It fails isinstance(_, Iterable), but valid star-iterable/in loops
class GetItem:
    def __getitem__(self, i): return [1, 2, 3][i]
assert not isinstance(GetItem(), Iterable) # True
assert not isinstance(GetItem(), Iterator) # True
print(*GetItem()) # 1 2 3
# iter normalizes them.
getiterator = iter(GetItem())
assert isinstance(getiterator, Iterable) # True
assert isinstance(getiterator, Iterator) # True

Standard Library itertools

Provides tools for iteration. A useful one is groupby, which can be combined with sorted to make a pseudo-SQL operation, similar to the piping of sort | uniq.
from itertools import groupby

def grouped(items, *, by=lambda *_:_, desc=False):
    _s = sorted(items, key=by, reverse=(not desc))
    for k, g in groupby(_s, by): yield k, [*g]

Counting letter occurence

for c, group in grouped('aaabbaabbccccaacbc'):
    print(c, "has", len(group), "occurences.")

Word count per word length

for w, group in grouped('this text by word length.'.split(), by=len):
    print(w, group)

Html page count per public subdir

from pathlib import Path
for p, pages in grouped(Path().rglob('*.html'), 
                        by=lambda _: _.parent):
    # hidden / ignored files
    if any(_[0] in '._' for _ in p.parts):
        continue
    print("   ", p, "has", len(pages), "pages.")