pytreeds 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pytreeds-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pytreeds contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytreeds
3
+ Version: 0.1.0
4
+ Summary: Beginner-friendly rooted trees with consistent string navigation and traversal
5
+ Author: pytreeds contributors
6
+ License-Expression: MIT
7
+ Project-URL: Source, https://gitlab.com/ujen.teaching/pytreeds
8
+ Project-URL: Issues, https://gitlab.com/ujen.teaching/pytreeds/-/issues
9
+ Keywords: tree,data structures,education,dfs,bfs,pyodide
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Education
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Education
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # pytreeds
20
+
21
+ A dependency-free Python library for beginners to create, inspect, modify, traverse, and print **non-binary trees**. Students keep only one tree variable. Node identifiers are unique strings, and all node-related operations return strings rather than mixed types.
22
+
23
+ ## Install
24
+
25
+ ~~~bash
26
+ pip install pytreeds
27
+ ~~~
28
+
29
+ Pyodide (from your platform's asynchronous package loader):
30
+
31
+ ~~~python
32
+ import micropip
33
+ await micropip.install("pytreeds")
34
+ ~~~
35
+
36
+ ## First example
37
+
38
+ ~~~python
39
+ from pytreeds import Tree
40
+
41
+ org = Tree("CEO")
42
+ org.add_nodes(["CTO", "CFO", "COO"], parent="CEO")
43
+ org.add_nodes(["Engineering", "DevOps"], parent="CTO")
44
+ org.add_node("Accounting", parent="CFO")
45
+
46
+ org.show()
47
+ print(org.height()) # 2 edges
48
+ print(org.size()) # 6
49
+ print(org.children("CTO")) # ['Engineering', 'DevOps']
50
+ print(org.parent("DevOps")) # CTO
51
+ print(org.leaves())
52
+ print(list(org.dfs()))
53
+ print(list(org.bfs()))
54
+
55
+ for name in org:
56
+ print(name, "->", org.parent(name))
57
+ ~~~
58
+
59
+ Text diagram:
60
+
61
+ ~~~text
62
+ CEO
63
+ ├── CTO
64
+ │ ├── Engineering
65
+ │ └── DevOps
66
+ ├── CFO
67
+ │ └── Accounting
68
+ └── COO
69
+ ~~~
70
+
71
+ ## API reference
72
+
73
+ ### Build and modify
74
+
75
+ | API | Description |
76
+ |---|---|
77
+ | Tree(root=None) | Create an empty tree or one with a root |
78
+ | add_node(name, parent=None) | Create one node |
79
+ | add_nodes(names, parent) | Add several children in insertion order |
80
+ | remove_node(name) | Remove a leaf; raises an error for internal nodes |
81
+ | remove_subtree(name) | Remove a whole branch; removing root empties tree |
82
+ | move_node(name, parent) | Move a whole branch under a different parent |
83
+ | rename_node(old, new) | Rename a node without changing its location |
84
+ | copy() | Return an independent copy |
85
+
86
+ ### Properties and relationships
87
+
88
+ | API | Description |
89
+ |---|---|
90
+ | root | Root name, or None if empty |
91
+ | size() | Total number of nodes |
92
+ | height(name=None) | Height in edges; empty tree = -1 |
93
+ | depth(name) | Number of edges from root; root depth = 0 |
94
+ | leaf_count(name=None) | Number of leaves in tree or subtree |
95
+ | is_leaf(name) | True if the node has no children |
96
+ | contains(name) | True if this name exists |
97
+ | parent(name) | Parent name, or None at root |
98
+ | children(name) | List of child names |
99
+ | siblings(name) | List of sibling names |
100
+ | ancestors(name) | Ancestors from root to parent |
101
+ | descendants(name) | Descendant names in DFS order |
102
+ | leaves(name=None) | Leaf names in DFS order |
103
+ | path(name) | Root-to-node list of names |
104
+
105
+ ### Traversal and search
106
+
107
+ | API | Description |
108
+ |---|---|
109
+ | dfs(name=None) | Preorder DFS iterator of strings |
110
+ | bfs(name=None) | Level-order BFS iterator of strings |
111
+ | dfs_postorder(name=None) | Postorder DFS iterator of strings |
112
+ | nodes(name=None) | List of names in preorder |
113
+ | find(text) | Names containing a case-sensitive substring |
114
+ | for name in tree | Default preorder DFS |
115
+
116
+ ### Print text
117
+
118
+ | API | Description |
119
+ |---|---|
120
+ | show(name=None, style="unicode") | Print tree or subtree |
121
+ | to_text(name=None, style="unicode") | Return a diagram string |
122
+ | print(tree) | Print the full diagram |
123
+
124
+ The style option can also be "ascii".
125
+
126
+ ## Design and errors
127
+
128
+ - Each node has a unique nonempty string name. No UUIDs, extra node variables, overloaded subscripts, or student-defined classes.
129
+ - Parent, children, leaves, DFS, BFS, and ordinary iteration consistently use strings.
130
+ - The first node is the root. Further additions need a parent.
131
+ - Children and traversals preserve insertion order.
132
+ - Height and depth count edges, not levels.
133
+ - Duplicate names and invalid moves raise ValueError; missing names raise KeyError.
134
+ - Batch insertions validate before modifying the tree.
135
+ - Avoid modifying the tree while looping over a traversal.
136
+
137
+ ## Subtree example
138
+
139
+ ~~~python
140
+ from pytreeds import Tree
141
+ org = Tree("CEO")
142
+ org.add_nodes(["CTO", "CFO"], parent="CEO")
143
+ org.add_node("DevOps", parent="CTO")
144
+ org.move_node("DevOps", parent="CFO")
145
+ org.rename_node("DevOps", "Infrastructure")
146
+ org.show()
147
+ print(org.path("Infrastructure"))
148
+ org.remove_subtree("CFO")
149
+ ~~~
150
+
151
+ Run tests locally using: python -m unittest discover -s tests -v
152
+
153
+ This repository intentionally keeps the existing publishing-only GitLab CI workflow; there is no testing CI job.
@@ -0,0 +1,135 @@
1
+ # pytreeds
2
+
3
+ A dependency-free Python library for beginners to create, inspect, modify, traverse, and print **non-binary trees**. Students keep only one tree variable. Node identifiers are unique strings, and all node-related operations return strings rather than mixed types.
4
+
5
+ ## Install
6
+
7
+ ~~~bash
8
+ pip install pytreeds
9
+ ~~~
10
+
11
+ Pyodide (from your platform's asynchronous package loader):
12
+
13
+ ~~~python
14
+ import micropip
15
+ await micropip.install("pytreeds")
16
+ ~~~
17
+
18
+ ## First example
19
+
20
+ ~~~python
21
+ from pytreeds import Tree
22
+
23
+ org = Tree("CEO")
24
+ org.add_nodes(["CTO", "CFO", "COO"], parent="CEO")
25
+ org.add_nodes(["Engineering", "DevOps"], parent="CTO")
26
+ org.add_node("Accounting", parent="CFO")
27
+
28
+ org.show()
29
+ print(org.height()) # 2 edges
30
+ print(org.size()) # 6
31
+ print(org.children("CTO")) # ['Engineering', 'DevOps']
32
+ print(org.parent("DevOps")) # CTO
33
+ print(org.leaves())
34
+ print(list(org.dfs()))
35
+ print(list(org.bfs()))
36
+
37
+ for name in org:
38
+ print(name, "->", org.parent(name))
39
+ ~~~
40
+
41
+ Text diagram:
42
+
43
+ ~~~text
44
+ CEO
45
+ ├── CTO
46
+ │ ├── Engineering
47
+ │ └── DevOps
48
+ ├── CFO
49
+ │ └── Accounting
50
+ └── COO
51
+ ~~~
52
+
53
+ ## API reference
54
+
55
+ ### Build and modify
56
+
57
+ | API | Description |
58
+ |---|---|
59
+ | Tree(root=None) | Create an empty tree or one with a root |
60
+ | add_node(name, parent=None) | Create one node |
61
+ | add_nodes(names, parent) | Add several children in insertion order |
62
+ | remove_node(name) | Remove a leaf; raises an error for internal nodes |
63
+ | remove_subtree(name) | Remove a whole branch; removing root empties tree |
64
+ | move_node(name, parent) | Move a whole branch under a different parent |
65
+ | rename_node(old, new) | Rename a node without changing its location |
66
+ | copy() | Return an independent copy |
67
+
68
+ ### Properties and relationships
69
+
70
+ | API | Description |
71
+ |---|---|
72
+ | root | Root name, or None if empty |
73
+ | size() | Total number of nodes |
74
+ | height(name=None) | Height in edges; empty tree = -1 |
75
+ | depth(name) | Number of edges from root; root depth = 0 |
76
+ | leaf_count(name=None) | Number of leaves in tree or subtree |
77
+ | is_leaf(name) | True if the node has no children |
78
+ | contains(name) | True if this name exists |
79
+ | parent(name) | Parent name, or None at root |
80
+ | children(name) | List of child names |
81
+ | siblings(name) | List of sibling names |
82
+ | ancestors(name) | Ancestors from root to parent |
83
+ | descendants(name) | Descendant names in DFS order |
84
+ | leaves(name=None) | Leaf names in DFS order |
85
+ | path(name) | Root-to-node list of names |
86
+
87
+ ### Traversal and search
88
+
89
+ | API | Description |
90
+ |---|---|
91
+ | dfs(name=None) | Preorder DFS iterator of strings |
92
+ | bfs(name=None) | Level-order BFS iterator of strings |
93
+ | dfs_postorder(name=None) | Postorder DFS iterator of strings |
94
+ | nodes(name=None) | List of names in preorder |
95
+ | find(text) | Names containing a case-sensitive substring |
96
+ | for name in tree | Default preorder DFS |
97
+
98
+ ### Print text
99
+
100
+ | API | Description |
101
+ |---|---|
102
+ | show(name=None, style="unicode") | Print tree or subtree |
103
+ | to_text(name=None, style="unicode") | Return a diagram string |
104
+ | print(tree) | Print the full diagram |
105
+
106
+ The style option can also be "ascii".
107
+
108
+ ## Design and errors
109
+
110
+ - Each node has a unique nonempty string name. No UUIDs, extra node variables, overloaded subscripts, or student-defined classes.
111
+ - Parent, children, leaves, DFS, BFS, and ordinary iteration consistently use strings.
112
+ - The first node is the root. Further additions need a parent.
113
+ - Children and traversals preserve insertion order.
114
+ - Height and depth count edges, not levels.
115
+ - Duplicate names and invalid moves raise ValueError; missing names raise KeyError.
116
+ - Batch insertions validate before modifying the tree.
117
+ - Avoid modifying the tree while looping over a traversal.
118
+
119
+ ## Subtree example
120
+
121
+ ~~~python
122
+ from pytreeds import Tree
123
+ org = Tree("CEO")
124
+ org.add_nodes(["CTO", "CFO"], parent="CEO")
125
+ org.add_node("DevOps", parent="CTO")
126
+ org.move_node("DevOps", parent="CFO")
127
+ org.rename_node("DevOps", "Infrastructure")
128
+ org.show()
129
+ print(org.path("Infrastructure"))
130
+ org.remove_subtree("CFO")
131
+ ~~~
132
+
133
+ Run tests locally using: python -m unittest discover -s tests -v
134
+
135
+ This repository intentionally keeps the existing publishing-only GitLab CI workflow; there is no testing CI job.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytreeds"
7
+ version = "0.1.0"
8
+ description = "Beginner-friendly rooted trees with consistent string navigation and traversal"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{name = "pytreeds contributors"}]
13
+ keywords = ["tree", "data structures", "education", "dfs", "bfs", "pyodide"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Education",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Education",
19
+ ]
20
+ [project.urls]
21
+ Source = "https://gitlab.com/ujen.teaching/pytreeds"
22
+ Issues = "https://gitlab.com/ujen.teaching/pytreeds/-/issues"
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,289 @@
1
+ """Simple, dependency-free rooted trees for Python beginners."""
2
+
3
+ from __future__ import annotations
4
+ from collections import deque
5
+ from collections.abc import Iterable, Iterator
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["Tree"]
9
+
10
+
11
+ class Tree:
12
+ """An ordered tree of uniquely named nodes; all public node values are strings."""
13
+
14
+ def __init__(self, root: str | None = None):
15
+ self._root: str | None = None
16
+ self._children: dict[str, list[str]] = {}
17
+ self._parents: dict[str, str | None] = {}
18
+ if root is not None:
19
+ self.add_node(root)
20
+
21
+ @staticmethod
22
+ def _check_name(name: str) -> None:
23
+ if not isinstance(name, str) or not name.strip():
24
+ raise ValueError("Node names must be non-empty strings")
25
+
26
+ def _require(self, name: str) -> None:
27
+ if name not in self._parents:
28
+ raise KeyError(f"Node not found: {name!r}")
29
+
30
+ @property
31
+ def root(self) -> str | None:
32
+ """Return the root name, or None if the tree is empty."""
33
+ return self._root
34
+
35
+ def add_node(self, name: str, parent: str | None = None) -> None:
36
+ """Add the root to an empty tree or add a child under parent."""
37
+ self._check_name(name)
38
+ if name in self._parents:
39
+ raise ValueError(f"Node already exists: {name!r}")
40
+ if self._root is None:
41
+ if parent is not None:
42
+ raise ValueError("The first node cannot have a parent")
43
+ self._root = name
44
+ else:
45
+ if parent is None:
46
+ raise ValueError("A parent is required for new non-root nodes")
47
+ self._require(parent)
48
+ self._children[parent].append(name)
49
+ self._children[name] = []
50
+ self._parents[name] = parent
51
+
52
+ def add_nodes(self, names: Iterable[str], parent: str) -> None:
53
+ """Add several children in order, after validating all names."""
54
+ self._require(parent)
55
+ if isinstance(names, (str, bytes)):
56
+ raise TypeError("Pass a list of node names, not a single string")
57
+ names = list(names)
58
+ if any(not isinstance(name, str) or not name.strip() for name in names):
59
+ raise ValueError("Node names must be non-empty strings")
60
+ if len(names) != len(set(names)) or any(name in self._parents for name in names):
61
+ raise ValueError("Node names must be unique within the tree")
62
+ for name in names:
63
+ self.add_node(name, parent=parent)
64
+
65
+ def contains(self, name: str) -> bool:
66
+ """Test whether a node exists."""
67
+ return isinstance(name, str) and name in self._parents
68
+
69
+ def __contains__(self, name: str) -> bool:
70
+ return self.contains(name)
71
+
72
+ def size(self) -> int:
73
+ return len(self._parents)
74
+
75
+ def depth(self, name: str) -> int:
76
+ """Count edges from root to node, starting at zero."""
77
+ self._require(name)
78
+ result = 0
79
+ while self._parents[name] is not None:
80
+ name = self._parents[name]
81
+ result += 1
82
+ return result
83
+
84
+ def height(self, name: str | None = None) -> int:
85
+ """Maximum downward edge count; -1 for an empty tree."""
86
+ if name is None and self._root is None:
87
+ return -1
88
+ name = self._start(name)
89
+ longest = 0
90
+ queue = deque([(name, 0)])
91
+ while queue:
92
+ current, level = queue.popleft()
93
+ longest = max(longest, level)
94
+ queue.extend((child, level + 1) for child in self._children[current])
95
+ return longest
96
+
97
+ def is_leaf(self, name: str) -> bool:
98
+ self._require(name)
99
+ return not self._children[name]
100
+
101
+ def parent(self, name: str) -> str | None:
102
+ self._require(name)
103
+ return self._parents[name]
104
+
105
+ def children(self, name: str) -> list[str]:
106
+ self._require(name)
107
+ return self._children[name].copy()
108
+
109
+ def siblings(self, name: str) -> list[str]:
110
+ self._require(name)
111
+ parent = self._parents[name]
112
+ return [] if parent is None else [child for child in self._children[parent] if child != name]
113
+
114
+ def ancestors(self, name: str) -> list[str]:
115
+ """Return ancestors from root to parent, excluding the node."""
116
+ self._require(name)
117
+ result = []
118
+ parent = self._parents[name]
119
+ while parent is not None:
120
+ result.append(parent)
121
+ parent = self._parents[parent]
122
+ return result[::-1]
123
+
124
+ def path(self, name: str) -> list[str]:
125
+ return self.ancestors(name) + [name]
126
+
127
+ def _start(self, name: str | None) -> str | None:
128
+ if name is not None:
129
+ self._require(name)
130
+ return name
131
+ return self._root
132
+
133
+ def dfs(self, name: str | None = None) -> Iterator[str]:
134
+ """Yield names using preorder depth-first traversal."""
135
+ start = self._start(name)
136
+ if start is None:
137
+ return
138
+ stack = [start]
139
+ while stack:
140
+ current = stack.pop()
141
+ yield current
142
+ stack.extend(reversed(self._children[current]))
143
+
144
+ def bfs(self, name: str | None = None) -> Iterator[str]:
145
+ """Yield names in breadth-first (level) order."""
146
+ start = self._start(name)
147
+ if start is None:
148
+ return
149
+ queue = deque([start])
150
+ while queue:
151
+ current = queue.popleft()
152
+ yield current
153
+ queue.extend(self._children[current])
154
+
155
+ def dfs_postorder(self, name: str | None = None) -> Iterator[str]:
156
+ """Yield names using depth-first postorder traversal."""
157
+ start = self._start(name)
158
+ if start is None:
159
+ return
160
+ stack = [(start, False)]
161
+ while stack:
162
+ current, ready = stack.pop()
163
+ if ready:
164
+ yield current
165
+ else:
166
+ stack.append((current, True))
167
+ stack.extend((child, False) for child in reversed(self._children[current]))
168
+
169
+ def __iter__(self) -> Iterator[str]:
170
+ return self.dfs()
171
+
172
+ def nodes(self, name: str | None = None) -> list[str]:
173
+ return list(self.dfs(name))
174
+
175
+ def descendants(self, name: str) -> list[str]:
176
+ self._require(name)
177
+ iterator = self.dfs(name)
178
+ next(iterator)
179
+ return list(iterator)
180
+
181
+ def leaves(self, name: str | None = None) -> list[str]:
182
+ return [node for node in self.dfs(name) if not self._children[node]]
183
+
184
+ def leaf_count(self, name: str | None = None) -> int:
185
+ return len(self.leaves(name))
186
+
187
+ def find(self, text: str) -> list[str]:
188
+ """Find names containing the given case-sensitive substring."""
189
+ if not isinstance(text, str):
190
+ raise TypeError("Search text must be a string")
191
+ return [name for name in self if text in name]
192
+
193
+ def remove_node(self, name: str) -> None:
194
+ """Remove a leaf; use remove_subtree() to remove a branch."""
195
+ self._require(name)
196
+ if self._children[name]:
197
+ raise ValueError("Node has children; use remove_subtree()")
198
+ parent = self._parents[name]
199
+ if parent is None:
200
+ self._root = None
201
+ else:
202
+ self._children[parent].remove(name)
203
+ del self._parents[name]
204
+ del self._children[name]
205
+
206
+ def remove_subtree(self, name: str) -> None:
207
+ """Remove a node and all descendants. Removing root empties tree."""
208
+ self._require(name)
209
+ parent = self._parents[name]
210
+ if parent is None:
211
+ self._root = None
212
+ else:
213
+ self._children[parent].remove(name)
214
+ for node in list(self.dfs(name)):
215
+ del self._parents[node]
216
+ del self._children[node]
217
+
218
+ def move_node(self, name: str, parent: str) -> None:
219
+ """Move a whole subtree, appending it to the new parent's children."""
220
+ self._require(name)
221
+ self._require(parent)
222
+ if name == self._root:
223
+ raise ValueError("Cannot move the root")
224
+ if parent == name or parent in self.descendants(name):
225
+ raise ValueError("Cannot move a node below itself or its descendants")
226
+ old_parent = self._parents[name]
227
+ self._children[old_parent].remove(name)
228
+ self._children[parent].append(name)
229
+ self._parents[name] = parent
230
+
231
+ def rename_node(self, old: str, new: str) -> None:
232
+ """Change a node name without changing its location."""
233
+ self._require(old)
234
+ self._check_name(new)
235
+ if new in self._parents:
236
+ raise ValueError(f"Node already exists: {new!r}")
237
+ parent = self._parents.pop(old)
238
+ children = self._children.pop(old)
239
+ self._parents[new] = parent
240
+ self._children[new] = children
241
+ if parent is None:
242
+ self._root = new
243
+ else:
244
+ siblings = self._children[parent]
245
+ siblings[siblings.index(old)] = new
246
+ for child in children:
247
+ self._parents[child] = new
248
+
249
+ def copy(self) -> Tree:
250
+ """Return an independent copy."""
251
+ other = Tree()
252
+ other._root = self._root
253
+ other._parents = self._parents.copy()
254
+ other._children = {name: children.copy() for name, children in self._children.items()}
255
+ return other
256
+
257
+ def to_text(self, name: str | None = None, style: str = "unicode") -> str:
258
+ """Return an indented Unicode or ASCII branch diagram."""
259
+ if style not in ("unicode", "ascii"):
260
+ raise ValueError("style must be 'unicode' or 'ascii'")
261
+ start = self._start(name)
262
+ if start is None:
263
+ return "(empty tree)"
264
+ if style == "unicode":
265
+ branch, last_branch, middle, blank = "├── ", "└── ", "│ ", " "
266
+ else:
267
+ branch, last_branch, middle, blank = "|-- ", chr(96) + "-- ", "| ", " "
268
+ lines = [start]
269
+ stack = []
270
+ children = self._children[start]
271
+ for i in range(len(children) - 1, -1, -1):
272
+ stack.append((children[i], "", i == len(children) - 1))
273
+ while stack:
274
+ current, prefix, is_last = stack.pop()
275
+ lines.append(prefix + (last_branch if is_last else branch) + current)
276
+ next_prefix = prefix + (blank if is_last else middle)
277
+ children = self._children[current]
278
+ for i in range(len(children) - 1, -1, -1):
279
+ stack.append((children[i], next_prefix, i == len(children) - 1))
280
+ return "\n".join(lines)
281
+
282
+ def show(self, name: str | None = None, style: str = "unicode") -> None:
283
+ print(self.to_text(name, style=style))
284
+
285
+ def __str__(self) -> str:
286
+ return self.to_text()
287
+
288
+ def __repr__(self) -> str:
289
+ return f"Tree(root={self._root!r}, size={self.size()})"
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytreeds
3
+ Version: 0.1.0
4
+ Summary: Beginner-friendly rooted trees with consistent string navigation and traversal
5
+ Author: pytreeds contributors
6
+ License-Expression: MIT
7
+ Project-URL: Source, https://gitlab.com/ujen.teaching/pytreeds
8
+ Project-URL: Issues, https://gitlab.com/ujen.teaching/pytreeds/-/issues
9
+ Keywords: tree,data structures,education,dfs,bfs,pyodide
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Education
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Education
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # pytreeds
20
+
21
+ A dependency-free Python library for beginners to create, inspect, modify, traverse, and print **non-binary trees**. Students keep only one tree variable. Node identifiers are unique strings, and all node-related operations return strings rather than mixed types.
22
+
23
+ ## Install
24
+
25
+ ~~~bash
26
+ pip install pytreeds
27
+ ~~~
28
+
29
+ Pyodide (from your platform's asynchronous package loader):
30
+
31
+ ~~~python
32
+ import micropip
33
+ await micropip.install("pytreeds")
34
+ ~~~
35
+
36
+ ## First example
37
+
38
+ ~~~python
39
+ from pytreeds import Tree
40
+
41
+ org = Tree("CEO")
42
+ org.add_nodes(["CTO", "CFO", "COO"], parent="CEO")
43
+ org.add_nodes(["Engineering", "DevOps"], parent="CTO")
44
+ org.add_node("Accounting", parent="CFO")
45
+
46
+ org.show()
47
+ print(org.height()) # 2 edges
48
+ print(org.size()) # 6
49
+ print(org.children("CTO")) # ['Engineering', 'DevOps']
50
+ print(org.parent("DevOps")) # CTO
51
+ print(org.leaves())
52
+ print(list(org.dfs()))
53
+ print(list(org.bfs()))
54
+
55
+ for name in org:
56
+ print(name, "->", org.parent(name))
57
+ ~~~
58
+
59
+ Text diagram:
60
+
61
+ ~~~text
62
+ CEO
63
+ ├── CTO
64
+ │ ├── Engineering
65
+ │ └── DevOps
66
+ ├── CFO
67
+ │ └── Accounting
68
+ └── COO
69
+ ~~~
70
+
71
+ ## API reference
72
+
73
+ ### Build and modify
74
+
75
+ | API | Description |
76
+ |---|---|
77
+ | Tree(root=None) | Create an empty tree or one with a root |
78
+ | add_node(name, parent=None) | Create one node |
79
+ | add_nodes(names, parent) | Add several children in insertion order |
80
+ | remove_node(name) | Remove a leaf; raises an error for internal nodes |
81
+ | remove_subtree(name) | Remove a whole branch; removing root empties tree |
82
+ | move_node(name, parent) | Move a whole branch under a different parent |
83
+ | rename_node(old, new) | Rename a node without changing its location |
84
+ | copy() | Return an independent copy |
85
+
86
+ ### Properties and relationships
87
+
88
+ | API | Description |
89
+ |---|---|
90
+ | root | Root name, or None if empty |
91
+ | size() | Total number of nodes |
92
+ | height(name=None) | Height in edges; empty tree = -1 |
93
+ | depth(name) | Number of edges from root; root depth = 0 |
94
+ | leaf_count(name=None) | Number of leaves in tree or subtree |
95
+ | is_leaf(name) | True if the node has no children |
96
+ | contains(name) | True if this name exists |
97
+ | parent(name) | Parent name, or None at root |
98
+ | children(name) | List of child names |
99
+ | siblings(name) | List of sibling names |
100
+ | ancestors(name) | Ancestors from root to parent |
101
+ | descendants(name) | Descendant names in DFS order |
102
+ | leaves(name=None) | Leaf names in DFS order |
103
+ | path(name) | Root-to-node list of names |
104
+
105
+ ### Traversal and search
106
+
107
+ | API | Description |
108
+ |---|---|
109
+ | dfs(name=None) | Preorder DFS iterator of strings |
110
+ | bfs(name=None) | Level-order BFS iterator of strings |
111
+ | dfs_postorder(name=None) | Postorder DFS iterator of strings |
112
+ | nodes(name=None) | List of names in preorder |
113
+ | find(text) | Names containing a case-sensitive substring |
114
+ | for name in tree | Default preorder DFS |
115
+
116
+ ### Print text
117
+
118
+ | API | Description |
119
+ |---|---|
120
+ | show(name=None, style="unicode") | Print tree or subtree |
121
+ | to_text(name=None, style="unicode") | Return a diagram string |
122
+ | print(tree) | Print the full diagram |
123
+
124
+ The style option can also be "ascii".
125
+
126
+ ## Design and errors
127
+
128
+ - Each node has a unique nonempty string name. No UUIDs, extra node variables, overloaded subscripts, or student-defined classes.
129
+ - Parent, children, leaves, DFS, BFS, and ordinary iteration consistently use strings.
130
+ - The first node is the root. Further additions need a parent.
131
+ - Children and traversals preserve insertion order.
132
+ - Height and depth count edges, not levels.
133
+ - Duplicate names and invalid moves raise ValueError; missing names raise KeyError.
134
+ - Batch insertions validate before modifying the tree.
135
+ - Avoid modifying the tree while looping over a traversal.
136
+
137
+ ## Subtree example
138
+
139
+ ~~~python
140
+ from pytreeds import Tree
141
+ org = Tree("CEO")
142
+ org.add_nodes(["CTO", "CFO"], parent="CEO")
143
+ org.add_node("DevOps", parent="CTO")
144
+ org.move_node("DevOps", parent="CFO")
145
+ org.rename_node("DevOps", "Infrastructure")
146
+ org.show()
147
+ print(org.path("Infrastructure"))
148
+ org.remove_subtree("CFO")
149
+ ~~~
150
+
151
+ Run tests locally using: python -m unittest discover -s tests -v
152
+
153
+ This repository intentionally keeps the existing publishing-only GitLab CI workflow; there is no testing CI job.
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/pytreeds/__init__.py
5
+ src/pytreeds.egg-info/PKG-INFO
6
+ src/pytreeds.egg-info/SOURCES.txt
7
+ src/pytreeds.egg-info/dependency_links.txt
8
+ src/pytreeds.egg-info/top_level.txt
9
+ tests/test_tree.py
@@ -0,0 +1 @@
1
+ pytreeds
@@ -0,0 +1,101 @@
1
+ import io
2
+ import unittest
3
+ from contextlib import redirect_stdout
4
+ from pytreeds import Tree
5
+
6
+
7
+ class TestTree(unittest.TestCase):
8
+ def setUp(self):
9
+ self.t = Tree("A")
10
+ self.t.add_nodes(["B", "C", "D"], parent="A")
11
+ self.t.add_nodes(["E", "F", "G"], parent="B")
12
+ self.t.add_nodes(["H", "I"], parent="C")
13
+
14
+ def test_properties(self):
15
+ t = self.t
16
+ self.assertEqual(t.size(), 9)
17
+ self.assertEqual(t.height(), 2)
18
+ self.assertEqual(t.height("B"), 1)
19
+ self.assertEqual(t.depth("G"), 2)
20
+ self.assertEqual(t.parent("G"), "B")
21
+ self.assertEqual(t.children("B"), ["E", "F", "G"])
22
+ self.assertEqual(t.siblings("B"), ["C", "D"])
23
+ self.assertEqual(t.ancestors("G"), ["A", "B"])
24
+ self.assertEqual(t.path("G"), ["A", "B", "G"])
25
+ self.assertEqual(t.descendants("B"), ["E", "F", "G"])
26
+ self.assertEqual(t.leaves(), ["E", "F", "G", "H", "I", "D"])
27
+ self.assertEqual(t.leaf_count(), 6)
28
+ self.assertTrue(t.is_leaf("G"))
29
+ self.assertFalse(t.is_leaf("B"))
30
+
31
+ def test_traversal(self):
32
+ self.assertEqual(list(self.t.dfs()), ["A", "B", "E", "F", "G", "C", "H", "I", "D"])
33
+ self.assertEqual(list(self.t.bfs()), ["A", "B", "C", "D", "E", "F", "G", "H", "I"])
34
+ self.assertEqual(list(self.t.dfs_postorder()), ["E", "F", "G", "B", "H", "I", "C", "D", "A"])
35
+ self.assertEqual(list(self.t), list(self.t.dfs()))
36
+ self.assertEqual(list(self.t.bfs("C")), ["C", "H", "I"])
37
+ self.assertEqual(self.t.nodes("B"), ["B", "E", "F", "G"])
38
+ self.assertEqual(self.t.find("G"), ["G"])
39
+
40
+ def test_changes(self):
41
+ t = self.t
42
+ with self.assertRaises(ValueError):
43
+ t.move_node("B", "F")
44
+ with self.assertRaises(ValueError):
45
+ t.remove_node("B")
46
+ t.move_node("F", "C")
47
+ t.rename_node("C", "X")
48
+ self.assertEqual(t.path("F"), ["A", "X", "F"])
49
+ t.remove_node("I")
50
+ t.remove_subtree("B")
51
+ self.assertFalse(t.contains("B"))
52
+ self.assertFalse(t.contains("E"))
53
+ self.assertTrue(t.contains("X"))
54
+
55
+ def test_invalid_and_copy(self):
56
+ with self.assertRaises(ValueError):
57
+ self.t.add_node("A", parent="B")
58
+ with self.assertRaises(KeyError):
59
+ self.t.add_node("Z", parent="missing")
60
+ with self.assertRaises(ValueError):
61
+ self.t.add_nodes(["new", "C"], parent="B")
62
+ self.assertFalse(self.t.contains("new"))
63
+ with self.assertRaises(TypeError):
64
+ self.t.add_nodes("xyz", parent="B")
65
+ copied = self.t.copy()
66
+ copied.rename_node("A", "ROOT")
67
+ self.assertEqual(self.t.root, "A")
68
+ self.assertEqual(copied.root, "ROOT")
69
+
70
+ def test_text(self):
71
+ expected = "A\n├── B\n│ ├── E\n│ ├── F\n│ └── G\n├── C\n│ ├── H\n│ └── I\n└── D"
72
+ self.assertEqual(self.t.to_text(), expected)
73
+ self.assertEqual(self.t.to_text("B"), "B\n├── E\n├── F\n└── G")
74
+ self.assertIn(chr(96) + "-- D", self.t.to_text(style="ascii"))
75
+ with io.StringIO() as output, redirect_stdout(output):
76
+ self.t.show("C")
77
+ self.assertEqual(output.getvalue(), "C\n├── H\n└── I\n")
78
+
79
+ def test_empty(self):
80
+ t = Tree()
81
+ self.assertEqual(t.height(), -1)
82
+ self.assertEqual(list(t.dfs()), [])
83
+ self.assertEqual(list(t.bfs()), [])
84
+ self.assertEqual(list(t.dfs_postorder()), [])
85
+ t.add_node("A")
86
+ t.remove_node("A")
87
+ self.assertIsNone(t.root)
88
+ self.t.remove_subtree("A")
89
+ self.assertEqual(self.t.size(), 0)
90
+
91
+ def test_deep(self):
92
+ t = Tree("0")
93
+ for n in range(1, 1200):
94
+ t.add_node(str(n), parent=str(n - 1))
95
+ self.assertEqual(t.height(), 1199)
96
+ self.assertEqual(len(t.to_text().splitlines()), 1200)
97
+ self.assertEqual(len(list(t.dfs_postorder())), 1200)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ unittest.main()