better-notion 0.9.9__py3-none-any.whl → 1.0.1__py3-none-any.whl

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,368 @@
1
+ """Dependency resolution for task management in the agents workflow system.
2
+
3
+ This module provides functionality to resolve task dependencies, determine
4
+ execution order, and find tasks that are ready to start.
5
+ """
6
+
7
+ from collections import deque
8
+ from typing import Any, AsyncIterator, Dict, List, Set, Tuple
9
+
10
+
11
+ class DependencyResolver:
12
+ """Resolves task dependencies and determines execution order.
13
+
14
+ This class provides methods to:
15
+ - Build dependency graphs from tasks
16
+ - Perform topological sorting to find execution order
17
+ - Detect circular dependencies
18
+ - Find tasks that are ready to start (all dependencies completed)
19
+ - Find tasks that are blocked by incomplete dependencies
20
+
21
+ Example:
22
+ >>> # Build dependency graph
23
+ >>> graph = {
24
+ ... "task-a": ["task-b"], # A depends on B
25
+ ... "task-b": [], # B has no dependencies
26
+ ... }
27
+ >>>
28
+ >>> # Get execution order
29
+ >>> order = DependencyResolver.topological_sort(graph)
30
+ >>> print(order) # ["task-b", "task-a"]
31
+ """
32
+
33
+ @staticmethod
34
+ def build_dependency_graph(
35
+ tasks: List[Any],
36
+ get_task_id: callable,
37
+ get_dependency_ids: callable,
38
+ ) -> Dict[str, List[str]]:
39
+ """Build dependency graph (adjacency list) from tasks.
40
+
41
+ Args:
42
+ tasks: List of task objects
43
+ get_task_id: Function to extract task ID from task object
44
+ get_dependency_ids: Function to extract dependency IDs from task object
45
+
46
+ Returns:
47
+ Dict mapping task_id → list of task_ids it depends on
48
+
49
+ Example:
50
+ >>> tasks = [task1, task2, task3]
51
+ >>> graph = DependencyResolver.build_dependency_graph(
52
+ ... tasks,
53
+ ... lambda t: t.id,
54
+ ... lambda t: t.dependencies
55
+ ... )
56
+ """
57
+ graph: Dict[str, List[str]] = {}
58
+
59
+ for task in tasks:
60
+ task_id = get_task_id(task)
61
+ deps = get_dependency_ids(task)
62
+
63
+ # Ensure deps is a list
64
+ if deps is None:
65
+ deps = []
66
+ elif isinstance(deps, str):
67
+ deps = [deps]
68
+ elif not isinstance(deps, list):
69
+ deps = list(deps)
70
+
71
+ graph[task_id] = deps
72
+
73
+ return graph
74
+
75
+ @staticmethod
76
+ def topological_sort(graph: Dict[str, List[str]]) -> List[str]:
77
+ """Perform topological sort to find execution order.
78
+
79
+ Args:
80
+ graph: Dependency graph (task_id → list of dependencies)
81
+
82
+ Returns:
83
+ List of task IDs in dependency order (dependencies before dependents)
84
+
85
+ Raises:
86
+ ValueError: If graph contains circular dependencies
87
+
88
+ Example:
89
+ >>> graph = {
90
+ ... "A": ["B"],
91
+ ... "B": ["C"],
92
+ ... "C": [],
93
+ ... }
94
+ >>> order = DependencyResolver.topological_sort(graph)
95
+ >>> print(order) # ["C", "B", "A"]
96
+ """
97
+ # Calculate in-degrees
98
+ in_degree: Dict[str, int] = {node: 0 for node in graph}
99
+
100
+ for node in graph:
101
+ for dep in graph[node]:
102
+ # Only count dependencies that are in our graph
103
+ if dep in in_degree:
104
+ in_degree[node] += 1
105
+
106
+ # Start with nodes that have no dependencies
107
+ queue = deque([node for node, degree in in_degree.items() if degree == 0])
108
+ result: List[str] = []
109
+
110
+ while queue:
111
+ node = queue.popleft()
112
+ result.append(node)
113
+
114
+ # Reduce in-degree for dependent nodes
115
+ for dependent, deps in graph.items():
116
+ if node in deps:
117
+ in_degree[dependent] -= 1
118
+ if in_degree[dependent] == 0:
119
+ queue.append(dependent)
120
+
121
+ # Check for cycles
122
+ if len(result) != len(graph):
123
+ # Find which nodes are in the cycle
124
+ processed = set(result)
125
+ cycle_nodes = [node for node in graph if node not in processed]
126
+
127
+ if cycle_nodes:
128
+ raise ValueError(
129
+ f"Circular dependency detected involving tasks: {', '.join(cycle_nodes[:3])}"
130
+ )
131
+
132
+ raise ValueError("Circular dependency detected in task graph")
133
+
134
+ return result
135
+
136
+ @staticmethod
137
+ def detect_cycles(graph: Dict[str, List[str]]) -> List[List[str]]:
138
+ """Detect circular dependencies in the graph.
139
+
140
+ Args:
141
+ graph: Dependency graph
142
+
143
+ Returns:
144
+ List of cycles (each cycle is a list of task IDs)
145
+
146
+ Example:
147
+ >>> graph = {
148
+ ... "A": ["B"],
149
+ ... "B": ["C"],
150
+ ... "C": ["A"], # Circular!
151
+ ... }
152
+ >>> cycles = DependencyResolver.detect_cycles(graph)
153
+ >>> print(len(cycles)) # 1
154
+ """
155
+ cycles: List[List[str]] = []
156
+ visited: Set[str] = set()
157
+ rec_stack: Set[str] = set()
158
+ path: List[str] = []
159
+
160
+ def dfs(node: str) -> None:
161
+ """Depth-first search to detect cycles."""
162
+ visited.add(node)
163
+ rec_stack.add(node)
164
+ path.append(node)
165
+
166
+ for neighbor in graph.get(node, []):
167
+ if neighbor not in visited:
168
+ dfs(neighbor)
169
+ elif neighbor in rec_stack:
170
+ # Found a cycle
171
+ cycle_start = path.index(neighbor)
172
+ cycle = path[cycle_start:] + [neighbor]
173
+ cycles.append(cycle)
174
+
175
+ path.pop()
176
+ rec_stack.remove(node)
177
+
178
+ for node in graph:
179
+ if node not in visited:
180
+ dfs(node)
181
+
182
+ return cycles
183
+
184
+ @staticmethod
185
+ def get_ready_tasks(
186
+ tasks: List[Any],
187
+ get_task_id: callable,
188
+ get_dependency_ids: callable,
189
+ get_task_status: callable,
190
+ completed_status: str = "Completed",
191
+ ) -> List[Any]:
192
+ """Find tasks that can be started (all dependencies completed).
193
+
194
+ Args:
195
+ tasks: List of task objects
196
+ get_task_id: Function to extract task ID
197
+ get_dependency_ids: Function to extract dependency IDs
198
+ get_task_status: Function to extract task status
199
+ completed_status: Status that means dependencies are satisfied
200
+
201
+ Returns:
202
+ List of tasks that are ready to start
203
+
204
+ Example:
205
+ >>> ready = DependencyResolver.get_ready_tasks(
206
+ ... tasks,
207
+ ... lambda t: t.id,
208
+ ... lambda t: t.depends_on,
209
+ ... lambda t: t.status
210
+ ... )
211
+ """
212
+ # Build task map for O(1) lookups
213
+ task_map = {get_task_id(t): t for t in tasks}
214
+
215
+ ready_tasks: List[Any] = []
216
+
217
+ for task in tasks:
218
+ task_id = get_task_id(task)
219
+ deps = get_dependency_ids(task)
220
+
221
+ if deps is None:
222
+ deps = []
223
+ elif isinstance(deps, str):
224
+ deps = [deps]
225
+ elif not isinstance(deps, list):
226
+ deps = list(deps)
227
+
228
+ # Check if all dependencies are satisfied
229
+ all_complete = True
230
+
231
+ for dep_id in deps:
232
+ dep_task = task_map.get(dep_id)
233
+
234
+ if dep_task is None:
235
+ # Dependency doesn't exist in our task list
236
+ # Assume it's not completed
237
+ all_complete = False
238
+ break
239
+
240
+ dep_status = get_task_status(dep_task)
241
+
242
+ if dep_status != completed_status:
243
+ all_complete = False
244
+ break
245
+
246
+ if all_complete and len(deps) > 0:
247
+ # Task has dependencies and all are complete
248
+ ready_tasks.append(task)
249
+ elif len(deps) == 0:
250
+ # Task has no dependencies - also ready
251
+ task_status = get_task_status(task)
252
+ # Only include if not already completed
253
+ if task_status != completed_status:
254
+ ready_tasks.append(task)
255
+
256
+ return ready_tasks
257
+
258
+ @staticmethod
259
+ def get_blocked_tasks(
260
+ tasks: List[Any],
261
+ get_task_id: callable,
262
+ get_dependency_ids: callable,
263
+ get_task_status: callable,
264
+ completed_status: str = "Completed",
265
+ ) -> List[Tuple[Any, List[Any]]]:
266
+ """Find tasks that are blocked by incomplete dependencies.
267
+
268
+ Args:
269
+ tasks: List of task objects
270
+ get_task_id: Function to extract task ID
271
+ get_dependency_ids: Function to extract dependency IDs
272
+ get_task_status: Function to extract task status
273
+ completed_status: Status that means dependencies are satisfied
274
+
275
+ Returns:
276
+ List of (task, blocking_tasks) tuples where blocking_tasks
277
+ are the incomplete dependencies
278
+
279
+ Example:
280
+ >>> blocked = DependencyResolver.get_blocked_tasks(
281
+ ... tasks,
282
+ ... lambda t: t.id,
283
+ ... lambda t: t.depends_on,
284
+ ... lambda t: t.status
285
+ ... )
286
+ >>> for task, blockers in blocked:
287
+ ... print(f"{task.id} blocked by {[b.id for b in blockers]}")
288
+ """
289
+ # Build task map
290
+ task_map = {get_task_id(t): t for t in tasks}
291
+
292
+ blocked: List[Tuple[Any, List[Any]]] = []
293
+
294
+ for task in tasks:
295
+ task_id = get_task_id(task)
296
+ deps = get_dependency_ids(task)
297
+
298
+ if deps is None:
299
+ deps = []
300
+ elif isinstance(deps, str):
301
+ deps = [deps]
302
+ elif not isinstance(deps, list):
303
+ deps = list(deps)
304
+
305
+ if not deps:
306
+ # No dependencies = not blocked
307
+ continue
308
+
309
+ # Check for incomplete dependencies
310
+ blocking: List[Any] = []
311
+
312
+ for dep_id in deps:
313
+ dep_task = task_map.get(dep_id)
314
+
315
+ if dep_task is None:
316
+ # Dependency doesn't exist - treat as blocker
317
+ continue
318
+
319
+ dep_status = get_task_status(dep_task)
320
+
321
+ if dep_status != completed_status:
322
+ blocking.append(dep_task)
323
+
324
+ if blocking:
325
+ blocked.append((task, blocking))
326
+
327
+ return blocked
328
+
329
+ @staticmethod
330
+ def get_execution_order(
331
+ tasks: List[Any],
332
+ get_task_id: callable,
333
+ get_dependency_ids: callable,
334
+ ) -> List[Any]:
335
+ """Get tasks in dependency execution order.
336
+
337
+ Args:
338
+ tasks: List of task objects
339
+ get_task_id: Function to extract task ID
340
+ get_dependency_ids: Function to extract dependency IDs
341
+
342
+ Returns:
343
+ List of tasks in execution order
344
+
345
+ Raises:
346
+ ValueError: If circular dependencies are detected
347
+
348
+ Example:
349
+ >>> ordered = DependencyResolver.get_execution_order(
350
+ ... tasks,
351
+ ... lambda t: t.id,
352
+ ... lambda t: t.depends_on
353
+ ... )
354
+ """
355
+ # Build dependency graph
356
+ graph = DependencyResolver.build_dependency_graph(
357
+ tasks,
358
+ get_task_id,
359
+ get_dependency_ids,
360
+ )
361
+
362
+ # Get execution order
363
+ order = DependencyResolver.topological_sort(graph)
364
+
365
+ # Build task map and return in order
366
+ task_map = {get_task_id(t): t for t in tasks}
367
+
368
+ return [task_map[task_id] for task_id in order if task_id in task_map]
@@ -0,0 +1,232 @@
1
+ """Project context management for agents workflow system.
2
+
3
+ This module provides the ProjectContext class for managing project context
4
+ stored in .notion files. Each project directory contains a .notion file
5
+ that identifies the project, organization, and role for that directory.
6
+ """
7
+
8
+ from dataclasses import dataclass, asdict
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import yaml
13
+
14
+
15
+ @dataclass
16
+ class ProjectContext:
17
+ """Project context stored in .notion file.
18
+
19
+ The .notion file identifies which project the current directory belongs to,
20
+ allowing CLI commands to know which Notion database to query and what
21
+ permissions the current role has.
22
+
23
+ Attributes:
24
+ project_id: Notion page ID for the project
25
+ project_name: Human-readable project name
26
+ org_id: Notion page ID for the organization
27
+ role: Current role for this project context (default: Developer)
28
+
29
+ Example:
30
+ >>> context = ProjectContext.create(
31
+ ... project_id="abc123",
32
+ ... project_name="my-project",
33
+ ... org_id="org789",
34
+ ... role="Developer"
35
+ ... )
36
+ >>> print(context.project_id)
37
+ 'abc123'
38
+
39
+ >>> # Load from current directory
40
+ >>> context = ProjectContext.from_current_directory()
41
+ >>> if context:
42
+ ... print(f"Working on {context.project_name} as {context.role}")
43
+ """
44
+
45
+ project_id: str
46
+ project_name: str
47
+ org_id: str
48
+ role: str = "Developer"
49
+
50
+ @classmethod
51
+ def from_current_directory(cls) -> Optional["ProjectContext"]:
52
+ """Load .notion file from current directory or parent directories.
53
+
54
+ This method searches up the directory tree from the current working
55
+ directory until it finds a .notion file. This allows commands to work
56
+ from any subdirectory within a project.
57
+
58
+ Returns:
59
+ ProjectContext if .notion file is found, None otherwise
60
+
61
+ Example:
62
+ >>> # In /Users/dev/projects/my-project/src
63
+ >>> # .notion is in /Users/dev/projects/my-project
64
+ >>> context = ProjectContext.from_current_directory()
65
+ >>> # Will find and load the .notion file from parent directory
66
+ """
67
+ cwd = Path.cwd()
68
+
69
+ # Search up the directory tree
70
+ for parent in [cwd, *cwd.parents]:
71
+ notion_file = parent / ".notion"
72
+
73
+ if notion_file.exists() and notion_file.is_file():
74
+ try:
75
+ with open(notion_file, encoding="utf-8") as f:
76
+ data = yaml.safe_load(f)
77
+
78
+ if data and isinstance(data, dict):
79
+ return cls(**data)
80
+
81
+ except (yaml.YAMLError, TypeError, ValueError) as e:
82
+ # Invalid YAML or data structure
83
+ # Log and continue searching
84
+ continue
85
+
86
+ return None
87
+
88
+ @classmethod
89
+ def from_path(cls, path: Path) -> Optional["ProjectContext"]:
90
+ """Load .notion file from specific path.
91
+
92
+ Args:
93
+ path: Path to directory containing .notion file
94
+
95
+ Returns:
96
+ ProjectContext if .notion file exists and is valid, None otherwise
97
+
98
+ Example:
99
+ >>> path = Path("/Users/dev/projects/my-project")
100
+ >>> context = ProjectContext.from_path(path)
101
+ """
102
+ notion_file = path / ".notion"
103
+
104
+ if not notion_file.exists() or not notion_file.is_file():
105
+ return None
106
+
107
+ try:
108
+ with open(notion_file, encoding="utf-8") as f:
109
+ data = yaml.safe_load(f)
110
+
111
+ if data and isinstance(data, dict):
112
+ return cls(**data)
113
+
114
+ except (yaml.YAMLError, TypeError, ValueError):
115
+ return None
116
+
117
+ @classmethod
118
+ def create(
119
+ cls,
120
+ project_id: str,
121
+ project_name: str,
122
+ org_id: str,
123
+ role: str = "Developer",
124
+ path: Optional[Path] = None,
125
+ ) -> "ProjectContext":
126
+ """Create new .notion file with project context.
127
+
128
+ Args:
129
+ project_id: Notion page ID for the project
130
+ project_name: Human-readable project name
131
+ org_id: Notion page ID for the organization
132
+ role: Role for this project context (default: Developer)
133
+ path: Directory where to create .notion file (default: cwd)
134
+
135
+ Returns:
136
+ New ProjectContext instance
137
+
138
+ Raises:
139
+ IOError: If unable to write to the .notion file
140
+
141
+ Example:
142
+ >>> context = ProjectContext.create(
143
+ ... project_id="abc123def456",
144
+ ... project_name="my-awesome-project",
145
+ ... org_id="org789xyz012",
146
+ ... role="Developer",
147
+ ... path=Path.cwd()
148
+ ... )
149
+ """
150
+ if path is None:
151
+ path = Path.cwd()
152
+
153
+ context = cls(
154
+ project_id=project_id,
155
+ project_name=project_name,
156
+ org_id=org_id,
157
+ role=role,
158
+ )
159
+
160
+ notion_file = path / ".notion"
161
+
162
+ with open(notion_file, "w", encoding="utf-8") as f:
163
+ yaml.dump(asdict(context), f, default_flow_style=False, sort_keys=False)
164
+
165
+ return context
166
+
167
+ def save(self, path: Optional[Path] = None) -> None:
168
+ """Save context to .notion file.
169
+
170
+ Args:
171
+ path: Directory where to save .notion file (default: cwd)
172
+
173
+ Raises:
174
+ IOError: If unable to write to the .notion file
175
+
176
+ Example:
177
+ >>> context.role = "PM"
178
+ >>> context.save()
179
+ """
180
+ if path is None:
181
+ path = Path.cwd()
182
+
183
+ notion_file = path / ".notion"
184
+
185
+ with open(notion_file, "w", encoding="utf-8") as f:
186
+ yaml.dump(asdict(self), f, default_flow_style=False, sort_keys=False)
187
+
188
+ def update_role(self, new_role: str, path: Optional[Path] = None) -> None:
189
+ """Update project role and save to .notion file.
190
+
191
+ Args:
192
+ new_role: New role value
193
+ path: Directory where .notion file is located (default: cwd)
194
+
195
+ Example:
196
+ >>> context.update_role("PM")
197
+ >>> print(context.role)
198
+ 'PM'
199
+ """
200
+ self.role = new_role
201
+ self.save(path)
202
+
203
+ def has_permission(self, permission: str) -> bool:
204
+ """Check if current role has a specific permission.
205
+
206
+ This is a convenience method that will integrate with the
207
+ RoleManager class. For now, it returns True for all permissions.
208
+
209
+ Args:
210
+ permission: Permission string (e.g., "tasks:claim")
211
+
212
+ Returns:
213
+ True if role has permission, False otherwise
214
+
215
+ Example:
216
+ >>> if context.has_permission("tasks:claim"):
217
+ ... # Claim the task
218
+ """
219
+ # TODO: Integrate with RoleManager
220
+ # For now, allow all permissions
221
+ return True
222
+
223
+ def __repr__(self) -> str:
224
+ """String representation of project context."""
225
+ return (
226
+ f"ProjectContext("
227
+ f"project_id={self.project_id!r}, "
228
+ f"project_name={self.project_name!r}, "
229
+ f"org_id={self.org_id!r}, "
230
+ f"role={self.role!r}"
231
+ f")"
232
+ )