graphlint 0.1.0__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.
graphlint/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ # -*- coding: utf-8 -*-
2
+ """graphlint — Code Dependency Graph Analyzer."""
3
+
4
+ from typing import Any
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["query", "build", "configure", "__version__"]
9
+
10
+
11
+ def __getattr__(name: str) -> Any:
12
+ """Lazy import public API names."""
13
+ if name == "query":
14
+ from graphlint.api import query as _query
15
+
16
+ return _query
17
+ if name == "build":
18
+ from graphlint.api import build as _build
19
+
20
+ return _build
21
+ if name == "configure":
22
+ from graphlint.api import configure as _configure
23
+
24
+ return _configure
25
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,263 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Agent tool integration — install/uninstall graphlint prompts for AI coding tools.
3
+
4
+ Configures agent tools at the global level so graphlint's usage prompt is
5
+ available in every project the agent opens.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from typing import List, Tuple
13
+
14
+ AGENT_PROMPT = """# graphlint — Dead Code Detection for Python
15
+
16
+ ## When to Use It
17
+ - **After code modifications**: Run to check if your edits left behind dead or redundant code — components no longer reachable from any entry point
18
+ - **Before analyzing a codebase**: Run to verify the dependency graph is correctly built and all expected entry points are recognized
19
+
20
+ ## Quick Commands
21
+ ```bash
22
+ graphlint build --force # Build/rebuild index (Full codebase scan, time consuming for large codebase)
23
+ graphlint query # List dependency graphs (recommanded, auto incremental rebuild)
24
+ graphlint query --json # JSON output
25
+ graphlint query -g <id> --detail full # Full detail on one graph
26
+ graphlint config show # View current config
27
+ ```
28
+
29
+ Use the -h option in each command to query detailed instructions (use only when necessary).
30
+
31
+ ## Key Parameters
32
+ - `-g, --graph-id <int>` — Inspect a specific dependency graph
33
+ - `--json, -j` — Structured output (JSON)
34
+ - `-w, --warn-types <str>` — Filter: `dead_code`, `circular_ref`, `unused_import`
35
+ - `-t, --include-tests` — Include test files in analysis
36
+ - `-d, --detail <level>` — Detail: `auto`/`summary`/`full`/`minimal`
37
+ - `-r, --root-dir <path>` — Project root directory
38
+ - `-C, --exclude-clean` — Show only graphs with issues
39
+ - `-f, --force` — Force full index rebuild
40
+ - `--sort-by <field>` — Sort: `warnings`/`nodes`/`edges`/`name`
41
+
42
+ ## Usage Examples
43
+ ```bash
44
+ # Check for dead code after a refactor
45
+ graphlint query --json
46
+
47
+ # Inspect a specific component's connections
48
+ graphlint query -g 5 -d full
49
+
50
+ # Scan all warnings sorted by severity
51
+ graphlint query -C --sort-by warnings --json
52
+ ```\
53
+ """
54
+
55
+ MARKER_START = "<!-- graphlint:start -->"
56
+ MARKER_END = "<!-- graphlint:end -->"
57
+
58
+
59
+ def _prompt_block() -> str:
60
+ return f"\n{MARKER_START}\n{AGENT_PROMPT}\n{MARKER_END}\n"
61
+
62
+
63
+ def _expand(path: str) -> str:
64
+ """Expand ~ to home directory, normalize separators."""
65
+ return os.path.normpath(os.path.expanduser(path))
66
+
67
+
68
+ # Tool definitions: (id, display_name, global_config_path, description)
69
+ # All paths use ~ which is expanded at install/uninstall time.
70
+ TOOLS: List[Tuple[str, str, str, str]] = [
71
+ (
72
+ "opencode",
73
+ "OpenCode CLI",
74
+ "~/.config/opencode/AGENTS.md",
75
+ "Global AGENTS.md — read by opencode in every project",
76
+ ),
77
+ (
78
+ "cursor",
79
+ "Cursor Editor",
80
+ "~/.cursorrules",
81
+ "Global .cursorrules — applies to all Cursor projects",
82
+ ),
83
+ (
84
+ "codex",
85
+ "Codex CLI",
86
+ "~/.codex/rules/graphlint.md",
87
+ "Global rules directory — recognized by Codex CLI",
88
+ ),
89
+ (
90
+ "cc",
91
+ "Claude Code (CLI)",
92
+ "~/.claude/CLAUDE.md",
93
+ "Global CLAUDE.md — read by Claude Code in every project",
94
+ ),
95
+ ]
96
+
97
+
98
+ def _prompt_installed_in(filepath: str) -> bool:
99
+ if not os.path.isfile(filepath):
100
+ return False
101
+ with open(filepath, encoding="utf-8") as f:
102
+ return MARKER_START in f.read()
103
+
104
+
105
+ def _write_prompt(filepath: str) -> bool:
106
+ try:
107
+ os.makedirs(os.path.dirname(filepath), exist_ok=True)
108
+ if os.path.isfile(filepath) and _prompt_installed_in(filepath):
109
+ return False
110
+ block = _prompt_block()
111
+ if os.path.isfile(filepath):
112
+ with open(filepath, "a", encoding="utf-8") as f:
113
+ f.write(block)
114
+ else:
115
+ with open(filepath, "w", encoding="utf-8") as f:
116
+ f.write(block)
117
+ return True
118
+ except OSError:
119
+ return False
120
+
121
+
122
+ def _remove_prompt(filepath: str) -> bool:
123
+ if not os.path.isfile(filepath):
124
+ return False
125
+ try:
126
+ with open(filepath, encoding="utf-8") as f:
127
+ content = f.read()
128
+ if MARKER_START not in content:
129
+ return False
130
+ start = content.index(MARKER_START)
131
+ end = content.index(MARKER_END) + len(MARKER_END)
132
+ new_content = content[:start] + content[end:]
133
+ lines = new_content.splitlines(keepends=True)
134
+ cleaned = []
135
+ prev_empty = False
136
+ for line in lines:
137
+ if line.strip() == "":
138
+ if prev_empty:
139
+ continue
140
+ prev_empty = True
141
+ else:
142
+ prev_empty = False
143
+ cleaned.append(line)
144
+ while cleaned and cleaned[0].strip() == "":
145
+ cleaned.pop(0)
146
+ while cleaned and cleaned[-1].strip() == "":
147
+ cleaned.pop()
148
+ new_content = "".join(cleaned)
149
+ if new_content.strip() == "":
150
+ os.remove(filepath)
151
+ else:
152
+ with open(filepath, "w", encoding="utf-8") as f:
153
+ f.write(new_content)
154
+ return True
155
+ except (OSError, ValueError):
156
+ return False
157
+
158
+
159
+ def _resolve_paths(cwd: str = None) -> List[Tuple[str, str, str, str, str]]:
160
+ """Resolve tool paths, expanded from ~."""
161
+ resolved = []
162
+ for tool_id, display_name, rel_path, desc in TOOLS:
163
+ full_path = _expand(rel_path)
164
+ resolved.append((tool_id, display_name, rel_path, full_path, desc))
165
+ return resolved
166
+
167
+
168
+ def _select_tools(message: str, resolved: List[Tuple]) -> List[Tuple]:
169
+ """Interactive multi-select prompt for agent tools."""
170
+ print(f"\n{message}\n")
171
+ for i, (_, display_name, rel_path, full_path, desc) in enumerate(resolved, 1):
172
+ status = "✓" if os.path.isfile(full_path) else " "
173
+ print(f" [{i}] {display_name:<20} {rel_path}")
174
+ print(f" {desc}")
175
+ print()
176
+ while True:
177
+ try:
178
+ raw = input(
179
+ "Enter numbers separated by comma (e.g. 1,3) or 'all': "
180
+ ).strip()
181
+ if raw.lower() == "all":
182
+ return list(resolved)
183
+ if not raw:
184
+ print("No selection. Aborting.")
185
+ return []
186
+ indices = [int(x.strip()) for x in raw.split(",")]
187
+ selected = []
188
+ for idx in indices:
189
+ if 1 <= idx <= len(resolved):
190
+ selected.append(resolved[idx - 1])
191
+ else:
192
+ print(f" Invalid number: {idx}")
193
+ break
194
+ else:
195
+ return selected
196
+ except (ValueError, KeyboardInterrupt):
197
+ print("Invalid input. Try again.")
198
+
199
+
200
+ def install_tools(cwd: str = None) -> str:
201
+ """Interactively install graphlint prompt to selected agent tools (global)."""
202
+ resolved = _resolve_paths(cwd)
203
+ selected = _select_tools("Select agent tool(s) to install graphlint prompt:", resolved)
204
+ if not selected:
205
+ return "No tools selected."
206
+ results = []
207
+ for tool_id, display_name, rel_path, full_path, desc in selected:
208
+ if _write_prompt(full_path):
209
+ results.append(f" ✓ {display_name} -> {full_path}")
210
+ else:
211
+ if _prompt_installed_in(full_path):
212
+ results.append(f" - {display_name} ({rel_path}) — already installed")
213
+ else:
214
+ results.append(f" ✗ {display_name} ({rel_path}) — failed to write")
215
+ return "Install results:\n" + "\n".join(results)
216
+
217
+
218
+ def uninstall_tools(cwd: str = None) -> str:
219
+ """Interactively uninstall graphlint prompt from selected agent tools."""
220
+ resolved = _resolve_paths(cwd)
221
+ installed = [
222
+ t for t in resolved if _prompt_installed_in(t[3])
223
+ ]
224
+ if not installed:
225
+ return "No agent tools with graphlint prompt found."
226
+ print("\nDetected installations:\n")
227
+ for i, (tool_id, display_name, rel_path, full_path, desc) in enumerate(
228
+ installed, 1
229
+ ):
230
+ print(f" [{i}] {display_name:<20} {rel_path}")
231
+ print()
232
+ while True:
233
+ try:
234
+ raw = input(
235
+ "Enter numbers to uninstall (comma separated) or 'all': "
236
+ ).strip()
237
+ if raw.lower() == "all":
238
+ selected = list(installed)
239
+ break
240
+ if not raw:
241
+ print("No selection. Aborting.")
242
+ return []
243
+ indices = [int(x.strip()) for x in raw.split(",")]
244
+ selected = []
245
+ for idx in indices:
246
+ if 1 <= idx <= len(installed):
247
+ selected.append(installed[idx - 1])
248
+ else:
249
+ print(f" Invalid number: {idx}")
250
+ break
251
+ else:
252
+ break
253
+ except (ValueError, KeyboardInterrupt):
254
+ print("Invalid input. Try again.")
255
+ if not selected:
256
+ return "No tools selected."
257
+ results = []
258
+ for tool_id, display_name, rel_path, full_path, desc in selected:
259
+ if _remove_prompt(full_path):
260
+ results.append(f" ✓ {display_name} ({rel_path}) — removed")
261
+ else:
262
+ results.append(f" ✗ {display_name} ({rel_path}) — failed to remove")
263
+ return "Uninstall results:\n" + "\n".join(results)
@@ -0,0 +1 @@
1
+ """AST parsing, dependency graph building, and entry point detection."""
@@ -0,0 +1,361 @@
1
+ # -*- coding: utf-8 -*-
2
+ """AST visitor — traverses AST to extract nodes, imports, and name usages."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import ast
7
+ from typing import List, Set
8
+
9
+ from graphlint.analyzer._types import NodeInfo
10
+ from graphlint.analyzer.decorators import DecoratorResolver
11
+ from graphlint.analyzer.imports import ImportAnalyzer, ImportInfo
12
+
13
+
14
+ class ASTVisitor(ast.NodeVisitor):
15
+ """Custom AST visitor that extracts nodes, imports, and name usages."""
16
+
17
+ def __init__(
18
+ self,
19
+ module_qualified: str,
20
+ file_path: str,
21
+ import_analyzer: ImportAnalyzer,
22
+ decorator_resolver: DecoratorResolver,
23
+ ) -> None:
24
+ """Initialize the AST visitor."""
25
+ super().__init__()
26
+ self.module_qualified: str = module_qualified
27
+ self.file_path: str = file_path
28
+ self.import_analyzer: ImportAnalyzer = import_analyzer
29
+ self.decorator_resolver: DecoratorResolver = decorator_resolver
30
+
31
+ self.nodes: List[NodeInfo] = []
32
+ self.imports: List[ImportInfo] = []
33
+ self.name_usages: Set[str] = set()
34
+
35
+ self._context: List[str] = [module_qualified]
36
+ self._current_class_id: int = 0
37
+ self._current_func_id: int = 0
38
+ self._node_id: int = 1
39
+
40
+ # ------------------------------------------------------------------
41
+ # Generic visit
42
+ # ------------------------------------------------------------------
43
+
44
+ def visit(self, node: ast.AST) -> None:
45
+ """Override visit to gracefully degrade on error."""
46
+ try:
47
+ super().visit(node)
48
+ except Exception as exc:
49
+ import sys
50
+
51
+ print(
52
+ f"[graphlint] AST visit error in {self.file_path}: {exc}",
53
+ file=sys.stderr,
54
+ )
55
+
56
+ def generic_visit(self, node: ast.AST) -> None:
57
+ """Generic visit: collect name usages."""
58
+ if isinstance(node, ast.Name):
59
+ if isinstance(node.ctx, ast.Load):
60
+ self.name_usages.add(node.id)
61
+ elif isinstance(node, ast.Attribute):
62
+ self.name_usages.add(node.attr)
63
+ if isinstance(node.value, ast.Name):
64
+ self.name_usages.add(node.value.id)
65
+ super().generic_visit(node)
66
+
67
+ # ------------------------------------------------------------------
68
+ # Import visit
69
+ # ------------------------------------------------------------------
70
+
71
+ def visit_Import(self, node: ast.Import) -> None:
72
+ """Process import xxx statements."""
73
+ infos = self.import_analyzer.analyze_import(node)
74
+ self.imports.extend(infos)
75
+ self.generic_visit(node)
76
+
77
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
78
+ """Process from xxx import yyy statements."""
79
+ infos = self.import_analyzer.analyze_import(node)
80
+ self.imports.extend(infos)
81
+ self.generic_visit(node)
82
+
83
+ # ------------------------------------------------------------------
84
+ # Class definition
85
+ # ------------------------------------------------------------------
86
+
87
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
88
+ """Process a class definition."""
89
+ qualified = ".".join(self._context + [node.name])
90
+ dec_infos = self.decorator_resolver.extract_decorator_names(
91
+ node.decorator_list, self.module_qualified
92
+ )
93
+ dec_names = [d.qualified_name for d in dec_infos]
94
+ docstring = self._get_docstring(node)
95
+ is_deprecated, dep_msg = DecoratorResolver.check_deprecated(
96
+ node.decorator_list, docstring
97
+ )
98
+
99
+ class_node = NodeInfo(
100
+ file_id=0,
101
+ name=node.name,
102
+ qualified_name=qualified,
103
+ node_type="class",
104
+ line_start=node.lineno,
105
+ line_end=node.end_lineno or node.lineno,
106
+ col_offset=node.col_offset,
107
+ parent_node_id=0,
108
+ is_deprecated=is_deprecated,
109
+ deprecation_msg=dep_msg,
110
+ type_annotation="",
111
+ is_async=False,
112
+ decorators=dec_names,
113
+ docstring=docstring,
114
+ is_entry=False,
115
+ )
116
+ class_node_id = self._add_node(class_node)
117
+ prev_class_id = self._current_class_id
118
+ self._current_class_id = class_node_id
119
+ self._context.append(node.name)
120
+
121
+ for base in node.bases:
122
+ self.visit(base)
123
+ for dec in node.decorator_list:
124
+ self.visit(dec)
125
+ for item in node.body:
126
+ self.visit(item)
127
+
128
+ self._context.pop()
129
+ self._current_class_id = prev_class_id
130
+
131
+ # ------------------------------------------------------------------
132
+ # Function definition
133
+ # ------------------------------------------------------------------
134
+
135
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
136
+ """Process a function definition."""
137
+ self._handle_function(node, is_async=False)
138
+
139
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
140
+ """Process an async function definition."""
141
+ self._handle_function(node, is_async=True)
142
+
143
+ def _handle_function(
144
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef, is_async: bool
145
+ ) -> None:
146
+ """Shared logic for function/method definitions."""
147
+ is_method = self._current_class_id != 0
148
+ qualified = ".".join(self._context + [node.name])
149
+ node_type = "method" if is_method else "function"
150
+
151
+ dec_infos = self.decorator_resolver.extract_decorator_names(
152
+ node.decorator_list, self.module_qualified
153
+ )
154
+ dec_names = [d.qualified_name for d in dec_infos]
155
+ docstring = self._get_docstring(node)
156
+ is_deprecated, dep_msg = DecoratorResolver.check_deprecated(
157
+ node.decorator_list, docstring
158
+ )
159
+
160
+ type_ann = ""
161
+ if node.returns:
162
+ try:
163
+ type_ann = ast.unparse(node.returns)
164
+ except Exception:
165
+ type_ann = ""
166
+
167
+ func_node = NodeInfo(
168
+ file_id=0,
169
+ name=node.name,
170
+ qualified_name=qualified,
171
+ node_type=node_type,
172
+ line_start=node.lineno,
173
+ line_end=node.end_lineno or node.lineno,
174
+ col_offset=node.col_offset,
175
+ parent_node_id=self._current_class_id,
176
+ is_deprecated=is_deprecated,
177
+ deprecation_msg=dep_msg,
178
+ type_annotation=type_ann,
179
+ is_async=is_async,
180
+ decorators=dec_names,
181
+ docstring=docstring,
182
+ is_entry=False,
183
+ )
184
+ func_node_id = self._add_node(func_node)
185
+
186
+ for dec in node.decorator_list:
187
+ self.visit(dec)
188
+ if node.returns:
189
+ self.visit(node.returns)
190
+ if hasattr(node, "args") and isinstance(node.args, ast.arguments):
191
+ for arg in ast.iter_child_nodes(node.args):
192
+ self.visit(arg)
193
+
194
+ prev_class_id = self._current_class_id
195
+ prev_func_id = self._current_func_id
196
+ self._current_class_id = 0
197
+ self._current_func_id = func_node_id
198
+ self._context.append(node.name)
199
+ for item in node.body:
200
+ self.visit(item)
201
+ self._context.pop()
202
+ self._current_class_id = prev_class_id
203
+ self._current_func_id = prev_func_id
204
+
205
+ # ------------------------------------------------------------------
206
+ # Variable / field
207
+ # ------------------------------------------------------------------
208
+
209
+ def visit_Assign(self, node: ast.Assign) -> None:
210
+ """Process assignment statements (module-level or class fields)."""
211
+ is_class_level = self._current_class_id != 0
212
+ is_func_level = not is_class_level and self._current_func_id != 0
213
+ node_type = "field" if is_class_level else "variable"
214
+ if is_class_level:
215
+ parent_id = self._current_class_id
216
+ elif is_func_level:
217
+ parent_id = self._current_func_id
218
+ else:
219
+ parent_id = 0
220
+
221
+ for target in node.targets:
222
+ self._extract_target(target, node_type, parent_id, node)
223
+
224
+ self.generic_visit(node)
225
+
226
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
227
+ """Process annotated assignment statements."""
228
+ if node.target is None:
229
+ self.generic_visit(node)
230
+ return
231
+
232
+ is_class_level = self._current_class_id != 0
233
+ is_func_level = not is_class_level and self._current_func_id != 0
234
+ node_type = "field" if is_class_level else "variable"
235
+ if is_class_level:
236
+ parent_id = self._current_class_id
237
+ elif is_func_level:
238
+ parent_id = self._current_func_id
239
+ else:
240
+ parent_id = 0
241
+
242
+ type_ann = ""
243
+ if node.annotation:
244
+ try:
245
+ type_ann = ast.unparse(node.annotation)
246
+ except Exception:
247
+ type_ann = ""
248
+ self.visit(node.annotation)
249
+
250
+ self._extract_annotated_target(
251
+ node.target, node_type, parent_id, node, type_ann
252
+ )
253
+ self.generic_visit(node)
254
+
255
+ # ------------------------------------------------------------------
256
+ # Target extraction helpers
257
+ # ------------------------------------------------------------------
258
+
259
+ def _extract_target(
260
+ self,
261
+ target: ast.expr,
262
+ node_type: str,
263
+ parent_id: int,
264
+ assign_node: ast.Assign,
265
+ ) -> None:
266
+ """Extract variable/field nodes from assignment targets."""
267
+ if isinstance(target, ast.Name):
268
+ qualified = ".".join(self._context + [target.id])
269
+ self._add_node(
270
+ NodeInfo(
271
+ file_id=0,
272
+ name=target.id,
273
+ qualified_name=qualified,
274
+ node_type=node_type,
275
+ line_start=assign_node.lineno,
276
+ line_end=assign_node.end_lineno or assign_node.lineno,
277
+ col_offset=assign_node.col_offset,
278
+ parent_node_id=parent_id,
279
+ )
280
+ )
281
+ elif isinstance(target, ast.Attribute):
282
+ if isinstance(target.value, ast.Attribute):
283
+ return
284
+ qualified = ".".join(self._context + [target.attr])
285
+ self._add_node(
286
+ NodeInfo(
287
+ file_id=0,
288
+ name=target.attr,
289
+ qualified_name=qualified,
290
+ node_type=node_type,
291
+ line_start=assign_node.lineno,
292
+ line_end=assign_node.end_lineno or assign_node.lineno,
293
+ col_offset=assign_node.col_offset,
294
+ parent_node_id=parent_id,
295
+ )
296
+ )
297
+ elif isinstance(target, (ast.Tuple, ast.List)):
298
+ for elt in target.elts:
299
+ self._extract_target(elt, node_type, parent_id, assign_node)
300
+
301
+ def _extract_annotated_target(
302
+ self,
303
+ target: ast.expr,
304
+ node_type: str,
305
+ parent_id: int,
306
+ node: ast.AnnAssign,
307
+ type_ann: str,
308
+ ) -> None:
309
+ """Extract nodes from annotated assignment targets."""
310
+ if isinstance(target, ast.Name):
311
+ qualified = ".".join(self._context + [target.id])
312
+ self._add_node(
313
+ NodeInfo(
314
+ file_id=0,
315
+ name=target.id,
316
+ qualified_name=qualified,
317
+ node_type=node_type,
318
+ line_start=node.lineno,
319
+ line_end=node.end_lineno or node.lineno,
320
+ col_offset=node.col_offset,
321
+ parent_node_id=parent_id,
322
+ type_annotation=type_ann,
323
+ )
324
+ )
325
+ elif isinstance(target, ast.Attribute):
326
+ if isinstance(target.value, ast.Attribute):
327
+ return
328
+ qualified = ".".join(self._context + [target.attr])
329
+ self._add_node(
330
+ NodeInfo(
331
+ file_id=0,
332
+ name=target.attr,
333
+ qualified_name=qualified,
334
+ node_type=node_type,
335
+ line_start=node.lineno,
336
+ line_end=node.end_lineno or node.lineno,
337
+ col_offset=node.col_offset,
338
+ parent_node_id=parent_id,
339
+ type_annotation=type_ann,
340
+ )
341
+ )
342
+
343
+ # ------------------------------------------------------------------
344
+ # Helpers
345
+ # ------------------------------------------------------------------
346
+
347
+ def _add_node(self, node: NodeInfo) -> int:
348
+ """Add a node and return its ID."""
349
+ node_id = self._node_id
350
+ self._node_id += 1
351
+ node.id = node_id
352
+ self.nodes.append(node)
353
+ return node_id
354
+
355
+ @staticmethod
356
+ def _get_docstring(node: ast.AST) -> str:
357
+ """Extract docstring (truncated to 500 chars)."""
358
+ ds = ast.get_docstring(node) or "" # type: ignore[arg-type]
359
+ if len(ds) > 500:
360
+ ds = ds[:497] + "..."
361
+ return ds