Coverage for railway / cli / init.py: 89%

75 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 00:06 +0900

1"""railway init command implementation.""" 

2 

3from pathlib import Path 

4from typing import Callable 

5 

6import typer 

7 

8 

9def _validate_project_name(name: str) -> str: 

10 """ 

11 Validate and normalize project name. 

12 

13 Replaces dashes with underscores for Python compatibility. 

14 """ 

15 normalized = name.replace("-", "_") 

16 if not normalized.isidentifier(): 16 ↛ 17line 16 didn't jump to line 17 because the condition on line 16 was never true

17 raise typer.BadParameter(f"'{name}' is not a valid Python identifier") 

18 return normalized 

19 

20 

21def _create_directory(path: Path) -> None: 

22 """Create a directory if it doesn't exist.""" 

23 path.mkdir(parents=True, exist_ok=True) 

24 

25 

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

27 """Write content to a file.""" 

28 path.write_text(content) 

29 

30 

31def _create_pyproject_toml(project_path: Path, project_name: str, python_version: str) -> None: 

32 """Create pyproject.toml file.""" 

33 content = f'''[project] 

34name = "{project_name}" 

35version = "0.1.0" 

36description = "Railway framework automation project" 

37requires-python = ">={python_version}" 

38dependencies = [ 

39 "railway-framework>=0.1.0", 

40 "loguru>=0.7.0", 

41 "pydantic>=2.0.0", 

42 "pydantic-settings>=2.0.0", 

43 "typer>=0.9.0", 

44 "pyyaml>=6.0.0", 

45] 

46 

47[project.optional-dependencies] 

48dev = [ 

49 "ruff>=0.1.0", 

50 "mypy>=1.7.0", 

51 "pytest>=7.4.0", 

52 "pytest-cov>=4.1.0", 

53] 

54 

55[build-system] 

56requires = ["hatchling"] 

57build-backend = "hatchling.build" 

58''' 

59 _write_file(project_path / "pyproject.toml", content) 

60 

61 

62def _create_env_example(project_path: Path, project_name: str) -> None: 

63 """Create .env.example file.""" 

64 content = f'''# Environment (development/staging/production) 

65RAILWAY_ENV=development 

66 

67# Application 

68APP_NAME={project_name} 

69 

70# Log Level Override (optional) 

71LOG_LEVEL=DEBUG 

72''' 

73 _write_file(project_path / ".env.example", content) 

74 

75 

76def _create_development_yaml(project_path: Path, project_name: str) -> None: 

77 """Create config/development.yaml file.""" 

78 content = f'''# Railway Framework Configuration - Development 

79 

80app: 

81 name: {project_name} 

82 version: "0.1.0" 

83 

84api: 

85 base_url: "https://api.example.com" 

86 timeout: 30 

87 

88logging: 

89 level: DEBUG 

90 format: "{{time:HH:mm:ss}} | {{level}} | {{message}}" 

91 handlers: 

92 - type: console 

93 level: DEBUG 

94 

95retry: 

96 default: 

97 max_attempts: 3 

98 min_wait: 2 

99 max_wait: 10 

100''' 

101 _write_file(project_path / "config" / "development.yaml", content) 

102 

103 

104def _create_settings_py(project_path: Path) -> None: 

105 """Create src/settings.py file.""" 

106 content = '''"""Application settings.""" 

107 

108from railway.core.settings import Settings, get_settings, reset_settings 

109 

110# Re-export for convenience 

111__all__ = ["Settings", "get_settings", "reset_settings", "settings"] 

112 

113# Lazy settings proxy 

114settings = get_settings() 

115''' 

116 _write_file(project_path / "src" / "settings.py", content) 

117 

118 

119def _create_tutorial_md(project_path: Path, project_name: str) -> None: 

120 """Create TUTORIAL.md file.""" 

121 content = f'''# {project_name} Tutorial 

122 

123Welcome to your Railway Framework project! This tutorial will guide you from zero to hero. 

124 

125## Prerequisites 

126 

127- Python 3.10+ 

128- uv installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`) 

129 

130--- 

131 

132## Step 1: Hello World (5 minutes) 

133 

134### 1.1 Create a simple entry point 

135 

136```bash 

137railway new entry hello --example 

138``` 

139 

140This creates `src/hello.py`: 

141 

142```python 

143from railway import entry_point, node 

144from loguru import logger 

145 

146@node 

147def greet(name: str) -> str: 

148 logger.info(f"Greeting {{name}}") 

149 return f"Hello, {{name}}!" 

150 

151@entry_point 

152def main(name: str = "World"): 

153 message = greet(name) 

154 print(message) 

155 return message 

156 

157if __name__ == "__main__": 

158 main() 

159``` 

160 

161### 1.2 Run it 

162 

163```bash 

164railway run hello 

165# Output: Hello, World! 

166 

167railway run hello -- --name Alice 

168# Output: Hello, Alice! 

169``` 

170 

171--- 

172 

173## Step 2: Error Handling (10 minutes) 

174 

175Railway handles errors automatically with the @node decorator. 

176 

177### 2.1 Create a node that can fail 

178 

179```bash 

180railway new node divide 

181``` 

182 

183Edit `src/nodes/divide.py`: 

184 

185```python 

186from railway import node 

187 

188@node 

189def divide(a: float, b: float) -> float: 

190 if b == 0: 

191 raise ValueError("Cannot divide by zero") 

192 return a / b 

193``` 

194 

195### 2.2 Errors are caught and logged 

196 

197When an error occurs: 

198- The error is logged with type and message 

199- A hint may be provided for common errors 

200- The log file location is shown 

201 

202--- 

203 

204## Step 3: Pipeline Processing (10 minutes) 

205 

206### 3.1 Create nodes 

207 

208```bash 

209railway new node fetch_data --example 

210railway new node process_data --example 

211``` 

212 

213### 3.2 Create a pipeline entry point 

214 

215```python 

216from railway import entry_point, pipeline 

217from src.nodes.fetch_data import fetch_data 

218from src.nodes.process_data import process_data 

219 

220@entry_point 

221def main(source: str): 

222 result = pipeline( 

223 fetch_data(source), # Initial value 

224 process_data, # Step 1 

225 ) 

226 return result 

227``` 

228 

229**Key concept:** The first argument to `pipeline()` is the initial value. 

230Subsequent arguments are functions that receive the previous result. 

231 

232--- 

233 

234## Step 4: Configuration (15 minutes) 

235 

236### 4.1 Edit config file 

237 

238Edit `config/development.yaml`: 

239 

240```yaml 

241api: 

242 base_url: "https://api.example.com" 

243 timeout: 30 

244 

245retry: 

246 default: 

247 max_attempts: 3 

248 nodes: 

249 fetch_data: 

250 max_attempts: 5 

251``` 

252 

253### 4.2 Use settings in your code 

254 

255```python 

256from railway import node 

257from src.settings import settings 

258 

259@node 

260def fetch_data() -> dict: 

261 url = settings.api.base_url + "/data" 

262 timeout = settings.api.timeout 

263 # Use url and timeout... 

264``` 

265 

266--- 

267 

268## Step 5: Testing (20 minutes) 

269 

270### 5.1 Run existing tests 

271 

272```bash 

273pytest tests/ 

274``` 

275 

276### 5.2 Write your own test 

277 

278When you create nodes with `railway new node`, test files are created automatically. 

279 

280```python 

281# tests/nodes/test_divide.py 

282import pytest 

283from src.nodes.divide import divide 

284 

285def test_divide_success(): 

286 result = divide(10, 2) 

287 assert result == 5.0 

288 

289def test_divide_by_zero(): 

290 with pytest.raises(ValueError): 

291 divide(10, 0) 

292``` 

293 

294--- 

295 

296## Step 6: Troubleshooting 

297 

298### Common Errors 

299 

300#### Error: "Module not found" 

301``` 

302ModuleNotFoundError: No module named 'src.nodes.fetch_data' 

303``` 

304 

305**Solution:** 

306- Make sure you're running from the project root 

307- Check that the file exists at the correct path 

308- Use `railway run` instead of `python -m` 

309 

310#### Error: "Configuration error" 

311``` 

312pydantic_core._pydantic_core.ValidationError: 1 validation error for APISettings 

313base_url 

314 Field required [type=missing, input_value={{}}, input_type=dict] 

315``` 

316 

317**Solution:** 

318- Check `config/development.yaml` has the required field 

319- Make sure `.env` has `RAILWAY_ENV=development` 

320- Verify the config file is valid YAML 

321 

322--- 

323 

324## Next Steps 

325 

3261. **Add retry handling**: Use `@node(retry=True)` 

3272. **Configure logging**: Edit `config/development.yaml` 

3283. **Add type hints**: Use Pydantic models for type-safe data 

329 

330See the Railway Framework documentation for more details. 

331''' 

332 _write_file(project_path / "TUTORIAL.md", content) 

333 

334 

335def _create_gitignore(project_path: Path) -> None: 

336 """Create .gitignore file.""" 

337 content = '''# Python 

338__pycache__/ 

339*.py[cod] 

340*.so 

341.Python 

342*.egg-info/ 

343dist/ 

344build/ 

345 

346# Environment 

347.env 

348.venv/ 

349venv/ 

350 

351# IDE 

352.idea/ 

353.vscode/ 

354*.swp 

355 

356# Logs 

357logs/*.log 

358 

359# Testing 

360.coverage 

361htmlcov/ 

362.pytest_cache/ 

363 

364# mypy 

365.mypy_cache/ 

366''' 

367 _write_file(project_path / ".gitignore", content) 

368 

369 

370def _create_init_files(project_path: Path) -> None: 

371 """Create __init__.py files.""" 

372 init_files = [ 

373 (project_path / "src" / "__init__.py", '"""Source package."""\n'), 

374 (project_path / "src" / "nodes" / "__init__.py", '"""Node modules."""\n'), 

375 (project_path / "src" / "common" / "__init__.py", '"""Common utilities."""\n'), 

376 (project_path / "tests" / "__init__.py", ""), 

377 ] 

378 for path, content in init_files: 

379 _write_file(path, content) 

380 

381 

382def _create_conftest_py(project_path: Path) -> None: 

383 """Create tests/conftest.py file.""" 

384 content = '''"""Pytest configuration.""" 

385 

386import pytest 

387''' 

388 _write_file(project_path / "tests" / "conftest.py", content) 

389 

390 

391def _create_example_entry(project_path: Path) -> None: 

392 """Create example entry point.""" 

393 content = '''"""Hello World entry point.""" 

394 

395from railway import entry_point, node 

396from loguru import logger 

397 

398 

399@node 

400def greet(name: str) -> str: 

401 """Greet someone.""" 

402 logger.info(f"Greeting {name}") 

403 return f"Hello, {name}!" 

404 

405 

406@entry_point 

407def main(name: str = "World"): 

408 """Simple hello world entry point.""" 

409 message = greet(name) 

410 print(message) 

411 return message 

412 

413 

414if __name__ == "__main__": 

415 main() 

416''' 

417 _write_file(project_path / "src" / "hello.py", content) 

418 

419 

420def _create_project_structure( 

421 project_path: Path, 

422 project_name: str, 

423 python_version: str, 

424 with_examples: bool, 

425) -> None: 

426 """Create all project directories and files.""" 

427 # Create directories (functional approach with map) 

428 directories = [ 

429 project_path / "src" / "nodes", 

430 project_path / "src" / "common", 

431 project_path / "tests" / "nodes", 

432 project_path / "config", 

433 project_path / "logs", 

434 ] 

435 list(map(_create_directory, directories)) 

436 

437 # Create files (using pure functions) 

438 _create_pyproject_toml(project_path, project_name, python_version) 

439 _create_env_example(project_path, project_name) 

440 _create_development_yaml(project_path, project_name) 

441 _create_settings_py(project_path) 

442 _create_tutorial_md(project_path, project_name) 

443 _create_gitignore(project_path) 

444 _create_init_files(project_path) 

445 _create_conftest_py(project_path) 

446 

447 # Create example if requested 

448 if with_examples: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 _create_example_entry(project_path) 

450 

451 

452def _show_success_output(project_name: str) -> None: 

453 """Display success message and next steps.""" 

454 typer.echo(f"\nCreated project: {project_name}\n") 

455 typer.echo("Project structure:") 

456 typer.echo(f" {project_name}/") 

457 typer.echo(" ├── src/") 

458 typer.echo(" ├── tests/") 

459 typer.echo(" ├── config/") 

460 typer.echo(" ├── .env.example") 

461 typer.echo(" └── TUTORIAL.md\n") 

462 typer.echo("Next steps:") 

463 typer.echo(f" 1. cd {project_name}") 

464 typer.echo(" 2. cp .env.example .env") 

465 typer.echo(" 3. Open TUTORIAL.md and follow the guide") 

466 typer.echo(" 4. railway new entry hello --example") 

467 

468 

469def init( 

470 project_name: str = typer.Argument(..., help="Name of the project to create"), 

471 python_version: str = typer.Option("3.10", help="Minimum Python version"), 

472 with_examples: bool = typer.Option(False, help="Include example entry points"), 

473) -> None: 

474 """ 

475 Create a new Railway Framework project. 

476 

477 Creates the project directory structure with all necessary files 

478 for a Railway-based automation project. 

479 """ 

480 # Validate project name 

481 normalized_name = _validate_project_name(project_name) 

482 

483 # Check if directory exists 

484 project_path = Path.cwd() / normalized_name 

485 if project_path.exists(): 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true

486 typer.echo(f"Error: Directory '{normalized_name}' already exists", err=True) 

487 raise typer.Exit(1) 

488 

489 # Create directory structure 

490 _create_project_structure(project_path, normalized_name, python_version, with_examples) 

491 

492 # Show success message 

493 _show_success_output(normalized_name)