An n-ary Tree type

Tree data-structures are processed recursively. In other terms, they represent, or are defined by, the run of recursive processes. The structure is simple:
class Tree:
    def __init__(self, name, *children):
        self.name = name
        self.children = children
    def __iter__(self):
        yield from self.children
    def __repr__(self):
        return self.name

tree = Tree("n", Tree("n1"),
    Tree("n2", Tree("n2a"), Tree("n2b")),
    Tree("n3", Tree("n3a")))

Or by doing anonymous types for each node:

node = lambda name, *next: type(
    str(name), (), dict(    
        __iter__=lambda _: iter(next),
        __repr__=lambda _: type(_).__name__
))()

tree2 = node("one", node("two"), node("three"))

Walking Trees with Recursive Generators

This walk function provides a recursive generator of the contents extracted from tree. NB - it also yields intermediate nodes, for a flatten, yield tree must filter out intermediates nodes.
def walk(tree):
    yield tree
    for _ in tree: yield from walk(_)

print(*walk(tree), sep=', ')
# n, n1, n2, n2a, n2b, n3, n3a

It works on any tree where tree is an Iterator of sub-Iterators. The tree must have leaves which are empty iterators. Examples with list/set/tuple, and then a tree implemented as a generator.

tree_tuple = (node(1), [node(2), {node(3), node(4)}])
print(*walk(tree_tuple))
# (1, [2, {3, 4}]) 1 [2, {3, 4}] 2 {3, 4} 3 4

def tree_gen():
    def branches():
        def branches():
            yield from (node(3), node(4))
        yield from (node(2), *branches())
    yield from (node(1), branches())
print(*walk(tree_gen()))
# [gen.obj...] 1 [gen.obj...] 2 [gen.obj...] 3 4

Walking with Pruning

Useful walks implement stopping conditions, to support walking infinite trees, or efficiently run by pruning early.

def prune(tree, maxdepth, *, d=0):
    if d >= maxdepth: return
    yield d, tree
    for _ in tree:
        yield from prune(_, maxdepth, d=d+1)

print(*prune(tree, 2), sep=', ')
# n n1 n3 n3a

import os
def walk_fs(dir):
    if type(dir) == os.DirEntry:
        # prune hidden files, git
        if dir.name[0] == '.': return
        # prune __pycache__ etc
        if dir.name[0] == '_': return
        # prune node_modules
        if dir.name == "node_modules": return
    for _ in os.scandir(dir):
        yield _
        if _.is_file(): yield _
        if _.is_dir(): yield from walk_fs(_)

for file in walk_fs('..'):
    if file.name.endswith('.html'):
        print('%s ;'*11 % (file.name, *file.stat()))

Communicating with Generators

Generators have a two-way communication channel using resp = yield, that gets a value from the outer scope that provides it via generator.send(...). Before returning, a last yield can be used to avoid having to catch the StopIteration.

def walk_sent(tree):
    sentinel = yield tree
    if sentinel == "Prune":
        yield; return
    for _ in tree:
        yield from walk_sent(_)

def cowalk_sent(tree):    
    for node in (g := walk_sent(tree)):
        if str(node) == "n2":
            g.send("Prune"); continue
        yield node

print(*cowalk_sent(tree))
# n n1 n3 n3a (skips the n2 subtree)

This can be useful for defering the pruning logic to the outside iteration, but also to let it provide contextual continuation mechanisms, e.g. provide compatible Parsers or let it decide the next set of children.