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

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

2

3 from pathlib import Path

4 from typing import Callable

5

6 import typer

7

8

9 def _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

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

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

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

24

25

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

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

28 path.write_text(content)

29

30

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

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

33 content = f'''[project]

34 name = "{project_name}"

35 version = "0.1.0"

36 description = "Railway framework automation project"

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

38 dependencies = [

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]

48 dev = [

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]

56 requires = ["hatchling"]

57 build-backend = "hatchling.build"

58'''

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

60

61

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

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

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

65 RAILWAY_ENV=development

66

67# Application

68 APP_NAME={project_name}

69

70# Log Level Override (optional)

71 LOG_LEVEL=DEBUG

72'''

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

74

75

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

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

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

79

80 app:

81 name: {project_name}

82 version: "0.1.0"

83

84 api:

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

86 timeout: 30

87

88 logging:

89 level: DEBUG

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

91 handlers:

92 - type: console

93 level: DEBUG

94

95 retry:

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

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

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

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

107

108 from 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

114 settings = get_settings()

115'''

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

117

118

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

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

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

122

123 Welcome 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

137 railway new entry hello --example

138```

139

140 This creates `src/hello.py`:

141

142```python

143 from railway import entry_point, node

144 from loguru import logger

145

146@node

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

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

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

150

151@entry_point

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

153 message = greet(name)

154 print(message)

155 return message

156

157 if __name__ == "__main__":

158 main()

159```

160

161### 1.2 Run it

162

163```bash

164 railway run hello

165# Output: Hello, World!

166

167 railway run hello -- --name Alice

168# Output: Hello, Alice!

169```

170

171---

172

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

174

175 Railway handles errors automatically with the @node decorator.

176

177### 2.1 Create a node that can fail

178

179```bash

180 railway new node divide

181```

182

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

184

185```python

186 from railway import node

187

188@node

189 def 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

197 When 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

209 railway new node fetch_data --example

210 railway new node process_data --example

211```

212

213### 3.2 Create a pipeline entry point

214

215```python

216 from railway import entry_point, pipeline

217 from src.nodes.fetch_data import fetch_data

218 from src.nodes.process_data import process_data

219

220@entry_point

221 def 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.

230 Subsequent 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

238 Edit `config/development.yaml`:

239

240```yaml

241 api:

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

243 timeout: 30

244

245 retry:

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

256 from railway import node

257 from src.settings import settings

258

259@node

260 def 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

273 pytest tests/

274```

275

276### 5.2 Write your own test

277

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

279

280```python

281# tests/nodes/test_divide.py

282 import pytest

283 from src.nodes.divide import divide

284

285 def test_divide_success():

286 result = divide(10, 2)

287 assert result == 5.0

288

289 def 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```

302 ModuleNotFoundError: 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```

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

313 base_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

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

327 2. **Configure logging**: Edit `config/development.yaml`

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

329

330 See the Railway Framework documentation for more details.

331'''

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

333

334

335 def _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/

343 dist/

344 build/

345

346# Environment

347.env

348.venv/

349 venv/

350

351# IDE

352.idea/

353.vscode/

354*.swp

355

356# Logs

357 logs/*.log

358

359# Testing

360.coverage

361 htmlcov/

362.pytest_cache/

363

364# mypy

365.mypy_cache/

366'''

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

368

369

370 def _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

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

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

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

385

386 import pytest

387'''

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

389

390

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

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

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

394

395 from railway import entry_point, node

396 from loguru import logger

397

398

399@node

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

401 """Greet someone."""

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

403 return f"Hello, {name}!"

404

405

406@entry_point

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

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

409 message = greet(name)

410 print(message)

411 return message

412

413

414 if __name__ == "__main__":

415 main()

416'''

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

418

419

420 def _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

452 def _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

469 def 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)