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.
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,6 @@
1
+ """Entry point for railtracks CLI when run as a module"""
2
+
3
+ from railtracks_cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,3 @@
1
+ """
2
+ Tests for railtracks-cli package
3
+ """
@@ -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()