relationalai 1.0.0a3__py3-none-any.whl → 1.0.0a5__py3-none-any.whl

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.
Files changed (118) hide show
  1. relationalai/config/config.py +47 -21
  2. relationalai/config/connections/__init__.py +5 -2
  3. relationalai/config/connections/duckdb.py +2 -2
  4. relationalai/config/connections/local.py +31 -0
  5. relationalai/config/connections/snowflake.py +0 -1
  6. relationalai/config/external/raiconfig_converter.py +235 -0
  7. relationalai/config/external/raiconfig_models.py +202 -0
  8. relationalai/config/external/utils.py +31 -0
  9. relationalai/config/shims.py +1 -0
  10. relationalai/semantics/__init__.py +10 -8
  11. relationalai/semantics/backends/sql/sql_compiler.py +1 -4
  12. relationalai/semantics/experimental/__init__.py +0 -0
  13. relationalai/semantics/experimental/builder.py +295 -0
  14. relationalai/semantics/experimental/builtins.py +154 -0
  15. relationalai/semantics/frontend/base.py +67 -42
  16. relationalai/semantics/frontend/core.py +34 -6
  17. relationalai/semantics/frontend/front_compiler.py +209 -37
  18. relationalai/semantics/frontend/pprint.py +6 -2
  19. relationalai/semantics/metamodel/__init__.py +7 -0
  20. relationalai/semantics/metamodel/metamodel.py +2 -0
  21. relationalai/semantics/metamodel/metamodel_analyzer.py +58 -16
  22. relationalai/semantics/metamodel/pprint.py +6 -1
  23. relationalai/semantics/metamodel/rewriter.py +11 -7
  24. relationalai/semantics/metamodel/typer.py +116 -41
  25. relationalai/semantics/reasoners/__init__.py +11 -0
  26. relationalai/semantics/reasoners/graph/__init__.py +35 -0
  27. relationalai/semantics/reasoners/graph/core.py +9028 -0
  28. relationalai/semantics/std/__init__.py +30 -10
  29. relationalai/semantics/std/aggregates.py +641 -12
  30. relationalai/semantics/std/common.py +146 -13
  31. relationalai/semantics/std/constraints.py +71 -1
  32. relationalai/semantics/std/datetime.py +904 -21
  33. relationalai/semantics/std/decimals.py +143 -2
  34. relationalai/semantics/std/floats.py +57 -4
  35. relationalai/semantics/std/integers.py +98 -4
  36. relationalai/semantics/std/math.py +857 -35
  37. relationalai/semantics/std/numbers.py +216 -20
  38. relationalai/semantics/std/re.py +213 -5
  39. relationalai/semantics/std/strings.py +437 -44
  40. relationalai/shims/executor.py +60 -52
  41. relationalai/shims/fixtures.py +85 -0
  42. relationalai/shims/helpers.py +26 -2
  43. relationalai/shims/hoister.py +28 -9
  44. relationalai/shims/mm2v0.py +204 -173
  45. relationalai/tools/cli/cli.py +192 -10
  46. relationalai/tools/cli/components/progress_reader.py +1 -1
  47. relationalai/tools/cli/docs.py +394 -0
  48. relationalai/tools/debugger.py +11 -4
  49. relationalai/tools/qb_debugger.py +435 -0
  50. relationalai/tools/typer_debugger.py +1 -2
  51. relationalai/util/dataclasses.py +3 -5
  52. relationalai/util/docutils.py +1 -2
  53. relationalai/util/error.py +2 -5
  54. relationalai/util/python.py +23 -0
  55. relationalai/util/runtime.py +1 -2
  56. relationalai/util/schema.py +2 -4
  57. relationalai/util/structures.py +4 -2
  58. relationalai/util/tracing.py +8 -2
  59. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/METADATA +8 -5
  60. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/RECORD +118 -95
  61. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/WHEEL +1 -1
  62. v0/relationalai/__init__.py +1 -1
  63. v0/relationalai/clients/client.py +52 -18
  64. v0/relationalai/clients/exec_txn_poller.py +122 -0
  65. v0/relationalai/clients/local.py +23 -8
  66. v0/relationalai/clients/resources/azure/azure.py +36 -11
  67. v0/relationalai/clients/resources/snowflake/__init__.py +4 -4
  68. v0/relationalai/clients/resources/snowflake/cli_resources.py +12 -1
  69. v0/relationalai/clients/resources/snowflake/direct_access_resources.py +124 -100
  70. v0/relationalai/clients/resources/snowflake/engine_service.py +381 -0
  71. v0/relationalai/clients/resources/snowflake/engine_state_handlers.py +35 -29
  72. v0/relationalai/clients/resources/snowflake/error_handlers.py +43 -2
  73. v0/relationalai/clients/resources/snowflake/snowflake.py +277 -179
  74. v0/relationalai/clients/resources/snowflake/use_index_poller.py +8 -0
  75. v0/relationalai/clients/types.py +5 -0
  76. v0/relationalai/errors.py +19 -1
  77. v0/relationalai/semantics/lqp/algorithms.py +173 -0
  78. v0/relationalai/semantics/lqp/builtins.py +199 -2
  79. v0/relationalai/semantics/lqp/executor.py +68 -37
  80. v0/relationalai/semantics/lqp/ir.py +28 -2
  81. v0/relationalai/semantics/lqp/model2lqp.py +215 -45
  82. v0/relationalai/semantics/lqp/passes.py +13 -658
  83. v0/relationalai/semantics/lqp/rewrite/__init__.py +12 -0
  84. v0/relationalai/semantics/lqp/rewrite/algorithm.py +385 -0
  85. v0/relationalai/semantics/lqp/rewrite/constants_to_vars.py +70 -0
  86. v0/relationalai/semantics/lqp/rewrite/deduplicate_vars.py +104 -0
  87. v0/relationalai/semantics/lqp/rewrite/eliminate_data.py +108 -0
  88. v0/relationalai/semantics/lqp/rewrite/extract_keys.py +25 -3
  89. v0/relationalai/semantics/lqp/rewrite/period_math.py +77 -0
  90. v0/relationalai/semantics/lqp/rewrite/quantify_vars.py +65 -31
  91. v0/relationalai/semantics/lqp/rewrite/unify_definitions.py +317 -0
  92. v0/relationalai/semantics/lqp/utils.py +11 -1
  93. v0/relationalai/semantics/lqp/validators.py +14 -1
  94. v0/relationalai/semantics/metamodel/builtins.py +2 -1
  95. v0/relationalai/semantics/metamodel/compiler.py +2 -1
  96. v0/relationalai/semantics/metamodel/dependency.py +12 -3
  97. v0/relationalai/semantics/metamodel/executor.py +11 -1
  98. v0/relationalai/semantics/metamodel/factory.py +2 -2
  99. v0/relationalai/semantics/metamodel/helpers.py +7 -0
  100. v0/relationalai/semantics/metamodel/ir.py +3 -2
  101. v0/relationalai/semantics/metamodel/rewrite/dnf_union_splitter.py +30 -20
  102. v0/relationalai/semantics/metamodel/rewrite/flatten.py +50 -13
  103. v0/relationalai/semantics/metamodel/rewrite/format_outputs.py +9 -3
  104. v0/relationalai/semantics/metamodel/typer/checker.py +6 -4
  105. v0/relationalai/semantics/metamodel/typer/typer.py +4 -3
  106. v0/relationalai/semantics/metamodel/visitor.py +4 -3
  107. v0/relationalai/semantics/reasoners/optimization/solvers_dev.py +1 -1
  108. v0/relationalai/semantics/reasoners/optimization/solvers_pb.py +336 -86
  109. v0/relationalai/semantics/rel/compiler.py +2 -1
  110. v0/relationalai/semantics/rel/executor.py +3 -2
  111. v0/relationalai/semantics/tests/lqp/__init__.py +0 -0
  112. v0/relationalai/semantics/tests/lqp/algorithms.py +345 -0
  113. v0/relationalai/tools/cli.py +339 -186
  114. v0/relationalai/tools/cli_controls.py +216 -67
  115. v0/relationalai/tools/cli_helpers.py +410 -6
  116. v0/relationalai/util/format.py +5 -2
  117. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/entry_points.txt +0 -0
  118. {relationalai-1.0.0a3.dist-info → relationalai-1.0.0a5.dist-info}/top_level.txt +0 -0
@@ -5,16 +5,37 @@ For more documentation on these commands, visit: https://docs.relationalai.com
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import sys
8
9
  from pathlib import Path
9
10
 
10
11
  import click
11
12
 
12
13
  from rich.console import Console
14
+ from rich.table import Table
13
15
 
14
16
  from .config_template import CONFIG_TEMPLATE
15
17
 
16
18
  console = Console()
17
19
 
20
+ def _load_config():
21
+ """Load config using the unified Config() factory (rai yaml → snowflake toml → dbt profiles)."""
22
+ from relationalai.config import Config
23
+
24
+ try:
25
+ return Config()
26
+ except Exception as e:
27
+ raise click.ClickException(f"Failed to load config: {e}") from e
28
+
29
+
30
+ def _validate_session(session) -> None: # noqa: ANN001
31
+ """Best-effort validation across session types."""
32
+ if hasattr(session, "sql"):
33
+ session.sql("select 1").collect()
34
+ return
35
+ if hasattr(session, "execute"):
36
+ session.execute("select 1").fetchall()
37
+ return
38
+
18
39
 
19
40
  @click.group(invoke_without_command=True)
20
41
  @click.version_option()
@@ -26,37 +47,61 @@ def cli(ctx: click.Context):
26
47
  click.echo(ctx.get_help())
27
48
 
28
49
 
29
- @cli.command()
50
+ @cli.command() # type: ignore[attr-defined]
30
51
  def analyze():
31
52
  """Check for errors, warnings, and performance issues in the models."""
32
53
  console.print("analyze")
33
54
 
34
55
 
35
- @cli.command()
56
+ @cli.command() # type: ignore[attr-defined]
36
57
  def build():
37
58
  """Create artifacts, show compilation errors, and produce SQL for the models."""
38
59
  console.print("build")
39
60
 
40
61
 
41
- @cli.command()
62
+ @cli.command() # type: ignore[attr-defined]
42
63
  def connect():
43
64
  """Validate config and database connection."""
44
- console.print("connect")
65
+ config = _load_config()
66
+ try:
67
+ connection = config.get_default_connection()
68
+ except Exception as e:
69
+ raise click.ClickException(f"Failed to resolve default connection: {e}") from e
70
+
71
+ session = None
72
+ err: Exception | None = None
73
+ try:
74
+ session = connection.get_session()
75
+ _validate_session(session)
76
+ status = "[green]OK[/green]"
77
+ except Exception as e:
78
+ session = None
79
+ err = e
80
+ status = "[red]FAIL[/red]"
81
+
82
+ table = Table(show_header=False)
83
+ table.add_row("Connection Status", status)
84
+ console.print(table)
85
+
86
+ if err is not None:
87
+ raise click.ClickException(f"Connection failed: {err}") from err
88
+
89
+ return session
45
90
 
46
91
 
47
- @cli.command()
92
+ @cli.command() # type: ignore[attr-defined]
48
93
  def deploy():
49
94
  """Deploy models/views to the database and produce SQL for execution."""
50
95
  console.print("deploy")
51
96
 
52
97
 
53
- @cli.command()
98
+ @cli.command() # type: ignore[attr-defined]
54
99
  def explore():
55
100
  """Model explorer for visualization of data."""
56
101
  console.print("explore")
57
102
 
58
103
 
59
- @cli.command()
104
+ @cli.command() # type: ignore[attr-defined]
60
105
  def init():
61
106
  """Create a template for the YAML config file."""
62
107
  config_file = Path("raiconfig.yml")
@@ -73,18 +118,155 @@ def init():
73
118
  raise click.ClickException(f"Failed to create config file: {e}")
74
119
 
75
120
 
76
- @cli.command()
121
+ @cli.command() # type: ignore[attr-defined]
77
122
  @click.option("--uninstall", is_flag=True, help="Nukes the database")
78
123
  def clean(uninstall: bool):
79
124
  """Clean up build folder and remove artifacts."""
80
125
  console.print("clean")
81
126
 
82
127
 
83
- @cli.command()
128
+ @cli.command() # type: ignore[attr-defined]
84
129
  def test():
85
130
  """Run constraints and tests."""
86
131
  console.print("test")
87
132
 
88
133
 
134
+ @cli.group() # type: ignore[attr-defined]
135
+ def docs():
136
+ """Generate and serve documentation site."""
137
+ pass
138
+
139
+
140
+ @docs.command()
141
+ @click.option(
142
+ "--output",
143
+ "-o",
144
+ default=None,
145
+ help="Output directory for generated documentation (defaults to docs/dist)",
146
+ )
147
+ def generate(output: str | None):
148
+ """Generate static documentation site from models."""
149
+ from .docs import generate_docs
150
+
151
+ try:
152
+ generate_docs(output_dir=output)
153
+ except click.ClickException as e:
154
+ # Print formatted error message and exit
155
+ console.print(f"[red]✗ Failed to generate documentation: {e.message}[/red]")
156
+ sys.exit(1)
157
+ except Exception as e:
158
+ console.print(f"[red]✗ Failed to generate documentation: {e}[/red]")
159
+ sys.exit(1)
160
+
161
+
162
+ @docs.command()
163
+ @click.option(
164
+ "--port",
165
+ "-p",
166
+ default=8000,
167
+ type=int,
168
+ help="Port to serve documentation on (default: 8000)",
169
+ )
170
+ @click.option(
171
+ "--host",
172
+ default="127.0.0.1",
173
+ help="Host to bind to (default: 127.0.0.1)",
174
+ )
175
+ @click.option(
176
+ "--build-dir",
177
+ default=None,
178
+ help="Directory containing built documentation (defaults to docs/dist)",
179
+ )
180
+ @click.option(
181
+ "--no-open",
182
+ is_flag=True,
183
+ help="Don't open browser automatically",
184
+ )
185
+ def serve(port: int, host: str, build_dir: str | None, no_open: bool):
186
+ """Serve generated documentation site."""
187
+ from .docs import find_docs_dir, serve_docs
188
+
189
+ # Determine build directory
190
+ if build_dir is None:
191
+ docs_dir = find_docs_dir()
192
+ build_dir = str(docs_dir / "dist")
193
+
194
+ try:
195
+ serve_docs(
196
+ build_dir=build_dir,
197
+ host=host,
198
+ port=port,
199
+ open_browser=not no_open,
200
+ )
201
+ except click.ClickException as e:
202
+ # Print formatted error message and exit
203
+ console.print(f"[red]✗ Failed to start server: {e.message}[/red]")
204
+ sys.exit(1)
205
+ except Exception as e:
206
+ console.print(f"[red]✗ Failed to start server: {e}[/red]")
207
+ sys.exit(1)
208
+
209
+
210
+ @docs.command()
211
+ @click.option(
212
+ "--port",
213
+ "-p",
214
+ default=5173,
215
+ type=int,
216
+ help="Port to serve development server on (default: 5173)",
217
+ )
218
+ @click.option(
219
+ "--host",
220
+ default="127.0.0.1",
221
+ help="Host to bind to (default: 127.0.0.1)",
222
+ )
223
+ @click.option(
224
+ "--no-open",
225
+ is_flag=True,
226
+ help="Don't open browser automatically",
227
+ )
228
+ def dev(port: int, host: str, no_open: bool):
229
+ """Start development server with hot module replacement (HMR).
230
+
231
+ This command runs Vite's dev server which provides:
232
+ - Hot module replacement - changes reflect instantly
233
+ - Fast refresh - no need to rebuild on every change
234
+ - Source maps for debugging
235
+
236
+ Use this for development instead of 'rai docs generate' + 'rai docs serve'.
237
+ """
238
+ from .docs import dev_docs
239
+
240
+ try:
241
+ dev_docs(
242
+ host=host,
243
+ port=port,
244
+ open_browser=not no_open,
245
+ )
246
+ except click.ClickException as e:
247
+ # Print formatted error message and exit
248
+ console.print(f"[red]✗ Failed to start dev server: {e.message}[/red]")
249
+ sys.exit(1)
250
+ except Exception as e:
251
+ console.print(f"[red]✗ Failed to start dev server: {e}[/red]")
252
+ sys.exit(1)
253
+
254
+
255
+ @docs.command(name="clean")
256
+ def clean_docs():
257
+ """Remove auto generated files and directories."""
258
+ from .docs import cleanup_docs
259
+
260
+ try:
261
+ cleanup_docs()
262
+ except click.ClickException as e:
263
+ # Print formatted error message and exit
264
+ console.print(f"[red]✗ Failed to cleanup: {e.message}[/red]")
265
+ sys.exit(1)
266
+ except Exception as e:
267
+ console.print(f"[red]✗ Failed to cleanup: {e}[/red]")
268
+ sys.exit(1)
269
+
270
+
89
271
  if __name__ == "__main__":
90
- cli()
272
+ cli() # type: ignore[call-arg]
@@ -96,7 +96,7 @@ class _RenderableWrapper:
96
96
  """Rich protocol - yield the result of calling the callable."""
97
97
  result = self._callable()
98
98
  # Result should always be a Group, which implements __rich_console__
99
- yield from result.__rich_console__(console, options)
99
+ yield from result.__rich_console__(console, options) # type: ignore[return-value, attr-defined]
100
100
 
101
101
  @dataclass(frozen=False)
102
102
  class Task:
@@ -0,0 +1,394 @@
1
+ """
2
+ Documentation generation and serving utilities.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import shutil
8
+ import subprocess
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import click
14
+ from rich.console import Console
15
+
16
+ console = Console()
17
+
18
+
19
+ def find_docs_dir() -> Path:
20
+ """Find the documentation source directory."""
21
+ # Try multiple possible locations
22
+ possible_paths = [
23
+ Path("docs"), # Root level
24
+ Path(__file__).parent.parent.parent.parent / "docs", # From this file
25
+ Path.cwd() / "docs", # Current working directory
26
+ ]
27
+
28
+ for path in possible_paths:
29
+ if path.exists() and (path / "package.json").exists():
30
+ return path.resolve()
31
+
32
+ raise click.ClickException(
33
+ "Documentation source directory not found. "
34
+ "Expected 'docs/' directory with package.json"
35
+ )
36
+
37
+
38
+ def has_nodejs() -> bool:
39
+ """Check if Node.js is installed."""
40
+ try:
41
+ subprocess.run(
42
+ ["node", "--version"],
43
+ capture_output=True,
44
+ text=True,
45
+ check=True,
46
+ )
47
+ return True
48
+ except (subprocess.CalledProcessError, FileNotFoundError):
49
+ return False
50
+
51
+
52
+ def has_npm() -> bool:
53
+ """Check if npm is installed."""
54
+ try:
55
+ subprocess.run(
56
+ ["npm", "--version"],
57
+ capture_output=True,
58
+ text=True,
59
+ check=True,
60
+ )
61
+ return True
62
+ except (subprocess.CalledProcessError, FileNotFoundError):
63
+ return False
64
+
65
+
66
+ def print_generation_success(output_path: Path) -> None:
67
+ """
68
+ Print success message after documentation generation.
69
+
70
+ Args:
71
+ output_path: Path to the generated documentation output directory
72
+ """
73
+ console.print("[green]✓ Documentation generated")
74
+ console.print()
75
+ console.print("[cyan]The documentation site is ready to host![/cyan]")
76
+ console.print(f"[dim]Output location: {output_path}[/dim]")
77
+ console.print()
78
+ console.print("[yellow]You can serve it locally with:[/yellow]")
79
+ console.print(" [bold]rai docs serve[/bold]")
80
+ console.print()
81
+ console.print("[yellow]Or host the dist folder directly:[/yellow]")
82
+ console.print(
83
+ "[dim]The dist folder contains a complete static website. You can copy it to any "
84
+ "web server (Nginx, Apache, Flask, etc.), upload it to a CDN, or deploy it to "
85
+ "static hosting services like GitHub Pages, Netlify, or Vercel. All files use "
86
+ "relative paths, so it works regardless of where it's hosted.[/dim]"
87
+ )
88
+
89
+
90
+ def generate_docs(
91
+ output_dir: Optional[str] = None,
92
+ docs_dir: Optional[Path] = None,
93
+ ) -> Path:
94
+ """
95
+ Generate static documentation site.
96
+
97
+ Args:
98
+ output_dir: Optional output directory (defaults to docs/dist)
99
+ docs_dir: Optional path to docs directory
100
+
101
+ Returns:
102
+ Path to the generated output directory
103
+ """
104
+ if docs_dir is None:
105
+ docs_dir = find_docs_dir()
106
+
107
+ # Check prerequisites
108
+ if not has_nodejs():
109
+ raise click.ClickException(
110
+ "Node.js is not installed. "
111
+ "Please install Node.js from https://nodejs.org/"
112
+ )
113
+
114
+ if not has_npm():
115
+ raise click.ClickException(
116
+ "npm is not installed. "
117
+ "npm should come with Node.js installation."
118
+ )
119
+
120
+ # Check if package.json exists
121
+ package_json = docs_dir / "package.json"
122
+ if not package_json.exists():
123
+ raise click.ClickException(
124
+ f"package.json not found in {docs_dir}. "
125
+ "This doesn't appear to be a valid SolidJS project."
126
+ )
127
+
128
+ # Install dependencies if node_modules doesn't exist
129
+ node_modules = docs_dir / "node_modules"
130
+ if not node_modules.exists():
131
+ console.print("[blue]Installing dependencies...[/blue]")
132
+ try:
133
+ subprocess.run(
134
+ ["npm", "install"],
135
+ cwd=docs_dir,
136
+ check=True,
137
+ stdout=subprocess.PIPE,
138
+ stderr=subprocess.PIPE,
139
+ )
140
+ console.print("[green]✓ Dependencies installed[/green]")
141
+ except subprocess.CalledProcessError as e:
142
+ error_msg = e.stderr.decode() if e.stderr else "Unknown error"
143
+ raise click.ClickException(
144
+ f"Failed to install dependencies: {error_msg}"
145
+ )
146
+
147
+ # Build the documentation
148
+ console.print("[blue]Building documentation site...[/blue]")
149
+ try:
150
+ subprocess.run(
151
+ ["npm", "run", "build"],
152
+ cwd=docs_dir,
153
+ check=True,
154
+ capture_output=True,
155
+ text=True,
156
+ )
157
+ console.print("[green]✓ Build completed successfully[/green]")
158
+ except subprocess.CalledProcessError as e:
159
+ error_msg = e.stderr if e.stderr else e.stdout if e.stdout else "Unknown error"
160
+ console.print(f"[red]Build error output:[/red]\n{error_msg}")
161
+ raise click.ClickException(f"Build failed: {error_msg}")
162
+
163
+ # Determine output directory
164
+ build_output = docs_dir / "dist"
165
+ if not build_output.exists():
166
+ raise click.ClickException(
167
+ "Build output directory not found. "
168
+ "Build may have failed."
169
+ )
170
+
171
+ # Copy to custom output directory if specified
172
+ if output_dir:
173
+ output_path = Path(output_dir).resolve()
174
+ if output_path.exists():
175
+ console.print(f"[yellow]Output directory '{output_path}' exists. Cleaning...[/yellow]")
176
+ shutil.rmtree(output_path)
177
+ shutil.copytree(build_output, output_path)
178
+ print_generation_success(output_path)
179
+ return output_path
180
+
181
+ print_generation_success(build_output)
182
+ return build_output
183
+
184
+
185
+ def serve_docs(
186
+ build_dir: str,
187
+ host: str = "127.0.0.1",
188
+ port: int = 8000,
189
+ open_browser: bool = True,
190
+ ) -> None:
191
+ """
192
+ Serve generated documentation site.
193
+
194
+ Args:
195
+ build_dir: Directory containing built documentation
196
+ host: Host to bind to
197
+ port: Port to serve on
198
+ open_browser: Whether to open browser automatically
199
+ """
200
+ build_path = Path(build_dir).resolve()
201
+
202
+ if not build_path.exists():
203
+ raise click.ClickException(
204
+ "Build directory does not exist. "
205
+ "Run 'rai docs generate' first."
206
+ )
207
+
208
+ index_html = build_path / "index.html"
209
+ if not index_html.exists():
210
+ raise click.ClickException(
211
+ "index.html not found. "
212
+ "This doesn't appear to be a valid build directory. "
213
+ "Run 'rai docs generate' first."
214
+ )
215
+
216
+ # Import FastAPI here to avoid requiring it if not using server
217
+ try:
218
+ from fastapi import FastAPI
219
+ from fastapi.staticfiles import StaticFiles
220
+ import uvicorn
221
+ except ImportError:
222
+ raise click.ClickException(
223
+ "FastAPI and uvicorn are required for serving documentation. "
224
+ "Install with: pip install fastapi uvicorn"
225
+ )
226
+
227
+ app = FastAPI(title="RAI Documentation")
228
+ app.mount("/", StaticFiles(directory=str(build_path), html=True), name="static")
229
+
230
+ url = f"http://{host}:{port}"
231
+ console.print(f"[green]✓ Documentation server running at {url}[/green]")
232
+ console.print("[yellow]Press Ctrl+C to stop the server[/yellow]")
233
+
234
+ if open_browser:
235
+ try:
236
+ webbrowser.open(url)
237
+ except Exception:
238
+ console.print(f"[yellow]Could not open browser automatically. Visit {url}[/yellow]")
239
+
240
+ try:
241
+ uvicorn.run(app, host=host, port=port, log_level="warning")
242
+ except KeyboardInterrupt:
243
+ console.print("\n[yellow]Server stopped[/yellow]")
244
+ except OSError as e:
245
+ if "Address already in use" in str(e):
246
+ raise click.ClickException(
247
+ f"Port {port} is already in use. "
248
+ "Try a different port with --port option."
249
+ )
250
+ raise
251
+
252
+
253
+ def dev_docs(
254
+ docs_dir: Optional[Path] = None,
255
+ host: str = "127.0.0.1",
256
+ port: int = 5173,
257
+ open_browser: bool = True,
258
+ ) -> None:
259
+ """
260
+ Start development server with hot module replacement.
261
+
262
+ This runs Vite's dev server, which provides:
263
+ - Hot module replacement (HMR) - changes reflect instantly
264
+ - Fast refresh - no need to rebuild on every change
265
+ - Source maps for debugging
266
+
267
+ Args:
268
+ docs_dir: Optional path to docs directory
269
+ host: Host to bind to
270
+ port: Port to serve on (default: 5173, Vite's default)
271
+ open_browser: Whether to open browser automatically
272
+ """
273
+ if docs_dir is None:
274
+ docs_dir = find_docs_dir()
275
+
276
+ # Check prerequisites
277
+ if not has_nodejs():
278
+ raise click.ClickException(
279
+ "Node.js is not installed. "
280
+ "Please install Node.js from https://nodejs.org/"
281
+ )
282
+
283
+ if not has_npm():
284
+ raise click.ClickException(
285
+ "npm is not installed. "
286
+ "npm should come with Node.js installation."
287
+ )
288
+
289
+ # Check if package.json exists
290
+ package_json = docs_dir / "package.json"
291
+ if not package_json.exists():
292
+ raise click.ClickException(
293
+ f"package.json not found in {docs_dir}. "
294
+ "This doesn't appear to be a valid SolidJS project."
295
+ )
296
+
297
+ # Install dependencies if node_modules doesn't exist
298
+ node_modules = docs_dir / "node_modules"
299
+ if not node_modules.exists():
300
+ console.print("[blue]Installing dependencies...[/blue]")
301
+ try:
302
+ subprocess.run(
303
+ ["npm", "install"],
304
+ cwd=docs_dir,
305
+ check=True,
306
+ stdout=subprocess.PIPE,
307
+ stderr=subprocess.PIPE,
308
+ )
309
+ console.print("[green]✓ Dependencies installed[/green]")
310
+ except subprocess.CalledProcessError as e:
311
+ error_msg = e.stderr.decode() if e.stderr else "Unknown error"
312
+ raise click.ClickException(
313
+ f"Failed to install dependencies: {error_msg}"
314
+ )
315
+
316
+ # Start dev server
317
+ url = f"http://{host}:{port}"
318
+ console.print("[green]✓ Starting development server...[/green]")
319
+ console.print(f"[blue]Server will be available at {url}[/blue]")
320
+ console.print("[yellow]Press Ctrl+C to stop the server[/yellow]")
321
+ console.print(
322
+ "[dim]Note: Changes to files will automatically reload in the browser[/dim]"
323
+ )
324
+
325
+ if open_browser:
326
+ # Open browser after a short delay to let server start
327
+ import threading
328
+ import time
329
+
330
+ def open_browser_delayed():
331
+ time.sleep(1.5) # Give server time to start
332
+ try:
333
+ webbrowser.open(url)
334
+ except Exception:
335
+ console.print(f"[yellow]Could not open browser automatically. Visit {url}[/yellow]")
336
+
337
+ threading.Thread(target=open_browser_delayed, daemon=True).start()
338
+
339
+ try:
340
+ # Run npm run dev with custom host/port
341
+ # Vite accepts --host and --port flags directly
342
+ subprocess.run(
343
+ ["npm", "run", "dev", "--", "--host", host, "--port", str(port)],
344
+ cwd=docs_dir,
345
+ check=True,
346
+ )
347
+ except KeyboardInterrupt:
348
+ console.print("\n[yellow]Development server stopped[/yellow]")
349
+ except subprocess.CalledProcessError as e:
350
+ error_msg = e.stderr.decode() if e.stderr else e.stdout.decode() if e.stdout else "Unknown error"
351
+ raise click.ClickException(f"Dev server failed: {error_msg}")
352
+ except OSError as e:
353
+ if "Address already in use" in str(e):
354
+ raise click.ClickException(
355
+ f"Port {port} is already in use. "
356
+ "Try a different port with --port option."
357
+ )
358
+ raise
359
+
360
+
361
+ def cleanup_docs(docs_dir: Optional[Path] = None) -> None:
362
+ """
363
+ Clean up generated files and dependencies.
364
+
365
+ Silently removes:
366
+ - node_modules/ directory
367
+ - dist/ directory (build output)
368
+
369
+ Args:
370
+ docs_dir: Optional path to docs directory
371
+ """
372
+ if docs_dir is None:
373
+ docs_dir = find_docs_dir()
374
+
375
+ removed_items = []
376
+
377
+ # Remove node_modules
378
+ node_modules = docs_dir / "node_modules"
379
+ if node_modules.exists() and node_modules.is_dir():
380
+ try:
381
+ shutil.rmtree(node_modules)
382
+ removed_items.append("node_modules")
383
+ except Exception as e:
384
+ raise click.ClickException(f"Failed to remove node_modules: {e}")
385
+
386
+ # Remove dist
387
+ dist = docs_dir / "dist"
388
+ if dist.exists() and dist.is_dir():
389
+ try:
390
+ shutil.rmtree(dist)
391
+ removed_items.append("dist")
392
+ except Exception as e:
393
+ raise click.ClickException(f"Failed to remove dist: {e}")
394
+
@@ -1,11 +1,15 @@
1
1
  # debug_snapshot.py
2
2
  from __future__ import annotations
3
- import os, json, time, tempfile
3
+ import os
4
+ import json
5
+ import time
6
+ import tempfile
4
7
  from typing import List
5
8
 
6
9
  def _tail_lines(path: str, max_lines: int) -> List[str]:
7
10
  """Tail last N lines efficiently; fine for small/medium files."""
8
- if not os.path.exists(path): return []
11
+ if not os.path.exists(path):
12
+ return []
9
13
  # simple approach: read and slice; optimize later if needed
10
14
  with open(path, "r", encoding="utf-8", errors="ignore") as f:
11
15
  return f.readlines()[-max_lines:]
@@ -17,11 +21,14 @@ def write_snapshot(spans_jsonl: str, out_html: str, last: int = 5000):
17
21
  os.makedirs(os.path.dirname(out_html) or ".", exist_ok=True)
18
22
  fd, tmp = tempfile.mkstemp(dir=os.path.dirname(out_html) or ".", prefix=".part-", suffix=".html")
19
23
  with os.fdopen(fd, "w", encoding="utf-8") as f:
20
- f.write(html); f.flush(); os.fsync(f.fileno())
24
+ f.write(html)
25
+ f.flush()
26
+ os.fsync(f.fileno())
21
27
  os.replace(tmp, out_html)
22
28
 
23
29
  def start_background_snapshots(spans_jsonl: str, out_html: str, interval_ms: int = 1000, last: int = 5000):
24
- import threading, atexit
30
+ import threading
31
+ import atexit
25
32
  stop = threading.Event()
26
33
 
27
34
  def loop():