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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:06 +0900
1"""railway init command implementation."""
3from pathlib import Path
4from typing import Callable
6import typer
9def _validate_project_name(name: str) -> str:
10 """
11 Validate and normalize project name.
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
21def _create_directory(path: Path) -> None:
22 """Create a directory if it doesn't exist."""
23 path.mkdir(parents=True, exist_ok=True)
26def _write_file(path: Path, content: str) -> None:
27 """Write content to a file."""
28 path.write_text(content)
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]
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]
55[build-system]
56requires = ["hatchling"]
57build-backend = "hatchling.build"
58'''
59 _write_file(project_path / "pyproject.toml", content)
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
67# Application
68APP_NAME={project_name}
70# Log Level Override (optional)
71LOG_LEVEL=DEBUG
72'''
73 _write_file(project_path / ".env.example", content)
76def _create_development_yaml(project_path: Path, project_name: str) -> None:
77 """Create config/development.yaml file."""
78 content = f'''# Railway Framework Configuration - Development
80app:
81 name: {project_name}
82 version: "0.1.0"
84api:
85 base_url: "https://api.example.com"
86 timeout: 30
88logging:
89 level: DEBUG
90 format: "{{time:HH:mm:ss}} | {{level}} | {{message}}"
91 handlers:
92 - type: console
93 level: DEBUG
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)
104def _create_settings_py(project_path: Path) -> None:
105 """Create src/settings.py file."""
106 content = '''"""Application settings."""
108from railway.core.settings import Settings, get_settings, reset_settings
110# Re-export for convenience
111__all__ = ["Settings", "get_settings", "reset_settings", "settings"]
113# Lazy settings proxy
114settings = get_settings()
115'''
116 _write_file(project_path / "src" / "settings.py", content)
119def _create_tutorial_md(project_path: Path, project_name: str) -> None:
120 """Create TUTORIAL.md file."""
121 content = f'''# {project_name} Tutorial
123Welcome to your Railway Framework project! This tutorial will guide you from zero to hero.
125## Prerequisites
127- Python 3.10+
128- uv installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
130---
132## Step 1: Hello World (5 minutes)
134### 1.1 Create a simple entry point
136```bash
137railway new entry hello --example
138```
140This creates `src/hello.py`:
142```python
143from railway import entry_point, node
144from loguru import logger
146@node
147def greet(name: str) -> str:
148 logger.info(f"Greeting {{name}}")
149 return f"Hello, {{name}}!"
151@entry_point
152def main(name: str = "World"):
153 message = greet(name)
154 print(message)
155 return message
157if __name__ == "__main__":
158 main()
159```
161### 1.2 Run it
163```bash
164railway run hello
165# Output: Hello, World!
167railway run hello -- --name Alice
168# Output: Hello, Alice!
169```
171---
173## Step 2: Error Handling (10 minutes)
175Railway handles errors automatically with the @node decorator.
177### 2.1 Create a node that can fail
179```bash
180railway new node divide
181```
183Edit `src/nodes/divide.py`:
185```python
186from railway import node
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```
195### 2.2 Errors are caught and logged
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
202---
204## Step 3: Pipeline Processing (10 minutes)
206### 3.1 Create nodes
208```bash
209railway new node fetch_data --example
210railway new node process_data --example
211```
213### 3.2 Create a pipeline entry point
215```python
216from railway import entry_point, pipeline
217from src.nodes.fetch_data import fetch_data
218from src.nodes.process_data import process_data
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```
229**Key concept:** The first argument to `pipeline()` is the initial value.
230Subsequent arguments are functions that receive the previous result.
232---
234## Step 4: Configuration (15 minutes)
236### 4.1 Edit config file
238Edit `config/development.yaml`:
240```yaml
241api:
242 base_url: "https://api.example.com"
243 timeout: 30
245retry:
246 default:
247 max_attempts: 3
248 nodes:
249 fetch_data:
250 max_attempts: 5
251```
253### 4.2 Use settings in your code
255```python
256from railway import node
257from src.settings import settings
259@node
260def fetch_data() -> dict:
261 url = settings.api.base_url + "/data"
262 timeout = settings.api.timeout
263 # Use url and timeout...
264```
266---
268## Step 5: Testing (20 minutes)
270### 5.1 Run existing tests
272```bash
273pytest tests/
274```
276### 5.2 Write your own test
278When you create nodes with `railway new node`, test files are created automatically.
280```python
281# tests/nodes/test_divide.py
282import pytest
283from src.nodes.divide import divide
285def test_divide_success():
286 result = divide(10, 2)
287 assert result == 5.0
289def test_divide_by_zero():
290 with pytest.raises(ValueError):
291 divide(10, 0)
292```
294---
296## Step 6: Troubleshooting
298### Common Errors
300#### Error: "Module not found"
301```
302ModuleNotFoundError: No module named 'src.nodes.fetch_data'
303```
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`
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```
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
322---
324## Next Steps
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
330See the Railway Framework documentation for more details.
331'''
332 _write_file(project_path / "TUTORIAL.md", content)
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/
346# Environment
347.env
348.venv/
349venv/
351# IDE
352.idea/
353.vscode/
354*.swp
356# Logs
357logs/*.log
359# Testing
360.coverage
361htmlcov/
362.pytest_cache/
364# mypy
365.mypy_cache/
366'''
367 _write_file(project_path / ".gitignore", content)
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)
382def _create_conftest_py(project_path: Path) -> None:
383 """Create tests/conftest.py file."""
384 content = '''"""Pytest configuration."""
386import pytest
387'''
388 _write_file(project_path / "tests" / "conftest.py", content)
391def _create_example_entry(project_path: Path) -> None:
392 """Create example entry point."""
393 content = '''"""Hello World entry point."""
395from railway import entry_point, node
396from loguru import logger
399@node
400def greet(name: str) -> str:
401 """Greet someone."""
402 logger.info(f"Greeting {name}")
403 return f"Hello, {name}!"
406@entry_point
407def main(name: str = "World"):
408 """Simple hello world entry point."""
409 message = greet(name)
410 print(message)
411 return message
414if __name__ == "__main__":
415 main()
416'''
417 _write_file(project_path / "src" / "hello.py", content)
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))
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)
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)
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")
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.
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)
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)
489 # Create directory structure
490 _create_project_structure(project_path, normalized_name, python_version, with_examples)
492 # Show success message
493 _show_success_output(normalized_name)