railtracks-cli 1.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- railtracks_cli-1.1.2/LICENSE +0 -0
- railtracks_cli-1.1.2/PKG-INFO +83 -0
- railtracks_cli-1.1.2/README.md +67 -0
- railtracks_cli-1.1.2/build_cli.py +53 -0
- railtracks_cli-1.1.2/pyproject.toml +35 -0
- railtracks_cli-1.1.2/run_tests.py +31 -0
- railtracks_cli-1.1.2/src/railtracks_cli/__init__.py +589 -0
- railtracks_cli-1.1.2/src/railtracks_cli/__main__.py +6 -0
- railtracks_cli-1.1.2/tests/__init__.py +3 -0
- railtracks_cli-1.1.2/tests/test_init.py +305 -0
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: railtracks-cli
|
|
3
|
+
Version: 1.1.2
|
|
4
|
+
Summary: railtracks - A Python development server with file watching and JSON API
|
|
5
|
+
Author-email: Logan Underwood <logan@railtown.ai>, Levi Varsanyi <levi@railtown.ai>, Jaime Bueza <jaime@railtown.ai>, Amir Refaee <amir@railtown.ai>, Aryan Ballani <aryan@railtown.ai>, Tristan Brown <tristan@railtown.ai>
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: railtracks
|
|
10
|
+
Requires-Dist: watchdog >= 3.0.0
|
|
11
|
+
Project-URL: railtown, https://railtown.ai
|
|
12
|
+
Project-URL: railtracks, https://railtracks.org
|
|
13
|
+
Project-URL: repository, https://github.com/RailtownAI/railtracks
|
|
14
|
+
Project-URL: ui-repository, https://github.com/RailtownAI/railtracks-visualizer
|
|
15
|
+
|
|
16
|
+
# Railtracks CLI
|
|
17
|
+
|
|
18
|
+
A simple CLI to help developers visualize and debug their agents.
|
|
19
|
+
|
|
20
|
+
## What is Railtracks CLI?
|
|
21
|
+
|
|
22
|
+
Railtracks CLI is a development tool that provides:
|
|
23
|
+
|
|
24
|
+
- **Local Development Server**: A web-based visualizer for your railtracks projects
|
|
25
|
+
- **File Watching**: Automatic detection of JSON file changes in your project
|
|
26
|
+
- **JSON API**: RESTful endpoints to interact with your project data
|
|
27
|
+
- **Modern UI**: A downloadable frontend interface for project visualization
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
### 1. Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install railtracks-cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Initialize Your Project
|
|
38
|
+
|
|
39
|
+
First, initialize the railtracks environment in your project directory:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
railtracks init
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This command will:
|
|
46
|
+
|
|
47
|
+
- Create a `.railtracks` directory in your project
|
|
48
|
+
- Add `.railtracks` to your `.gitignore` file
|
|
49
|
+
- Download and extract the latest frontend UI
|
|
50
|
+
|
|
51
|
+
### 3. Start the Development Server
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
railtracks viz
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This starts the development server at `http://localhost:3030` with:
|
|
58
|
+
|
|
59
|
+
- File watching for JSON changes
|
|
60
|
+
- API endpoints for data access
|
|
61
|
+
- Portable Web-based visualizer interface that can be opened in any web environment (web, mobile, vs extension, chrome extension, etc)
|
|
62
|
+
|
|
63
|
+
## Project Structure
|
|
64
|
+
|
|
65
|
+
After initialization, your project will have this structure:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
your-project/
|
|
69
|
+
├── .railtracks/ # Railtracks working directory
|
|
70
|
+
│ ├── ui/ # Frontend interface files
|
|
71
|
+
│ └── *.json # Your project JSON files
|
|
72
|
+
├── .gitignore # Updated to exclude .railtracks
|
|
73
|
+
└── your-source-files/ # Your actual project files
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## File Watching
|
|
77
|
+
|
|
78
|
+
The CLI automatically watches the `.railtracks` directory for JSON file changes:
|
|
79
|
+
|
|
80
|
+
- **Real-time Detection**: Monitors file modifications with debouncing
|
|
81
|
+
- **JSON Validation**: Validates JSON syntax when files are accessed
|
|
82
|
+
- **Console Logging**: Reports file changes in the terminal
|
|
83
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Railtracks CLI
|
|
2
|
+
|
|
3
|
+
A simple CLI to help developers visualize and debug their agents.
|
|
4
|
+
|
|
5
|
+
## What is Railtracks CLI?
|
|
6
|
+
|
|
7
|
+
Railtracks CLI is a development tool that provides:
|
|
8
|
+
|
|
9
|
+
- **Local Development Server**: A web-based visualizer for your railtracks projects
|
|
10
|
+
- **File Watching**: Automatic detection of JSON file changes in your project
|
|
11
|
+
- **JSON API**: RESTful endpoints to interact with your project data
|
|
12
|
+
- **Modern UI**: A downloadable frontend interface for project visualization
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
### 1. Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install railtracks-cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### 2. Initialize Your Project
|
|
23
|
+
|
|
24
|
+
First, initialize the railtracks environment in your project directory:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
railtracks init
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This command will:
|
|
31
|
+
|
|
32
|
+
- Create a `.railtracks` directory in your project
|
|
33
|
+
- Add `.railtracks` to your `.gitignore` file
|
|
34
|
+
- Download and extract the latest frontend UI
|
|
35
|
+
|
|
36
|
+
### 3. Start the Development Server
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
railtracks viz
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This starts the development server at `http://localhost:3030` with:
|
|
43
|
+
|
|
44
|
+
- File watching for JSON changes
|
|
45
|
+
- API endpoints for data access
|
|
46
|
+
- Portable Web-based visualizer interface that can be opened in any web environment (web, mobile, vs extension, chrome extension, etc)
|
|
47
|
+
|
|
48
|
+
## Project Structure
|
|
49
|
+
|
|
50
|
+
After initialization, your project will have this structure:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
your-project/
|
|
54
|
+
├── .railtracks/ # Railtracks working directory
|
|
55
|
+
│ ├── ui/ # Frontend interface files
|
|
56
|
+
│ └── *.json # Your project JSON files
|
|
57
|
+
├── .gitignore # Updated to exclude .railtracks
|
|
58
|
+
└── your-source-files/ # Your actual project files
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## File Watching
|
|
62
|
+
|
|
63
|
+
The CLI automatically watches the `.railtracks` directory for JSON file changes:
|
|
64
|
+
|
|
65
|
+
- **Real-time Detection**: Monitors file modifications with debouncing
|
|
66
|
+
- **JSON Validation**: Validates JSON syntax when files are accessed
|
|
67
|
+
- **Console Logging**: Reports file changes in the terminal
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Build script for the railtracks CLI package in the monorepo.
|
|
4
|
+
This script helps build and install the CLI package locally for development.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_command(cmd, cwd=None):
|
|
13
|
+
"""Run a command and return the result."""
|
|
14
|
+
print(f"Running: {cmd}")
|
|
15
|
+
if cwd:
|
|
16
|
+
print(f"Working directory: {cwd}")
|
|
17
|
+
|
|
18
|
+
result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
|
|
19
|
+
|
|
20
|
+
if result.returncode != 0:
|
|
21
|
+
print(f"Error: {result.stderr}")
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
print(f"Success: {result.stdout}")
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
"""Build the CLI package."""
|
|
30
|
+
# Get the project root directory
|
|
31
|
+
project_root = Path(__file__).parent.parent
|
|
32
|
+
cli_dir = project_root / "railtracks-cli"
|
|
33
|
+
|
|
34
|
+
if not cli_dir.exists():
|
|
35
|
+
print(f"Error: CLI directory not found at {cli_dir}")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
print("Building railtracks CLI package...")
|
|
39
|
+
|
|
40
|
+
# Build the CLI package
|
|
41
|
+
if not run_command("python -m build", cwd=cli_dir):
|
|
42
|
+
print("Failed to build CLI package")
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
print("CLI package built successfully!")
|
|
46
|
+
print("\nTo install the CLI package locally for development:")
|
|
47
|
+
print("pip install -e railtracks-cli/")
|
|
48
|
+
print("\nTo install the main package with CLI:")
|
|
49
|
+
print("pip install -e .[cli]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["flit_core >=3.2,<4"]
|
|
3
|
+
build-backend = "flit_core.buildapi"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "railtracks-cli"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Logan Underwood", email = "logan@railtown.ai" },
|
|
9
|
+
{ name = "Levi Varsanyi", email = "levi@railtown.ai" },
|
|
10
|
+
{ name = "Jaime Bueza", email = "jaime@railtown.ai" },
|
|
11
|
+
{ name = "Amir Refaee", email = "amir@railtown.ai" },
|
|
12
|
+
{ name = "Aryan Ballani", email = "aryan@railtown.ai" },
|
|
13
|
+
{ name = "Tristan Brown", email = "tristan@railtown.ai" }
|
|
14
|
+
]
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
license = { file = "LICENSE" }
|
|
17
|
+
classifiers = ["License :: OSI Approved :: MIT License"]
|
|
18
|
+
dynamic = ["version", "description"]
|
|
19
|
+
|
|
20
|
+
dependencies = [
|
|
21
|
+
"railtracks",
|
|
22
|
+
"watchdog >= 3.0.0"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
railtracks = "https://railtracks.org"
|
|
27
|
+
repository = "https://github.com/RailtownAI/railtracks"
|
|
28
|
+
ui-repository = "https://github.com/RailtownAI/railtracks-visualizer"
|
|
29
|
+
railtown = "https://railtown.ai"
|
|
30
|
+
|
|
31
|
+
[tool.flit.module]
|
|
32
|
+
name = "railtracks_cli"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
railtracks = "railtracks_cli:main"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Simple test runner for railtracks-cli tests
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_tests():
|
|
13
|
+
"""Run all tests in the tests directory"""
|
|
14
|
+
# Add src to path so tests can import railtracks_cli
|
|
15
|
+
src_path = Path(__file__).parent / "src"
|
|
16
|
+
sys.path.insert(0, str(src_path))
|
|
17
|
+
|
|
18
|
+
# Discover and run tests
|
|
19
|
+
loader = unittest.TestLoader()
|
|
20
|
+
start_dir = Path(__file__).parent / "tests"
|
|
21
|
+
suite = loader.discover(str(start_dir), pattern="test_*.py")
|
|
22
|
+
|
|
23
|
+
runner = unittest.TextTestRunner(verbosity=2)
|
|
24
|
+
result = runner.run(suite)
|
|
25
|
+
|
|
26
|
+
# Return exit code based on test results
|
|
27
|
+
return 0 if result.wasSuccessful() else 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
sys.exit(run_tests())
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
railtracks - A Python development server with file watching and JSON API
|
|
5
|
+
Usage: railtracks [command]
|
|
6
|
+
|
|
7
|
+
Commands:
|
|
8
|
+
init Initialize railtracks environment (setup directories, download UI)
|
|
9
|
+
viz Start the railtracks development server
|
|
10
|
+
|
|
11
|
+
- Checks to see if there is a .railtracks directory
|
|
12
|
+
- If not, it creates one (and adds it to the .gitignore)
|
|
13
|
+
- If there is a build directory, it runs the build command
|
|
14
|
+
- If there is a .railtracks directory, it starts the server
|
|
15
|
+
|
|
16
|
+
For testing purposes, you can add `alias railtracks="python railtracks.py"` to your .bashrc or .zshrc
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import mimetypes
|
|
21
|
+
import os
|
|
22
|
+
import queue
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import traceback
|
|
28
|
+
import urllib.request
|
|
29
|
+
import webbrowser
|
|
30
|
+
import zipfile
|
|
31
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from urllib.parse import urlparse
|
|
34
|
+
|
|
35
|
+
from watchdog.events import FileSystemEventHandler
|
|
36
|
+
from watchdog.observers import Observer
|
|
37
|
+
|
|
38
|
+
__version__ = "1.1.2"
|
|
39
|
+
|
|
40
|
+
# TODO: Once we are releasing to PyPi change this to the release asset instead
|
|
41
|
+
latest_ui_url = "https://railtownazureb2c.blob.core.windows.net/cdn/rc-viz/latest.zip"
|
|
42
|
+
|
|
43
|
+
cli_name = "railtracks"
|
|
44
|
+
cli_directory = ".railtracks"
|
|
45
|
+
DEFAULT_PORT = 3030
|
|
46
|
+
DEBOUNCE_INTERVAL = 0.5 # seconds
|
|
47
|
+
|
|
48
|
+
# Simple streaming for single-user dev tool
|
|
49
|
+
current_stream_queue = None
|
|
50
|
+
stream_queue_lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_script_directory():
|
|
54
|
+
"""Get the directory where this script is located"""
|
|
55
|
+
return Path(__file__).parent.absolute()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def print_status(message):
|
|
59
|
+
print(f"[{cli_name}] {message}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_success(message):
|
|
63
|
+
print(f"[{cli_name}] {message}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def print_warning(message):
|
|
67
|
+
print(f"[{cli_name}] {message}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def print_error(message):
|
|
71
|
+
print(f"[{cli_name}] {message}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_stream_queue(stream_queue):
|
|
75
|
+
"""Set the current stream queue (single client)"""
|
|
76
|
+
global current_stream_queue
|
|
77
|
+
with stream_queue_lock:
|
|
78
|
+
current_stream_queue = stream_queue
|
|
79
|
+
print_status("Stream client connected")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def clear_stream_queue():
|
|
83
|
+
"""Clear the current stream queue"""
|
|
84
|
+
global current_stream_queue
|
|
85
|
+
with stream_queue_lock:
|
|
86
|
+
current_stream_queue = None
|
|
87
|
+
print_status("Stream client disconnected")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def send_to_stream(message):
|
|
91
|
+
"""Send message to the current stream client (if any)"""
|
|
92
|
+
global current_stream_queue
|
|
93
|
+
with stream_queue_lock:
|
|
94
|
+
if current_stream_queue:
|
|
95
|
+
try:
|
|
96
|
+
current_stream_queue.put_nowait(message)
|
|
97
|
+
except queue.Full:
|
|
98
|
+
print_status("Stream queue full, clearing connection")
|
|
99
|
+
current_stream_queue = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_railtracks_dir():
|
|
103
|
+
"""Create .railtracks directory if it doesn't exist and add to .gitignore"""
|
|
104
|
+
railtracks_dir = Path(cli_directory)
|
|
105
|
+
if not railtracks_dir.exists():
|
|
106
|
+
print_status(f"Creating {cli_directory} directory...")
|
|
107
|
+
railtracks_dir.mkdir(exist_ok=True)
|
|
108
|
+
print_success(f"Created {cli_directory} directory")
|
|
109
|
+
|
|
110
|
+
# Check if cli_directory is in .gitignore
|
|
111
|
+
gitignore_path = Path(".gitignore")
|
|
112
|
+
if gitignore_path.exists():
|
|
113
|
+
with open(gitignore_path) as f:
|
|
114
|
+
gitignore_content = f.read()
|
|
115
|
+
|
|
116
|
+
if cli_directory not in gitignore_content:
|
|
117
|
+
print_status(f"Adding {cli_directory} to .gitignore...")
|
|
118
|
+
with open(gitignore_path, "a") as f:
|
|
119
|
+
f.write(f"\n{cli_directory}\n")
|
|
120
|
+
print_success(f"Added {cli_directory} to .gitignore")
|
|
121
|
+
else:
|
|
122
|
+
print_status("Creating .gitignore file...")
|
|
123
|
+
with open(gitignore_path, "w") as f:
|
|
124
|
+
f.write(f"{cli_directory}\n")
|
|
125
|
+
print_success(f"Created .gitignore with {cli_directory}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def download_and_extract_ui():
|
|
129
|
+
"""Download the latest frontend UI and extract it to .railtracks/ui"""
|
|
130
|
+
ui_url = latest_ui_url
|
|
131
|
+
ui_dir = Path(f"{cli_directory}/ui")
|
|
132
|
+
|
|
133
|
+
print_status("Downloading latest frontend UI...")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
# Create temporary file for download
|
|
137
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as temp_file:
|
|
138
|
+
temp_zip_path = temp_file.name
|
|
139
|
+
|
|
140
|
+
# Download the zip file
|
|
141
|
+
print_status(f"Downloading from: {ui_url}")
|
|
142
|
+
urllib.request.urlretrieve(ui_url, temp_zip_path)
|
|
143
|
+
|
|
144
|
+
# Create ui directory if it doesn't exist
|
|
145
|
+
ui_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
|
|
147
|
+
# Extract the zip file
|
|
148
|
+
print_status("Extracting UI files...")
|
|
149
|
+
with zipfile.ZipFile(temp_zip_path, "r") as zip_ref:
|
|
150
|
+
zip_ref.extractall(ui_dir)
|
|
151
|
+
|
|
152
|
+
# Clean up temporary file
|
|
153
|
+
os.unlink(temp_zip_path)
|
|
154
|
+
|
|
155
|
+
print_success("Frontend UI downloaded and extracted successfully")
|
|
156
|
+
print_status(f"UI files available in: {ui_dir}")
|
|
157
|
+
|
|
158
|
+
except urllib.error.URLError as e:
|
|
159
|
+
print_error(f"Failed to download UI: {e}")
|
|
160
|
+
print_error("Please check your internet connection and try again")
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
except zipfile.BadZipFile as e:
|
|
163
|
+
print_error(f"Failed to extract UI zip file: {e}")
|
|
164
|
+
print_error("The downloaded file may be corrupted")
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
print_error(f"Unexpected error during UI download/extraction: {e}")
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def init_railtracks():
|
|
172
|
+
"""Initialize the railtracks environment"""
|
|
173
|
+
print_status("Initializing railtracks environment...")
|
|
174
|
+
|
|
175
|
+
# Setup directories
|
|
176
|
+
create_railtracks_dir()
|
|
177
|
+
|
|
178
|
+
# Download and extract UI
|
|
179
|
+
download_and_extract_ui()
|
|
180
|
+
|
|
181
|
+
print_success("railtracks initialization completed!")
|
|
182
|
+
print_status("You can now run 'railtracks viz' to start the server")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class FileChangeHandler(FileSystemEventHandler):
|
|
186
|
+
"""Handle file system events in the .railtracks directory"""
|
|
187
|
+
|
|
188
|
+
def __init__(self):
|
|
189
|
+
self.last_modified = {}
|
|
190
|
+
|
|
191
|
+
def on_modified(self, event):
|
|
192
|
+
if event.is_directory:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
file_path = Path(event.src_path)
|
|
196
|
+
if file_path.suffix.lower() == ".json":
|
|
197
|
+
current_time = time.time()
|
|
198
|
+
last_time = self.last_modified.get(str(file_path), 0)
|
|
199
|
+
|
|
200
|
+
# Debounce rapid file changes
|
|
201
|
+
if current_time - last_time > DEBOUNCE_INTERVAL:
|
|
202
|
+
self.last_modified[str(file_path)] = current_time
|
|
203
|
+
print_status(f"JSON file modified: {file_path.name}")
|
|
204
|
+
|
|
205
|
+
# Send to stream client
|
|
206
|
+
stream_message = {
|
|
207
|
+
"type": "file_updated",
|
|
208
|
+
"filename": file_path.name,
|
|
209
|
+
"timestamp": current_time,
|
|
210
|
+
}
|
|
211
|
+
send_to_stream(json.dumps(stream_message))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class RailtracksHTTPHandler(BaseHTTPRequestHandler):
|
|
215
|
+
"""HTTP request handler for the railtracks server"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, *args, **kwargs):
|
|
218
|
+
self.ui_dir = Path(f"{cli_directory}/ui")
|
|
219
|
+
self.railtracks_dir = Path(cli_directory)
|
|
220
|
+
try:
|
|
221
|
+
super().__init__(*args, **kwargs)
|
|
222
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
|
|
223
|
+
# Client disconnected during initialization - this is normal for SSE
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
def do_GET(self): # noqa: N802
|
|
227
|
+
"""Handle GET requests"""
|
|
228
|
+
parsed_url = urlparse(self.path)
|
|
229
|
+
path = parsed_url.path
|
|
230
|
+
|
|
231
|
+
# API endpoints
|
|
232
|
+
if path == "/api/files":
|
|
233
|
+
self.handle_api_files()
|
|
234
|
+
elif path.startswith("/api/json/"):
|
|
235
|
+
self.handle_api_json(path)
|
|
236
|
+
elif path == "/stream":
|
|
237
|
+
self.handle_stream()
|
|
238
|
+
else:
|
|
239
|
+
# Serve static files from build directory
|
|
240
|
+
self.serve_static_file(path)
|
|
241
|
+
|
|
242
|
+
def do_OPTIONS(self): # noqa: N802
|
|
243
|
+
"""Handle OPTIONS requests for CORS preflight"""
|
|
244
|
+
self.send_response(200)
|
|
245
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
246
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
247
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
248
|
+
self.end_headers()
|
|
249
|
+
|
|
250
|
+
def do_POST(self): # noqa: N802
|
|
251
|
+
"""Handle POST requests"""
|
|
252
|
+
parsed_url = urlparse(self.path)
|
|
253
|
+
path = parsed_url.path
|
|
254
|
+
|
|
255
|
+
if path == "/api/refresh":
|
|
256
|
+
self.handle_refresh()
|
|
257
|
+
else:
|
|
258
|
+
self.send_error(404, "Not Found")
|
|
259
|
+
|
|
260
|
+
def handle_api_files(self):
|
|
261
|
+
"""Handle /api/files endpoint - list JSON files in .railtracks directory"""
|
|
262
|
+
try:
|
|
263
|
+
json_files = []
|
|
264
|
+
if self.railtracks_dir.exists():
|
|
265
|
+
for file_path in self.railtracks_dir.glob("*.json"):
|
|
266
|
+
json_files.append(
|
|
267
|
+
{
|
|
268
|
+
"name": file_path.name,
|
|
269
|
+
"size": file_path.stat().st_size,
|
|
270
|
+
"modified": file_path.stat().st_mtime,
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
self.send_response(200)
|
|
275
|
+
self.send_header("Content-Type", "application/json")
|
|
276
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
277
|
+
self.end_headers()
|
|
278
|
+
self.wfile.write(json.dumps(json_files).encode())
|
|
279
|
+
|
|
280
|
+
except Exception as e:
|
|
281
|
+
print_error(f"Error handling /api/files: {e}")
|
|
282
|
+
self.send_error(500, "Internal Server Error")
|
|
283
|
+
|
|
284
|
+
def handle_api_json(self, path):
|
|
285
|
+
"""Handle /api/json/{filename} endpoint - load specific JSON file"""
|
|
286
|
+
try:
|
|
287
|
+
# Extract filename from path
|
|
288
|
+
filename = path.replace("/api/json/", "")
|
|
289
|
+
if not filename.endswith(".json"):
|
|
290
|
+
filename += ".json"
|
|
291
|
+
|
|
292
|
+
file_path = self.railtracks_dir / filename
|
|
293
|
+
|
|
294
|
+
if not file_path.exists():
|
|
295
|
+
self.send_error(404, f"File {filename} not found")
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# Read and parse JSON file
|
|
299
|
+
with open(file_path, encoding="utf-8") as f:
|
|
300
|
+
content = f.read()
|
|
301
|
+
# Validate JSON
|
|
302
|
+
json.loads(content)
|
|
303
|
+
|
|
304
|
+
self.send_response(200)
|
|
305
|
+
self.send_header("Content-Type", "application/json")
|
|
306
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
307
|
+
self.end_headers()
|
|
308
|
+
self.wfile.write(content.encode())
|
|
309
|
+
|
|
310
|
+
except json.JSONDecodeError as e:
|
|
311
|
+
print_error(f"Invalid JSON in {filename}: {e}")
|
|
312
|
+
self.send_error(400, f"Invalid JSON: {e}")
|
|
313
|
+
except Exception as e:
|
|
314
|
+
print_error(f"Error handling /api/json/{filename}: {e}")
|
|
315
|
+
self.send_error(500, "Internal Server Error")
|
|
316
|
+
|
|
317
|
+
def handle_refresh(self):
|
|
318
|
+
"""Handle /api/refresh endpoint - trigger frontend refresh"""
|
|
319
|
+
self.send_response(200)
|
|
320
|
+
self.send_header("Content-Type", "application/json")
|
|
321
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
322
|
+
self.end_headers()
|
|
323
|
+
self.wfile.write(json.dumps({"status": "refresh_triggered"}).encode())
|
|
324
|
+
print_status("Frontend refresh triggered")
|
|
325
|
+
|
|
326
|
+
def handle_stream(self):
|
|
327
|
+
"""Handle /stream endpoint - HTTP streaming for file updates (MCP-style)"""
|
|
328
|
+
try:
|
|
329
|
+
# Create queue for this connection
|
|
330
|
+
stream_queue = queue.Queue()
|
|
331
|
+
set_stream_queue(stream_queue)
|
|
332
|
+
|
|
333
|
+
# Send streaming headers (newline-delimited JSON)
|
|
334
|
+
self.send_response(200)
|
|
335
|
+
self.send_header("Content-Type", "application/x-ndjson")
|
|
336
|
+
self.send_header("Cache-Control", "no-cache")
|
|
337
|
+
self.send_header("Connection", "keep-alive")
|
|
338
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
339
|
+
self.end_headers()
|
|
340
|
+
|
|
341
|
+
# Send initial connection message
|
|
342
|
+
initial_message = {
|
|
343
|
+
"type": "connected",
|
|
344
|
+
"message": "Stream connection established",
|
|
345
|
+
"timestamp": time.time(),
|
|
346
|
+
}
|
|
347
|
+
self.write_json_line(initial_message)
|
|
348
|
+
|
|
349
|
+
# Keep connection alive and send messages
|
|
350
|
+
try:
|
|
351
|
+
while True:
|
|
352
|
+
try:
|
|
353
|
+
# Wait for message with timeout
|
|
354
|
+
message = stream_queue.get(timeout=30)
|
|
355
|
+
parsed_message = json.loads(message)
|
|
356
|
+
self.write_json_line(parsed_message)
|
|
357
|
+
except queue.Empty:
|
|
358
|
+
# Send keepalive
|
|
359
|
+
keepalive = {"type": "keepalive", "timestamp": time.time()}
|
|
360
|
+
self.write_json_line(keepalive)
|
|
361
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
362
|
+
# Client disconnected
|
|
363
|
+
break
|
|
364
|
+
except Exception as e:
|
|
365
|
+
print_error(f"Stream connection error: {e}")
|
|
366
|
+
finally:
|
|
367
|
+
# Clean up
|
|
368
|
+
clear_stream_queue()
|
|
369
|
+
|
|
370
|
+
except Exception as e:
|
|
371
|
+
print_error(f"Error handling stream connection: {e}")
|
|
372
|
+
clear_stream_queue()
|
|
373
|
+
try:
|
|
374
|
+
self.send_error(500, "Internal Server Error")
|
|
375
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
376
|
+
pass # Connection might already be closed
|
|
377
|
+
|
|
378
|
+
def write_json_line(self, data):
|
|
379
|
+
"""Write a JSON line to the streaming response (NDJSON format)"""
|
|
380
|
+
try:
|
|
381
|
+
json_line = json.dumps(data) + "\n"
|
|
382
|
+
self.wfile.write(json_line.encode())
|
|
383
|
+
self.wfile.flush()
|
|
384
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
385
|
+
# Client disconnected - this is normal
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
def serve_static_file(self, path):
|
|
389
|
+
"""Serve static files from .railtracks/ui directory"""
|
|
390
|
+
try:
|
|
391
|
+
# Default to index.html for root path
|
|
392
|
+
if path == "/":
|
|
393
|
+
file_path = self.ui_dir / "index.html"
|
|
394
|
+
else:
|
|
395
|
+
file_path = self.ui_dir / path.lstrip("/")
|
|
396
|
+
|
|
397
|
+
if not file_path.exists():
|
|
398
|
+
# For SPA routing, fallback to index.html
|
|
399
|
+
file_path = self.ui_dir / "index.html"
|
|
400
|
+
|
|
401
|
+
if not file_path.exists():
|
|
402
|
+
self.send_error(404, "File not found")
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# Determine content type
|
|
406
|
+
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
407
|
+
if content_type is None:
|
|
408
|
+
content_type = "application/octet-stream"
|
|
409
|
+
|
|
410
|
+
# Read and serve file
|
|
411
|
+
with open(file_path, "rb") as f:
|
|
412
|
+
content = f.read()
|
|
413
|
+
|
|
414
|
+
self.send_response(200)
|
|
415
|
+
self.send_header("Content-Type", content_type)
|
|
416
|
+
self.end_headers()
|
|
417
|
+
self.wfile.write(content)
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
print_error(f"Error serving static file {path}: {e}")
|
|
421
|
+
self.send_error(500, "Internal Server Error")
|
|
422
|
+
|
|
423
|
+
def log_message(self, format, *args):
|
|
424
|
+
"""Override to use our colored logging and suppress connection errors"""
|
|
425
|
+
message = format % args
|
|
426
|
+
# Suppress common connection error messages that are normal for SSE
|
|
427
|
+
if any(
|
|
428
|
+
error in message.lower()
|
|
429
|
+
for error in [
|
|
430
|
+
"connection aborted",
|
|
431
|
+
"connection reset",
|
|
432
|
+
"broken pipe",
|
|
433
|
+
"an established connection was aborted",
|
|
434
|
+
]
|
|
435
|
+
):
|
|
436
|
+
return
|
|
437
|
+
print_status(f"{self.address_string()} - {message}")
|
|
438
|
+
|
|
439
|
+
def handle_error(self, request, client_address):
|
|
440
|
+
"""Override to suppress connection errors"""
|
|
441
|
+
# Get the exception info
|
|
442
|
+
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
443
|
+
|
|
444
|
+
# Suppress common connection errors that are normal for SSE
|
|
445
|
+
if exc_type in (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
# For other errors, use default handling but with our logging
|
|
449
|
+
print_error(f"Error handling request from {client_address}")
|
|
450
|
+
print_error(f"{exc_type.__name__}: {exc_value}")
|
|
451
|
+
|
|
452
|
+
# Only print full traceback for unexpected errors
|
|
453
|
+
if exc_type not in (
|
|
454
|
+
ConnectionAbortedError,
|
|
455
|
+
ConnectionResetError,
|
|
456
|
+
BrokenPipeError,
|
|
457
|
+
):
|
|
458
|
+
traceback.print_exc()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class RailtracksServer:
|
|
462
|
+
"""Main server class"""
|
|
463
|
+
|
|
464
|
+
def __init__(self, port=DEFAULT_PORT):
|
|
465
|
+
self.port = port
|
|
466
|
+
self.server = None
|
|
467
|
+
self.observer = None
|
|
468
|
+
self.running = False
|
|
469
|
+
|
|
470
|
+
def start_file_watcher(self):
|
|
471
|
+
"""Start watching the .railtracks directory"""
|
|
472
|
+
railtracks_dir = Path(cli_directory)
|
|
473
|
+
if not railtracks_dir.exists():
|
|
474
|
+
railtracks_dir.mkdir(exist_ok=True)
|
|
475
|
+
|
|
476
|
+
event_handler = FileChangeHandler()
|
|
477
|
+
self.observer = Observer()
|
|
478
|
+
self.observer.schedule(event_handler, str(railtracks_dir), recursive=True)
|
|
479
|
+
self.observer.start()
|
|
480
|
+
print_status(f"Watching for JSON file changes in: {railtracks_dir}")
|
|
481
|
+
|
|
482
|
+
def start_http_server(self):
|
|
483
|
+
"""Start the HTTP server"""
|
|
484
|
+
|
|
485
|
+
# Create a custom handler class with the ui and railtracks directories
|
|
486
|
+
class Handler(RailtracksHTTPHandler):
|
|
487
|
+
def __init__(self, *args, **kwargs):
|
|
488
|
+
self.ui_dir = Path(f"{cli_directory}/ui")
|
|
489
|
+
self.railtracks_dir = Path(cli_directory)
|
|
490
|
+
try:
|
|
491
|
+
super().__init__(*args, **kwargs)
|
|
492
|
+
except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
|
|
493
|
+
# Client disconnected during initialization - this is normal for SSE
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
self.server = ThreadingHTTPServer(("localhost", self.port), Handler)
|
|
497
|
+
print_success(f"🚀 railtracks server running at http://localhost:{self.port}")
|
|
498
|
+
print_status(f"📁 Serving files from: {cli_directory}/ui/")
|
|
499
|
+
print_status(f"👀 Watching for changes in: {cli_directory}/")
|
|
500
|
+
print_status("📋 API endpoints:")
|
|
501
|
+
print_status(" GET /api/files - List JSON files")
|
|
502
|
+
print_status(" GET /api/json/filename - Load JSON file")
|
|
503
|
+
print_status(" POST /api/refresh - Trigger frontend refresh")
|
|
504
|
+
print_status(" GET /stream - HTTP streaming for file updates")
|
|
505
|
+
print_status("Press Ctrl+C to stop the server")
|
|
506
|
+
|
|
507
|
+
# Open browser after a short delay to ensure server is ready
|
|
508
|
+
def open_browser():
|
|
509
|
+
time.sleep(1) # Give server a moment to fully start
|
|
510
|
+
url = f"http://localhost:{self.port}"
|
|
511
|
+
print_status(f"Opening browser to {url}")
|
|
512
|
+
try:
|
|
513
|
+
webbrowser.open(url)
|
|
514
|
+
except Exception as e:
|
|
515
|
+
print_warning(f"Could not open browser automatically: {e}")
|
|
516
|
+
print_status(f"Please manually open: {url}")
|
|
517
|
+
|
|
518
|
+
browser_thread = threading.Thread(target=open_browser)
|
|
519
|
+
browser_thread.daemon = True
|
|
520
|
+
browser_thread.start()
|
|
521
|
+
|
|
522
|
+
self.server.serve_forever()
|
|
523
|
+
|
|
524
|
+
def start(self):
|
|
525
|
+
"""Start both the file watcher and HTTP server"""
|
|
526
|
+
self.running = True
|
|
527
|
+
|
|
528
|
+
# Start file watcher in a separate thread
|
|
529
|
+
watcher_thread = threading.Thread(target=self.start_file_watcher)
|
|
530
|
+
watcher_thread.daemon = True
|
|
531
|
+
watcher_thread.start()
|
|
532
|
+
|
|
533
|
+
# Start HTTP server in main thread
|
|
534
|
+
try:
|
|
535
|
+
self.start_http_server()
|
|
536
|
+
except KeyboardInterrupt:
|
|
537
|
+
self.stop()
|
|
538
|
+
|
|
539
|
+
def stop(self):
|
|
540
|
+
"""Stop the server and cleanup"""
|
|
541
|
+
if self.running:
|
|
542
|
+
print_status("Shutting down railtracks...")
|
|
543
|
+
self.running = False
|
|
544
|
+
|
|
545
|
+
if self.server:
|
|
546
|
+
self.server.shutdown()
|
|
547
|
+
|
|
548
|
+
if self.observer:
|
|
549
|
+
self.observer.stop()
|
|
550
|
+
self.observer.join()
|
|
551
|
+
|
|
552
|
+
print_success("railtracks stopped.")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def main():
|
|
556
|
+
"""Main function"""
|
|
557
|
+
if len(sys.argv) < 2:
|
|
558
|
+
print(f"Usage: {cli_name} [command]")
|
|
559
|
+
print("")
|
|
560
|
+
print("Commands:")
|
|
561
|
+
print(
|
|
562
|
+
f" init Initialize {cli_name} environment (setup directories, download portable UI)"
|
|
563
|
+
)
|
|
564
|
+
print(f" viz Start the {cli_name} development server")
|
|
565
|
+
print("")
|
|
566
|
+
print("Examples:")
|
|
567
|
+
print(f" {cli_name} init # Initialize development environment")
|
|
568
|
+
print(f" {cli_name} viz # Start visualizer web app")
|
|
569
|
+
sys.exit(1)
|
|
570
|
+
|
|
571
|
+
command = sys.argv[1]
|
|
572
|
+
|
|
573
|
+
if command == "init":
|
|
574
|
+
init_railtracks()
|
|
575
|
+
elif command == "viz":
|
|
576
|
+
# Setup directories
|
|
577
|
+
create_railtracks_dir()
|
|
578
|
+
|
|
579
|
+
# Start server
|
|
580
|
+
server = RailtracksServer()
|
|
581
|
+
server.start()
|
|
582
|
+
else:
|
|
583
|
+
print(f"Unknown command: {command}")
|
|
584
|
+
print("Available commands: init, viz")
|
|
585
|
+
sys.exit(1)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
if __name__ == "__main__":
|
|
589
|
+
main()
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Basic unit tests for railtracks CLI functionality
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import unittest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from unittest.mock import MagicMock, mock_open, patch
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
20
|
+
|
|
21
|
+
import railtracks_cli
|
|
22
|
+
|
|
23
|
+
from railtracks_cli import (
|
|
24
|
+
DEBOUNCE_INTERVAL,
|
|
25
|
+
FileChangeHandler,
|
|
26
|
+
clear_stream_queue,
|
|
27
|
+
create_railtracks_dir,
|
|
28
|
+
get_script_directory,
|
|
29
|
+
print_error,
|
|
30
|
+
print_status,
|
|
31
|
+
print_success,
|
|
32
|
+
print_warning,
|
|
33
|
+
send_to_stream,
|
|
34
|
+
set_stream_queue,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestUtilityFunctions(unittest.TestCase):
|
|
39
|
+
"""Test basic utility functions"""
|
|
40
|
+
|
|
41
|
+
def test_get_script_directory(self):
|
|
42
|
+
"""Test get_script_directory returns a valid Path"""
|
|
43
|
+
result = get_script_directory()
|
|
44
|
+
self.assertIsInstance(result, Path)
|
|
45
|
+
self.assertTrue(result.exists())
|
|
46
|
+
self.assertTrue(result.is_dir())
|
|
47
|
+
|
|
48
|
+
@patch('builtins.print')
|
|
49
|
+
def test_print_functions(self, mock_print):
|
|
50
|
+
"""Test all print functions format messages correctly"""
|
|
51
|
+
test_message = "test message"
|
|
52
|
+
|
|
53
|
+
print_status(test_message)
|
|
54
|
+
mock_print.assert_called_with("[railtracks] test message")
|
|
55
|
+
|
|
56
|
+
print_success(test_message)
|
|
57
|
+
mock_print.assert_called_with("[railtracks] test message")
|
|
58
|
+
|
|
59
|
+
print_warning(test_message)
|
|
60
|
+
mock_print.assert_called_with("[railtracks] test message")
|
|
61
|
+
|
|
62
|
+
print_error(test_message)
|
|
63
|
+
mock_print.assert_called_with("[railtracks] test message")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestStreamQueue(unittest.TestCase):
|
|
67
|
+
"""Test stream queue functionality"""
|
|
68
|
+
|
|
69
|
+
def setUp(self):
|
|
70
|
+
"""Clear stream queue before each test"""
|
|
71
|
+
clear_stream_queue()
|
|
72
|
+
|
|
73
|
+
def tearDown(self):
|
|
74
|
+
"""Clear stream queue after each test"""
|
|
75
|
+
clear_stream_queue()
|
|
76
|
+
|
|
77
|
+
def test_set_and_clear_stream_queue(self):
|
|
78
|
+
"""Test setting and clearing stream queue"""
|
|
79
|
+
# Initially should be None
|
|
80
|
+
self.assertIsNone(railtracks_cli.current_stream_queue)
|
|
81
|
+
|
|
82
|
+
# Set a queue
|
|
83
|
+
test_queue = queue.Queue()
|
|
84
|
+
set_stream_queue(test_queue)
|
|
85
|
+
|
|
86
|
+
# Should be set now
|
|
87
|
+
self.assertIsNotNone(railtracks_cli.current_stream_queue)
|
|
88
|
+
|
|
89
|
+
# Clear it
|
|
90
|
+
clear_stream_queue()
|
|
91
|
+
|
|
92
|
+
# Should be None again
|
|
93
|
+
self.assertIsNone(railtracks_cli.current_stream_queue)
|
|
94
|
+
|
|
95
|
+
def test_send_to_stream_no_queue(self):
|
|
96
|
+
"""Test sending to stream when no queue is set"""
|
|
97
|
+
# Should not raise an exception
|
|
98
|
+
send_to_stream("test message")
|
|
99
|
+
|
|
100
|
+
def test_send_to_stream_with_queue(self):
|
|
101
|
+
"""Test sending to stream with active queue"""
|
|
102
|
+
test_queue = queue.Queue()
|
|
103
|
+
set_stream_queue(test_queue)
|
|
104
|
+
|
|
105
|
+
test_message = "test message"
|
|
106
|
+
send_to_stream(test_message)
|
|
107
|
+
|
|
108
|
+
# Should have received the message
|
|
109
|
+
self.assertFalse(test_queue.empty())
|
|
110
|
+
received = test_queue.get_nowait()
|
|
111
|
+
self.assertEqual(received, test_message)
|
|
112
|
+
|
|
113
|
+
def test_send_to_stream_full_queue(self):
|
|
114
|
+
"""Test sending to stream when queue is full"""
|
|
115
|
+
# Create a queue with maxsize 1
|
|
116
|
+
test_queue = queue.Queue(maxsize=1)
|
|
117
|
+
set_stream_queue(test_queue)
|
|
118
|
+
|
|
119
|
+
# Fill the queue
|
|
120
|
+
test_queue.put_nowait("existing message")
|
|
121
|
+
|
|
122
|
+
# Try to send another message - should clear the queue
|
|
123
|
+
send_to_stream("new message")
|
|
124
|
+
|
|
125
|
+
# Queue should have been cleared
|
|
126
|
+
self.assertIsNone(railtracks_cli.current_stream_queue)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TestCreateRailtracksDir(unittest.TestCase):
|
|
130
|
+
"""Test create_railtracks_dir function"""
|
|
131
|
+
|
|
132
|
+
def setUp(self):
|
|
133
|
+
"""Set up temporary directory for testing"""
|
|
134
|
+
self.test_dir = tempfile.mkdtemp()
|
|
135
|
+
self.original_cwd = os.getcwd()
|
|
136
|
+
os.chdir(self.test_dir)
|
|
137
|
+
|
|
138
|
+
def tearDown(self):
|
|
139
|
+
"""Clean up temporary directory"""
|
|
140
|
+
os.chdir(self.original_cwd)
|
|
141
|
+
shutil.rmtree(self.test_dir)
|
|
142
|
+
|
|
143
|
+
@patch('railtracks_cli.print_status')
|
|
144
|
+
@patch('railtracks_cli.print_success')
|
|
145
|
+
def test_create_railtracks_dir_new(self, mock_success, mock_status):
|
|
146
|
+
"""Test creating .railtracks directory when it doesn't exist"""
|
|
147
|
+
# Ensure .railtracks doesn't exist
|
|
148
|
+
railtracks_path = Path(".railtracks")
|
|
149
|
+
self.assertFalse(railtracks_path.exists())
|
|
150
|
+
|
|
151
|
+
create_railtracks_dir()
|
|
152
|
+
|
|
153
|
+
# Should exist now
|
|
154
|
+
self.assertTrue(railtracks_path.exists())
|
|
155
|
+
self.assertTrue(railtracks_path.is_dir())
|
|
156
|
+
|
|
157
|
+
# Should have called print functions
|
|
158
|
+
mock_status.assert_called()
|
|
159
|
+
mock_success.assert_called()
|
|
160
|
+
|
|
161
|
+
@patch('railtracks_cli.print_status')
|
|
162
|
+
@patch('railtracks_cli.print_success')
|
|
163
|
+
def test_create_railtracks_dir_existing(self, mock_success, mock_status):
|
|
164
|
+
"""Test when .railtracks directory already exists"""
|
|
165
|
+
# Create .railtracks directory first
|
|
166
|
+
railtracks_path = Path(".railtracks")
|
|
167
|
+
railtracks_path.mkdir()
|
|
168
|
+
|
|
169
|
+
create_railtracks_dir()
|
|
170
|
+
|
|
171
|
+
# Should still exist
|
|
172
|
+
self.assertTrue(railtracks_path.exists())
|
|
173
|
+
self.assertTrue(railtracks_path.is_dir())
|
|
174
|
+
|
|
175
|
+
@patch('railtracks_cli.print_status')
|
|
176
|
+
@patch('railtracks_cli.print_success')
|
|
177
|
+
def test_create_railtracks_dir_gitignore_new(self, mock_success, mock_status):
|
|
178
|
+
"""Test creating .gitignore with .railtracks entry"""
|
|
179
|
+
create_railtracks_dir()
|
|
180
|
+
|
|
181
|
+
# Should create .gitignore
|
|
182
|
+
gitignore_path = Path(".gitignore")
|
|
183
|
+
self.assertTrue(gitignore_path.exists())
|
|
184
|
+
|
|
185
|
+
# Should contain .railtracks
|
|
186
|
+
with open(gitignore_path) as f:
|
|
187
|
+
content = f.read()
|
|
188
|
+
self.assertIn(".railtracks", content)
|
|
189
|
+
|
|
190
|
+
@patch('railtracks_cli.print_status')
|
|
191
|
+
@patch('railtracks_cli.print_success')
|
|
192
|
+
def test_create_railtracks_dir_gitignore_existing(self, mock_success, mock_status):
|
|
193
|
+
"""Test adding .railtracks to existing .gitignore"""
|
|
194
|
+
# Create existing .gitignore
|
|
195
|
+
gitignore_path = Path(".gitignore")
|
|
196
|
+
with open(gitignore_path, "w") as f:
|
|
197
|
+
f.write("*.pyc\n__pycache__/\n")
|
|
198
|
+
|
|
199
|
+
create_railtracks_dir()
|
|
200
|
+
|
|
201
|
+
# Should contain both old and new entries
|
|
202
|
+
with open(gitignore_path) as f:
|
|
203
|
+
content = f.read()
|
|
204
|
+
self.assertIn("*.pyc", content)
|
|
205
|
+
self.assertIn(".railtracks", content)
|
|
206
|
+
|
|
207
|
+
@patch('railtracks_cli.print_status')
|
|
208
|
+
def test_create_railtracks_dir_gitignore_already_present(self, mock_status):
|
|
209
|
+
"""Test when .railtracks is already in .gitignore"""
|
|
210
|
+
# Create .gitignore with .railtracks already present
|
|
211
|
+
gitignore_path = Path(".gitignore")
|
|
212
|
+
with open(gitignore_path, "w") as f:
|
|
213
|
+
f.write("*.pyc\n.railtracks\n__pycache__/\n")
|
|
214
|
+
|
|
215
|
+
original_content = gitignore_path.read_text()
|
|
216
|
+
|
|
217
|
+
create_railtracks_dir()
|
|
218
|
+
|
|
219
|
+
# Content should be unchanged
|
|
220
|
+
new_content = gitignore_path.read_text()
|
|
221
|
+
self.assertEqual(original_content, new_content)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class TestFileChangeHandler(unittest.TestCase):
|
|
225
|
+
"""Test FileChangeHandler debouncing logic"""
|
|
226
|
+
|
|
227
|
+
def setUp(self):
|
|
228
|
+
"""Set up handler for testing"""
|
|
229
|
+
self.handler = FileChangeHandler()
|
|
230
|
+
|
|
231
|
+
@patch('railtracks_cli.send_to_stream')
|
|
232
|
+
@patch('railtracks_cli.print_status')
|
|
233
|
+
def test_file_change_handler_json_file(self, mock_print, mock_stream):
|
|
234
|
+
"""Test handler processes JSON file changes"""
|
|
235
|
+
# Create a mock event
|
|
236
|
+
mock_event = MagicMock()
|
|
237
|
+
mock_event.is_directory = False
|
|
238
|
+
mock_event.src_path = "/test/path/file.json"
|
|
239
|
+
|
|
240
|
+
self.handler.on_modified(mock_event)
|
|
241
|
+
|
|
242
|
+
# Should have printed status and sent to stream
|
|
243
|
+
mock_print.assert_called_once()
|
|
244
|
+
mock_stream.assert_called_once()
|
|
245
|
+
|
|
246
|
+
@patch('railtracks_cli.send_to_stream')
|
|
247
|
+
@patch('railtracks_cli.print_status')
|
|
248
|
+
def test_file_change_handler_non_json_file(self, mock_print, mock_stream):
|
|
249
|
+
"""Test handler ignores non-JSON files"""
|
|
250
|
+
# Create a mock event for non-JSON file
|
|
251
|
+
mock_event = MagicMock()
|
|
252
|
+
mock_event.is_directory = False
|
|
253
|
+
mock_event.src_path = "/test/path/file.txt"
|
|
254
|
+
|
|
255
|
+
self.handler.on_modified(mock_event)
|
|
256
|
+
|
|
257
|
+
# Should not have printed or sent to stream
|
|
258
|
+
mock_print.assert_not_called()
|
|
259
|
+
mock_stream.assert_not_called()
|
|
260
|
+
|
|
261
|
+
@patch('railtracks_cli.send_to_stream')
|
|
262
|
+
@patch('railtracks_cli.print_status')
|
|
263
|
+
def test_file_change_handler_directory(self, mock_print, mock_stream):
|
|
264
|
+
"""Test handler ignores directory changes"""
|
|
265
|
+
# Create a mock event for directory
|
|
266
|
+
mock_event = MagicMock()
|
|
267
|
+
mock_event.is_directory = True
|
|
268
|
+
mock_event.src_path = "/test/path/directory"
|
|
269
|
+
|
|
270
|
+
self.handler.on_modified(mock_event)
|
|
271
|
+
|
|
272
|
+
# Should not have printed or sent to stream
|
|
273
|
+
mock_print.assert_not_called()
|
|
274
|
+
mock_stream.assert_not_called()
|
|
275
|
+
|
|
276
|
+
@patch('railtracks_cli.send_to_stream')
|
|
277
|
+
@patch('railtracks_cli.print_status')
|
|
278
|
+
@patch('time.time')
|
|
279
|
+
def test_file_change_handler_debouncing(self, mock_time, mock_print, mock_stream):
|
|
280
|
+
"""Test debouncing prevents rapid duplicate events"""
|
|
281
|
+
# Set up time mock to simulate rapid changes
|
|
282
|
+
mock_time.side_effect = [1.0, 1.1, 1.6] # Second call within debounce, third outside
|
|
283
|
+
|
|
284
|
+
mock_event = MagicMock()
|
|
285
|
+
mock_event.is_directory = False
|
|
286
|
+
mock_event.src_path = "/test/path/file.json"
|
|
287
|
+
|
|
288
|
+
# First call should process
|
|
289
|
+
self.handler.on_modified(mock_event)
|
|
290
|
+
self.assertEqual(mock_print.call_count, 1)
|
|
291
|
+
self.assertEqual(mock_stream.call_count, 1)
|
|
292
|
+
|
|
293
|
+
# Second call within debounce interval should be ignored
|
|
294
|
+
self.handler.on_modified(mock_event)
|
|
295
|
+
self.assertEqual(mock_print.call_count, 1) # Still 1
|
|
296
|
+
self.assertEqual(mock_stream.call_count, 1) # Still 1
|
|
297
|
+
|
|
298
|
+
# Third call outside debounce interval should process
|
|
299
|
+
self.handler.on_modified(mock_event)
|
|
300
|
+
self.assertEqual(mock_print.call_count, 2)
|
|
301
|
+
self.assertEqual(mock_stream.call_count, 2)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
unittest.main()
|