msaas-folder-hierarchy 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.
@@ -0,0 +1,23 @@
1
+ node_modules/
2
+ dist/
3
+ .next/
4
+ .turbo/
5
+ *.pyc
6
+ __pycache__/
7
+ .venv/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ !.env.*.example
15
+ !.env.*.template
16
+ .DS_Store
17
+ coverage/
18
+
19
+ # Runtime artifacts
20
+ logs_llm/
21
+ vectors.db
22
+ vectors.db-shm
23
+ vectors.db-wal
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: msaas-folder-hierarchy
3
+ Version: 0.1.0
4
+ Summary: Materialized path tree structure for hierarchical folders with move, copy, and depth tracking
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: msaas-api-core
7
+ Requires-Dist: msaas-errors
8
+ Requires-Dist: pydantic>=2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "msaas-folder-hierarchy"
7
+ version = "0.1.0"
8
+ description = "Materialized path tree structure for hierarchical folders with move, copy, and depth tracking"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "msaas-api-core",
12
+ "msaas-errors",
13
+ "pydantic>=2.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=8.0",
19
+ "pytest-asyncio>=0.24",
20
+ ]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/folder_hierarchy"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ asyncio_mode = "auto"
28
+
29
+ [tool.ruff]
30
+ target-version = "py312"
31
+ line-length = 100
32
+
33
+ [tool.ruff.lint]
34
+ select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH"]
35
+
36
+ [tool.uv.sources]
37
+ msaas-api-core = { workspace = true }
38
+ msaas-errors = { workspace = true }
@@ -0,0 +1,19 @@
1
+ """Willian Folder Hierarchy -- Materialized path tree structure management."""
2
+
3
+ from folder_hierarchy.models import (
4
+ FolderCreate,
5
+ FolderMove,
6
+ FolderNode,
7
+ FolderTree,
8
+ FolderUpdate,
9
+ )
10
+ from folder_hierarchy.service import FolderHierarchyService
11
+
12
+ __all__ = [
13
+ "FolderCreate",
14
+ "FolderHierarchyService",
15
+ "FolderMove",
16
+ "FolderNode",
17
+ "FolderTree",
18
+ "FolderUpdate",
19
+ ]
@@ -0,0 +1,59 @@
1
+ """Folder hierarchy data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+
7
+
8
+ def _utcnow() -> datetime:
9
+ return datetime.now(UTC)
10
+
11
+
12
+ from typing import Any
13
+ from uuid import uuid4
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ class FolderNode(BaseModel):
19
+ id: str = Field(default_factory=lambda: str(uuid4()))
20
+ name: str
21
+ path: str = "/"
22
+ parent_id: str | None = None
23
+ depth: int = 0
24
+ tenant_id: str | None = None
25
+ metadata: dict[str, Any] = Field(default_factory=dict)
26
+ created_at: datetime = Field(default_factory=_utcnow)
27
+ updated_at: datetime = Field(default_factory=_utcnow)
28
+ children: list[FolderNode] = Field(default_factory=list)
29
+ item_count: int = 0
30
+
31
+ @property
32
+ def is_root(self) -> bool:
33
+ return self.parent_id is None
34
+
35
+ @property
36
+ def path_parts(self) -> list[str]:
37
+ return [p for p in self.path.split("/") if p]
38
+
39
+
40
+ class FolderCreate(BaseModel):
41
+ name: str = Field(min_length=1, max_length=255)
42
+ parent_id: str | None = None
43
+ tenant_id: str | None = None
44
+ metadata: dict[str, Any] = Field(default_factory=dict)
45
+
46
+
47
+ class FolderUpdate(BaseModel):
48
+ name: str | None = Field(default=None, min_length=1, max_length=255)
49
+ metadata: dict[str, Any] | None = None
50
+
51
+
52
+ class FolderMove(BaseModel):
53
+ target_parent_id: str | None = None
54
+
55
+
56
+ class FolderTree(BaseModel):
57
+ roots: list[FolderNode] = Field(default_factory=list)
58
+ total_count: int = 0
59
+ max_depth: int = 0
@@ -0,0 +1,216 @@
1
+ """Folder hierarchy service with in-memory storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+
7
+ from folder_hierarchy.models import (
8
+ FolderCreate,
9
+ FolderMove,
10
+ FolderNode,
11
+ FolderTree,
12
+ FolderUpdate,
13
+ )
14
+
15
+
16
+ class FolderHierarchyService:
17
+ """Manages hierarchical folder structures using materialized paths."""
18
+
19
+ def __init__(self) -> None:
20
+ self._folders: dict[str, FolderNode] = {}
21
+
22
+ def create(self, params: FolderCreate) -> FolderNode:
23
+ if params.parent_id:
24
+ parent = self._get_folder(params.parent_id)
25
+ self._check_duplicate(params.name, params.parent_id, params.tenant_id)
26
+ path = f"{parent.path}{parent.name}/"
27
+ depth = parent.depth + 1
28
+ else:
29
+ self._check_duplicate(params.name, None, params.tenant_id)
30
+ path = "/"
31
+ depth = 0
32
+
33
+ folder = FolderNode(
34
+ name=params.name,
35
+ path=path,
36
+ parent_id=params.parent_id,
37
+ depth=depth,
38
+ tenant_id=params.tenant_id,
39
+ metadata=params.metadata,
40
+ )
41
+ self._folders[folder.id] = folder
42
+ return folder
43
+
44
+ def get(self, folder_id: str) -> FolderNode:
45
+ return self._get_folder(folder_id)
46
+
47
+ def update(self, folder_id: str, params: FolderUpdate) -> FolderNode:
48
+ folder = self._get_folder(folder_id)
49
+ if params.name is not None:
50
+ self._check_duplicate(
51
+ params.name, folder.parent_id, folder.tenant_id, exclude_id=folder_id
52
+ )
53
+ old_path_prefix = f"{folder.path}{folder.name}/"
54
+ folder.name = params.name
55
+ new_path_prefix = f"{folder.path}{folder.name}/"
56
+ self._update_children_paths(old_path_prefix, new_path_prefix)
57
+ if params.metadata is not None:
58
+ folder.metadata = params.metadata
59
+ folder.updated_at = datetime.now(UTC)
60
+ return folder
61
+
62
+ def delete(self, folder_id: str, recursive: bool = False) -> list[str]:
63
+ folder = self._get_folder(folder_id)
64
+ children = self._get_children(folder_id)
65
+
66
+ if children and not recursive:
67
+ from errors import BusinessLogicError
68
+
69
+ raise BusinessLogicError(
70
+ message=f"Folder '{folder.name}' has {len(children)} children. "
71
+ "Use recursive=True to delete."
72
+ )
73
+
74
+ deleted_ids: list[str] = []
75
+ if recursive:
76
+ descendants = self._get_descendants(folder_id)
77
+ for d in reversed(descendants):
78
+ del self._folders[d.id]
79
+ deleted_ids.append(d.id)
80
+
81
+ del self._folders[folder_id]
82
+ deleted_ids.append(folder_id)
83
+ return deleted_ids
84
+
85
+ def move(self, folder_id: str, params: FolderMove) -> FolderNode:
86
+ folder = self._get_folder(folder_id)
87
+
88
+ if params.target_parent_id:
89
+ target = self._get_folder(params.target_parent_id)
90
+ if self._is_descendant(params.target_parent_id, folder_id):
91
+ from errors import BusinessLogicError
92
+
93
+ raise BusinessLogicError(message="Cannot move folder into its own descendant")
94
+ self._check_duplicate(
95
+ folder.name, params.target_parent_id, folder.tenant_id, exclude_id=folder_id
96
+ )
97
+ new_path = f"{target.path}{target.name}/"
98
+ new_depth = target.depth + 1
99
+ else:
100
+ self._check_duplicate(folder.name, None, folder.tenant_id, exclude_id=folder_id)
101
+ new_path = "/"
102
+ new_depth = 0
103
+
104
+ old_path_prefix = f"{folder.path}{folder.name}/"
105
+ folder.parent_id = params.target_parent_id
106
+ folder.path = new_path
107
+ folder.depth = new_depth
108
+ folder.updated_at = datetime.now(UTC)
109
+
110
+ new_path_prefix = f"{new_path}{folder.name}/"
111
+ self._update_children_paths(old_path_prefix, new_path_prefix)
112
+ self._update_children_depth(folder_id, new_depth + 1)
113
+
114
+ return folder
115
+
116
+ def get_tree(self, tenant_id: str | None = None) -> FolderTree:
117
+ roots = [
118
+ f
119
+ for f in self._folders.values()
120
+ if f.parent_id is None and (tenant_id is None or f.tenant_id == tenant_id)
121
+ ]
122
+ for root in roots:
123
+ self._build_tree(root)
124
+ max_depth = max((f.depth for f in self._folders.values()), default=0)
125
+ return FolderTree(
126
+ roots=roots,
127
+ total_count=len(self._folders),
128
+ max_depth=max_depth,
129
+ )
130
+
131
+ def get_children(self, folder_id: str) -> list[FolderNode]:
132
+ self._get_folder(folder_id)
133
+ return self._get_children(folder_id)
134
+
135
+ def get_ancestors(self, folder_id: str) -> list[FolderNode]:
136
+ folder = self._get_folder(folder_id)
137
+ ancestors: list[FolderNode] = []
138
+ current = folder
139
+ while current.parent_id:
140
+ parent = self._folders.get(current.parent_id)
141
+ if parent is None:
142
+ break
143
+ ancestors.append(parent)
144
+ current = parent
145
+ ancestors.reverse()
146
+ return ancestors
147
+
148
+ def get_breadcrumb(self, folder_id: str) -> list[dict[str, str]]:
149
+ ancestors = self.get_ancestors(folder_id)
150
+ folder = self._get_folder(folder_id)
151
+ return [{"id": a.id, "name": a.name} for a in ancestors] + [
152
+ {"id": folder.id, "name": folder.name}
153
+ ]
154
+
155
+ def _get_folder(self, folder_id: str) -> FolderNode:
156
+ folder = self._folders.get(folder_id)
157
+ if folder is None:
158
+ from errors import NotFoundError
159
+
160
+ raise NotFoundError(message=f"Folder {folder_id} not found")
161
+ return folder
162
+
163
+ def _get_children(self, parent_id: str) -> list[FolderNode]:
164
+ return [f for f in self._folders.values() if f.parent_id == parent_id]
165
+
166
+ def _get_descendants(self, folder_id: str) -> list[FolderNode]:
167
+ descendants: list[FolderNode] = []
168
+ children = self._get_children(folder_id)
169
+ for child in children:
170
+ descendants.append(child)
171
+ descendants.extend(self._get_descendants(child.id))
172
+ return descendants
173
+
174
+ def _is_descendant(self, folder_id: str, ancestor_id: str) -> bool:
175
+ current = self._folders.get(folder_id)
176
+ while current:
177
+ if current.id == ancestor_id:
178
+ return True
179
+ if current.parent_id is None:
180
+ break
181
+ current = self._folders.get(current.parent_id)
182
+ return False
183
+
184
+ def _check_duplicate(
185
+ self,
186
+ name: str,
187
+ parent_id: str | None,
188
+ tenant_id: str | None,
189
+ exclude_id: str | None = None,
190
+ ) -> None:
191
+ for f in self._folders.values():
192
+ if (
193
+ f.name == name
194
+ and f.parent_id == parent_id
195
+ and f.tenant_id == tenant_id
196
+ and f.id != exclude_id
197
+ ):
198
+ from errors import ConflictError
199
+
200
+ raise ConflictError(message=f"Folder '{name}' already exists in this location")
201
+
202
+ def _build_tree(self, node: FolderNode) -> None:
203
+ node.children = self._get_children(node.id)
204
+ for child in node.children:
205
+ self._build_tree(child)
206
+
207
+ def _update_children_paths(self, old_prefix: str, new_prefix: str) -> None:
208
+ for f in self._folders.values():
209
+ if f.path.startswith(old_prefix):
210
+ f.path = new_prefix + f.path[len(old_prefix) :]
211
+
212
+ def _update_children_depth(self, parent_id: str, depth: int) -> None:
213
+ children = self._get_children(parent_id)
214
+ for child in children:
215
+ child.depth = depth
216
+ self._update_children_depth(child.id, depth + 1)
File without changes
@@ -0,0 +1,113 @@
1
+ """Tests for folder hierarchy service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from folder_hierarchy.models import FolderCreate, FolderMove, FolderUpdate
8
+ from folder_hierarchy.service import FolderHierarchyService
9
+
10
+
11
+ class TestFolderHierarchyService:
12
+ def setup_method(self) -> None:
13
+ self.service = FolderHierarchyService()
14
+
15
+ def test_create_root_folder(self) -> None:
16
+ folder = self.service.create(FolderCreate(name="Documents"))
17
+ assert folder.name == "Documents"
18
+ assert folder.path == "/"
19
+ assert folder.depth == 0
20
+ assert folder.is_root is True
21
+
22
+ def test_create_nested_folder(self) -> None:
23
+ root = self.service.create(FolderCreate(name="Documents"))
24
+ child = self.service.create(FolderCreate(name="Reports", parent_id=root.id))
25
+ assert child.path == "/Documents/"
26
+ assert child.depth == 1
27
+ assert child.parent_id == root.id
28
+
29
+ def test_create_deeply_nested(self) -> None:
30
+ root = self.service.create(FolderCreate(name="A"))
31
+ b = self.service.create(FolderCreate(name="B", parent_id=root.id))
32
+ c = self.service.create(FolderCreate(name="C", parent_id=b.id))
33
+ assert c.path == "/A/B/"
34
+ assert c.depth == 2
35
+
36
+ def test_duplicate_name_same_parent_fails(self) -> None:
37
+ self.service.create(FolderCreate(name="Docs"))
38
+ with pytest.raises(Exception, match="already exists"):
39
+ self.service.create(FolderCreate(name="Docs"))
40
+
41
+ def test_same_name_different_parent_ok(self) -> None:
42
+ root1 = self.service.create(FolderCreate(name="A"))
43
+ root2 = self.service.create(FolderCreate(name="B"))
44
+ f1 = self.service.create(FolderCreate(name="Docs", parent_id=root1.id))
45
+ f2 = self.service.create(FolderCreate(name="Docs", parent_id=root2.id))
46
+ assert f1.id != f2.id
47
+
48
+ def test_update_name(self) -> None:
49
+ folder = self.service.create(FolderCreate(name="Old"))
50
+ updated = self.service.update(folder.id, FolderUpdate(name="New"))
51
+ assert updated.name == "New"
52
+
53
+ def test_delete_empty_folder(self) -> None:
54
+ folder = self.service.create(FolderCreate(name="Temp"))
55
+ deleted = self.service.delete(folder.id)
56
+ assert folder.id in deleted
57
+
58
+ def test_delete_with_children_fails(self) -> None:
59
+ root = self.service.create(FolderCreate(name="Root"))
60
+ self.service.create(FolderCreate(name="Child", parent_id=root.id))
61
+ with pytest.raises(Exception, match="children"):
62
+ self.service.delete(root.id)
63
+
64
+ def test_delete_recursive(self) -> None:
65
+ root = self.service.create(FolderCreate(name="Root"))
66
+ child = self.service.create(FolderCreate(name="Child", parent_id=root.id))
67
+ deleted = self.service.delete(root.id, recursive=True)
68
+ assert len(deleted) == 2
69
+
70
+ def test_move_folder(self) -> None:
71
+ a = self.service.create(FolderCreate(name="A"))
72
+ b = self.service.create(FolderCreate(name="B"))
73
+ child = self.service.create(FolderCreate(name="X", parent_id=a.id))
74
+ moved = self.service.move(child.id, FolderMove(target_parent_id=b.id))
75
+ assert moved.parent_id == b.id
76
+ assert moved.path == "/B/"
77
+
78
+ def test_move_into_descendant_fails(self) -> None:
79
+ root = self.service.create(FolderCreate(name="Root"))
80
+ child = self.service.create(FolderCreate(name="Child", parent_id=root.id))
81
+ with pytest.raises(Exception, match="descendant"):
82
+ self.service.move(root.id, FolderMove(target_parent_id=child.id))
83
+
84
+ def test_get_tree(self) -> None:
85
+ root = self.service.create(FolderCreate(name="Root"))
86
+ self.service.create(FolderCreate(name="Child", parent_id=root.id))
87
+ tree = self.service.get_tree()
88
+ assert tree.total_count == 2
89
+ assert len(tree.roots) == 1
90
+ assert len(tree.roots[0].children) == 1
91
+
92
+ def test_get_ancestors(self) -> None:
93
+ a = self.service.create(FolderCreate(name="A"))
94
+ b = self.service.create(FolderCreate(name="B", parent_id=a.id))
95
+ c = self.service.create(FolderCreate(name="C", parent_id=b.id))
96
+ ancestors = self.service.get_ancestors(c.id)
97
+ assert len(ancestors) == 2
98
+ assert ancestors[0].name == "A"
99
+ assert ancestors[1].name == "B"
100
+
101
+ def test_get_breadcrumb(self) -> None:
102
+ a = self.service.create(FolderCreate(name="A"))
103
+ b = self.service.create(FolderCreate(name="B", parent_id=a.id))
104
+ crumbs = self.service.get_breadcrumb(b.id)
105
+ assert len(crumbs) == 2
106
+ assert crumbs[0]["name"] == "A"
107
+ assert crumbs[1]["name"] == "B"
108
+
109
+ def test_tenant_isolation(self) -> None:
110
+ self.service.create(FolderCreate(name="X", tenant_id="t1"))
111
+ self.service.create(FolderCreate(name="X", tenant_id="t2"))
112
+ tree_t1 = self.service.get_tree(tenant_id="t1")
113
+ assert len(tree_t1.roots) == 1