Coverage for railway / cli / list.py: 74%
86 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:06 +0900
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:06 +0900
1"""railway list command implementation."""
3import ast
4from pathlib import Path
5from typing import Optional
7import typer
10def _is_railway_project() -> bool:
11 """Check if current directory is a Railway project."""
12 return (Path.cwd() / "src").exists()
15def _extract_module_docstring(content: str) -> str | None:
16 """Extract module docstring from Python code."""
17 try:
18 tree = ast.parse(content)
19 docstring = ast.get_docstring(tree)
20 if docstring: 20 ↛ 23line 20 didn't jump to line 23 because the condition on line 20 was always true
21 # Return first line only
22 return docstring.split("\n")[0].strip()
23 return None
24 except Exception:
25 return None
28def _analyze_entry_file(file_path: Path) -> dict | None:
29 """Analyze a Python file for @entry_point decorator."""
30 try:
31 content = file_path.read_text()
33 # Check for @entry_point decorator
34 if "@entry_point" not in content: 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 return None
37 # Get module docstring
38 docstring = _extract_module_docstring(content)
40 return {
41 "name": file_path.stem,
42 "path": str(file_path.relative_to(Path.cwd())),
43 "description": docstring or "No description",
44 }
45 except Exception:
46 return None
49def _analyze_node_file(file_path: Path) -> dict | None:
50 """Analyze a Python file for @node decorator."""
51 try:
52 content = file_path.read_text()
54 # Check for @node decorator
55 if "@node" not in content: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 return None
58 # Get module docstring
59 docstring = _extract_module_docstring(content)
61 return {
62 "name": file_path.stem,
63 "path": str(file_path.relative_to(Path.cwd())),
64 "description": docstring or "No description",
65 }
66 except Exception:
67 return None
70def _find_entries() -> list[dict]:
71 """Find all entry points in src/."""
72 src_dir = Path.cwd() / "src"
73 skip_files = {"__init__.py", "settings.py"}
75 def should_analyze(py_file: Path) -> bool:
76 return not py_file.name.startswith("_") and py_file.name not in skip_files
78 files = [f for f in src_dir.glob("*.py") if should_analyze(f)]
79 entries = [_analyze_entry_file(f) for f in files]
80 return [e for e in entries if e is not None]
83def _find_nodes() -> list[dict]:
84 """Find all nodes in src/nodes/."""
85 nodes_dir = Path.cwd() / "src" / "nodes"
87 if not nodes_dir.exists(): 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 return []
90 def should_analyze(py_file: Path) -> bool:
91 return not py_file.name.startswith("_")
93 files = [f for f in nodes_dir.glob("*.py") if should_analyze(f)]
94 nodes = [_analyze_node_file(f) for f in files]
95 return [n for n in nodes if n is not None]
98def _count_tests() -> int:
99 """Count test files."""
100 tests_dir = Path.cwd() / "tests"
101 if not tests_dir.exists(): 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 return 0
104 return sum(1 for _ in tests_dir.rglob("test_*.py"))
107def _display_entries(entries: list[dict]) -> None:
108 """Display entry points."""
109 typer.echo("\nEntry Points:")
110 if not entries: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 typer.echo(" (none)")
112 return
114 for entry in entries:
115 typer.echo(f" * {entry['name']:20} {entry['description']}")
118def _display_nodes(nodes: list[dict]) -> None:
119 """Display nodes."""
120 typer.echo("\nNodes:")
121 if not nodes: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
122 typer.echo(" (none)")
123 return
125 for node in nodes:
126 typer.echo(f" * {node['name']:20} {node['description']}")
129def _display_all(entries: list[dict], nodes: list[dict], tests: int) -> None:
130 """Display all components."""
131 _display_entries(entries)
132 _display_nodes(nodes)
134 typer.echo(f"\nStatistics:")
135 typer.echo(f" {len(entries)} entry points, {len(nodes)} nodes, {tests} tests")
138def list_components(
139 filter_type: Optional[str] = typer.Argument(None, help="Filter: entries or nodes"),
140) -> None:
141 """
142 List entry points and nodes in the project.
144 Examples:
145 railway list # Show all
146 railway list entries # Show only entry points
147 railway list nodes # Show only nodes
148 """
149 if not _is_railway_project(): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 typer.echo("Error: Not in a Railway project (src/ directory not found)", err=True)
151 raise typer.Exit(1)
153 entries = _find_entries()
154 nodes = _find_nodes()
155 tests = _count_tests()
157 if filter_type == "entries": 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
158 _display_entries(entries)
159 elif filter_type == "nodes": 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 _display_nodes(nodes)
161 else:
162 _display_all(entries, nodes, tests)