Coverage for railway/cli/new.py: 77%

1"""railway new command implementation."""

2

3 from enum import Enum

4 from pathlib import Path

5

6 import typer

7

8

9 class ComponentType(str, Enum):

10 """Type of component to create."""

11

12 entry = "entry"

13 node = "node"

14

15

16 def _is_railway_project() -> bool:

17 """Check if current directory is a Railway project."""

18 return (Path.cwd() / "src").exists()

19

20

21 def _write_file(path: Path, content: str) -> None:

22 """Write content to a file."""

23 path.write_text(content)

24

25

26 def _get_entry_template(name: str) -> str:

27 """Get basic entry point template."""

28 return f'''"""{name} entry point."""

29

30 from railway import entry_point, node

31 from loguru import logger

32

33

34@entry_point

35 def main():

36 """

37 {name} entry point.

38

39 TODO: Add your implementation here.

40 """

41 logger.info("Starting {name}")

42 # Your implementation here

43 return "Success"

44

45

46 if __name__ == "__main__":

47 main()

48'''

49

50

51 def _get_entry_example_template(name: str) -> str:

52 """Get example entry point template."""

53 return f'''"""{name} entry point with example implementation."""

54

55 from datetime import datetime

56

57 from railway import entry_point, node, pipeline

58 from loguru import logger

59

60

61@node

62 def fetch_data(date: str) -> dict:

63 """Fetch data for the given date."""

64 logger.info(f"Fetching data for {{date}}")

65 # Example: Replace with actual API call

66 return {{"date": date, "records": [1, 2, 3]}}

67

68

69@node

70 def process_data(data: dict) -> dict:

71 """Process the fetched data."""

72 logger.info(f"Processing {{len(data['records'])}} records")

73 return {{

74 "date": data["date"],

75 "summary": {{

76 "total": len(data["records"]),

77 "sum": sum(data["records"]),

78 }}

79 }}

80

81

82@entry_point

83 def main(date: str = None, dry_run: bool = False):

84 """

85 {name} entry point.

86

87 Args:

88 date: Target date (YYYY-MM-DD), defaults to today

89 dry_run: If True, don't make actual changes

90 """

91 if date is None:

92 date = datetime.now().strftime("%Y-%m-%d")

93

94 if dry_run:

95 logger.warning("DRY RUN mode - no actual changes")

96

97 result = pipeline(

98 fetch_data(date),

99 process_data,

100 )

101

102 logger.info(f"Result: {{result}}")

103 return result

104

105

106 if __name__ == "__main__":

107 main()

108'''

109

110

111 def _get_node_template(name: str) -> str:

112 """Get basic node template."""

113 return f'''"""{name} node."""

114

115 from railway import node

116 from loguru import logger

117

118

119@node

120 def {name}(data: dict) -> dict:

121 """

122 {name} node.

123

124 Args:

125 data: Input data

126

127 Returns:

128 Processed data

129

130 TODO: Add your implementation here.

131 """

132 logger.info(f"Processing in {name}")

133 # Your implementation here

134 return data

135'''

136

137

138 def _get_node_example_template(name: str) -> str:

139 """Get example node template."""

140 return f'''"""{name} node with example implementation."""

141

142 from railway import node

143 from loguru import logger

144

145

146@node(retry=True)

147 def {name}(data: dict) -> dict:

148 """

149 {name} node.

150

151 This is an example node that demonstrates:

152 - Type annotations

153 - Logging

154 - Error handling (via @node decorator)

155 - Return value

156

157 Args:

158 data: Input data dictionary

159

160 Returns:

161 Processed data dictionary

162 """

163 logger.info(f"Starting {name} with {{len(data)}} fields")

164

165 # Example processing

166 result = {{

167 **data,

168 "processed_by": "{name}",

169 "status": "completed",

170 }}

171

172 logger.debug(f"Processed result: {{result}}")

173 return result

174'''

175

176

177 def _get_node_test_template(name: str) -> str:

178 """Get test template for a node."""

179 class_name = "".join(word.title() for word in name.split("_"))

180 return f'''"""Tests for {name} node."""

181

182 import pytest

183 from unittest.mock import MagicMock, patch

184

185 from src.nodes.{name} import {name}

186

187

188 class Test{class_name}:

189 """Test suite for {name} node."""

190

191 def test_{name}_success(self):

192 """Test normal case."""

193 # TODO: Implement test

194 pass

195

196 def test_{name}_error(self):

197 """Test error case."""

198 # TODO: Implement test

199 pass

200'''

201

202

203 def _create_entry(name: str, example: bool, force: bool) -> None:

204 """Create a new entry point."""

205 file_path = Path.cwd() / "src" / f"{name}.py"

206

207 if file_path.exists() and not force: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 typer.echo(f"Error: {file_path} already exists. Use --force to overwrite.", err=True)

209 raise typer.Exit(1)

210

211 content = _get_entry_example_template(name) if example else _get_entry_template(name)

212 _write_file(file_path, content)

213

214 typer.echo(f"Created entry point: src/{name}.py")

215 typer.echo("Entry point is ready to use\n")

216 typer.echo("To run:")

217 typer.echo(f" railway run {name}")

218 typer.echo(f" # or: uv run python -m src.{name}")

219

220

221 def _create_node_test(name: str) -> None:

222 """Create test file for node."""

223 tests_dir = Path.cwd() / "tests" / "nodes"

224 if not tests_dir.exists(): 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 tests_dir.mkdir(parents=True)

226

227 test_file = tests_dir / f"test_{name}.py"

228 if test_file.exists(): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 return # Don't overwrite existing tests

230

231 content = _get_node_test_template(name)

232 _write_file(test_file, content)

233

234

235 def _create_node(name: str, example: bool, force: bool) -> None:

236 """Create a new node."""

237 nodes_dir = Path.cwd() / "src" / "nodes"

238 if not nodes_dir.exists(): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 nodes_dir.mkdir(parents=True)

240 (nodes_dir / "__init__.py").write_text('"""Node modules."""\n')

241

242 file_path = nodes_dir / f"{name}.py"

243

244 if file_path.exists() and not force: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 typer.echo(f"Error: {file_path} already exists. Use --force to overwrite.", err=True)

246 raise typer.Exit(1)

247

248 content = _get_node_example_template(name) if example else _get_node_template(name)

249 _write_file(file_path, content)

250

251 # Create test file

252 _create_node_test(name)

253

254 typer.echo(f"Created node: src/nodes/{name}.py")

255 typer.echo(f"Created test: tests/nodes/test_{name}.py\n")

256 typer.echo("To use in an entry point:")

257 typer.echo(f" from src.nodes.{name} import {name}")

258

259

260 def new(

261 component_type: ComponentType = typer.Argument(..., help="Type: entry or node"),

262 name: str = typer.Argument(..., help="Name of the component"),

263 example: bool = typer.Option(False, "--example", help="Generate with example code"),

264 force: bool = typer.Option(False, "--force", help="Overwrite if exists"),

265) -> None:

266 """

267 Create a new entry point or node.

268

269 Examples:

270 railway new entry daily_report

271 railway new node fetch_data --example

272 """

273 # Validate we're in a project

274 if not _is_railway_project(): 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 typer.echo("Error: Not in a Railway project (src/ directory not found)", err=True)

276 raise typer.Exit(1)

277

278 if component_type == ComponentType.entry:

279 _create_entry(name, example, force)

280 else:

281 _create_node(name, example, force)