Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
singlestore-labs
GitHub Repository: singlestore-labs/singlestoredb-python
Path: blob/main/resources/build_docs.py
798 views
1
#!/usr/bin/env python3
2
"""
3
Unified documentation build script for SingleStoreDB Python SDK.
4
5
Usage:
6
python build_docs.py [target] [options]
7
8
Can be run from the repository root or resources directory.
9
The script automatically detects its location and works with the docs/src directory.
10
11
Examples:
12
python resources/build_docs.py html # From repo root
13
python build_docs.py html # From resources directory
14
python build_docs.py help # Show Sphinx help
15
python build_docs.py html --verbose # Build with verbose output
16
python build_docs.py html --no-docker # Build without starting Docker
17
18
"""
19
from __future__ import annotations
20
21
import argparse
22
import glob
23
import os
24
import re
25
import shutil
26
import subprocess
27
import sys
28
import time
29
from pathlib import Path
30
from re import Match
31
from typing import Any
32
from typing import Dict
33
from typing import List
34
from typing import Optional
35
36
# Detect script location and set up paths
37
def get_repo_paths() -> tuple[Path, Path]:
38
"""Determine the repository root and docs source directory based on script location."""
39
script_dir = Path(__file__).parent.absolute()
40
41
# Check if we're in the resources directory or repo root
42
if script_dir.name == 'resources':
43
repo_root = script_dir.parent
44
docs_src = repo_root / 'docs' / 'src'
45
elif (script_dir / 'singlestoredb').exists():
46
# We're in the repo root
47
repo_root = script_dir
48
docs_src = repo_root / 'docs' / 'src'
49
else:
50
# Try to find the repo root by looking for singlestoredb directory
51
current = script_dir
52
while current != current.parent:
53
if (current / 'singlestoredb').exists():
54
repo_root = current
55
docs_src = repo_root / 'docs' / 'src'
56
break
57
current = current.parent
58
else:
59
raise RuntimeError(f'Could not find repository root from {script_dir}')
60
61
return repo_root, docs_src
62
63
REPO_ROOT, DOCS_SRC = get_repo_paths()
64
65
# Add the repo root to the path so we can import singlestoredb
66
sys.path.insert(0, str(REPO_ROOT))
67
68
from singlestoredb.server import docker
69
70
# Constants using absolute paths
71
CONTAINER_INFO_FILE = DOCS_SRC / '.singlestore_docs_container.txt'
72
BUILD_DIR = DOCS_SRC / '_build'
73
SOURCE_DIR = DOCS_SRC
74
CUSTOM_CSS_FILE = DOCS_SRC / 'custom.css'
75
76
77
class DocBuilder:
78
"""Main documentation builder class."""
79
80
def __init__(
81
self, target: str = 'html', verbose: bool = False,
82
use_docker: bool = True, sphinx_opts: str = '',
83
):
84
self.target = target
85
self.verbose = verbose
86
self.use_docker = use_docker
87
self.sphinx_opts = sphinx_opts
88
self.container_started = False
89
self.container_url: Optional[str] = None
90
91
# Store paths for operations
92
self.docs_src = DOCS_SRC
93
self.build_dir = BUILD_DIR
94
self.source_dir = SOURCE_DIR
95
self.custom_css_file = CUSTOM_CSS_FILE
96
self.container_info_file = CONTAINER_INFO_FILE
97
98
# Ensure docs/src directory exists
99
if not self.docs_src.exists():
100
raise RuntimeError(f'Documentation source directory not found: {self.docs_src}')
101
102
def log(self, message: str, error: bool = False) -> None:
103
"""Log a message to stderr if verbose mode is enabled."""
104
if self.verbose or error:
105
print(message, file=sys.stderr)
106
107
def status(self, message: str) -> None:
108
"""Always show status messages to indicate progress."""
109
print(f'๐Ÿ“‹ {message}', file=sys.stderr)
110
111
def step(self, step_num: int, total_steps: int, message: str) -> None:
112
"""Show a numbered step with progress."""
113
print(f'๐Ÿ“ Step {step_num}/{total_steps}: {message}', file=sys.stderr)
114
115
# Docker Management Functions
116
def start_docker_container(self) -> bool:
117
"""Start SingleStoreDB Docker container if needed."""
118
# Check if SINGLESTOREDB_URL is already set
119
if os.environ.get('SINGLESTOREDB_URL'):
120
self.status(f"Using existing database: {os.environ['SINGLESTOREDB_URL']}")
121
return True
122
123
if not self.use_docker:
124
self.status('Docker disabled, skipping container start')
125
return True
126
127
self.status('๐Ÿณ Starting SingleStoreDB Docker container...')
128
start_time = time.time()
129
130
try:
131
# Start the container
132
server = docker.start()
133
self.container_url = server.connection_url
134
135
# Save the container info for stopping later
136
with open(self.container_info_file, 'w') as f:
137
f.write(f'{server.container.name}\n')
138
f.write(f'{self.container_url}\n')
139
140
# Set environment variable
141
os.environ['SINGLESTOREDB_URL'] = self.container_url
142
self.container_started = True
143
144
# Wait for the container to be ready
145
if not self.wait_for_container_ready(server):
146
return False
147
148
elapsed = time.time() - start_time
149
self.status(f'โœ… Database ready in {elapsed:.1f}s: {self.container_url}')
150
return True
151
152
except Exception as e:
153
self.log(f'Error starting Docker container: {e}', error=True)
154
return False
155
156
def wait_for_container_ready(self, server: Any, max_retries: int = 30) -> bool:
157
"""Wait for the Docker container to be ready."""
158
print(' โณ Waiting for database to be ready...', end='', flush=True, file=sys.stderr)
159
160
for attempt in range(max_retries):
161
try:
162
test_conn = server.connect()
163
test_conn.close()
164
print('', file=sys.stderr) # New line after dots
165
return True
166
except Exception as e:
167
if attempt == max_retries - 1:
168
print('', file=sys.stderr) # New line after dots
169
self.log(f'โŒ Container failed to start after {max_retries} seconds', error=True)
170
self.log(f'Last error: {e}', error=True)
171
return False
172
print('.', end='', flush=True, file=sys.stderr)
173
time.sleep(1)
174
return False
175
176
def stop_docker_container(self) -> bool:
177
"""Stop and remove the SingleStoreDB Docker container."""
178
if not self.container_started or not self.container_info_file.exists():
179
return True
180
181
try:
182
with open(self.container_info_file, 'r') as f:
183
lines = f.readlines()
184
if not lines:
185
return True
186
container_name = lines[0].strip()
187
188
self.status(f'๐Ÿ›‘ Stopping container: {container_name}')
189
190
# Stop the container
191
subprocess.run(
192
['docker', 'stop', container_name],
193
stdout=subprocess.DEVNULL,
194
stderr=subprocess.DEVNULL,
195
)
196
197
# Remove the container
198
subprocess.run(
199
['docker', 'rm', container_name],
200
stdout=subprocess.DEVNULL,
201
stderr=subprocess.DEVNULL,
202
)
203
204
# Remove the info file
205
self.container_info_file.unlink()
206
self.log('SingleStoreDB container stopped and removed.')
207
return True
208
209
except Exception as e:
210
self.log(f'Error stopping container: {e}', error=True)
211
# Still try to remove the info file
212
if self.container_info_file.exists():
213
self.container_info_file.unlink()
214
return False
215
216
# Build Functions
217
def clean_build_directory(self) -> None:
218
"""Clean the build directory."""
219
html_build_dir = self.build_dir / 'html'
220
if html_build_dir.exists():
221
self.log(f'Cleaning build directory: {html_build_dir}')
222
shutil.rmtree(html_build_dir)
223
224
def copy_custom_css(self) -> bool:
225
"""Copy custom CSS to the _static directory."""
226
try:
227
static_dir = self.build_dir / 'html' / '_static'
228
static_dir.mkdir(parents=True, exist_ok=True)
229
230
custom_css_dest = static_dir / 'custom.css'
231
with open(custom_css_dest, 'w') as dest_file:
232
with open(self.custom_css_file, 'r') as src_file:
233
dest_file.write(src_file.read().strip())
234
235
self.log(f'Copied {self.custom_css_file} to {custom_css_dest}')
236
return True
237
238
except Exception as e:
239
self.log(f'Error copying custom CSS: {e}', error=True)
240
return False
241
242
def run_sphinx_build(self) -> bool:
243
"""Run sphinx-build with the specified target."""
244
if self.target == 'help':
245
# Show Sphinx help
246
cmd = ['sphinx-build', '-M', 'help', str(self.source_dir), str(self.build_dir)]
247
try:
248
subprocess.run(cmd, check=True)
249
return True
250
except subprocess.CalledProcessError as e:
251
self.log(f'Error running Sphinx help: {e}', error=True)
252
return False
253
254
# Clean build directory first
255
self.status('๐Ÿงน Cleaning build directory...')
256
self.clean_build_directory()
257
258
# Build the command
259
cmd = ['sphinx-build', '-M', self.target, str(self.source_dir), str(self.build_dir)]
260
261
# Add any additional Sphinx options
262
if self.sphinx_opts:
263
cmd.extend(self.sphinx_opts.split())
264
265
self.status(f'๐Ÿ”จ Building {self.target} documentation...')
266
self.log(f'Command: {" ".join(cmd)}')
267
268
start_time = time.time()
269
270
try:
271
# Run sphinx-build - always show output for better feedback
272
result = subprocess.run(cmd, check=True)
273
274
elapsed = time.time() - start_time
275
self.status(f'โœ… Sphinx build completed in {elapsed:.1f}s')
276
return True
277
278
except subprocess.CalledProcessError as e:
279
elapsed = time.time() - start_time
280
self.log(f'โŒ Sphinx build failed after {elapsed:.1f}s (exit code: {e.returncode})', error=True)
281
return False
282
283
# HTML Post-processing Functions
284
def collect_generated_links(self, build_html_dir: Path) -> Dict[str, str]:
285
"""Collect links from generated HTML files."""
286
links: Dict[str, str] = {}
287
generated_dir = build_html_dir / 'generated'
288
289
if not generated_dir.exists():
290
return links
291
292
for html_file in generated_dir.glob('*.html'):
293
# Match class names like "ClassName.html"
294
m = re.search(r'([A-Z]\w+)\.html$', html_file.name)
295
if m:
296
links[m.group(1)] = html_file.name
297
continue
298
299
# Match method names like "ClassName.method_name.html"
300
m = re.search(r'([A-Z]\w+\.[a-z]\w+)\.html$', html_file.name)
301
if m:
302
links[m.group(1)] = html_file.name
303
304
if links:
305
self.log(f'Found {len(links)} generated API reference links')
306
return links
307
308
def check_link(self, match: Match[str], links: Dict[str, str]) -> str:
309
"""Check and fix links in HTML content."""
310
link, pre, txt, post = match.groups()
311
if not link and txt in links:
312
return f'<a href="{links[txt]}">{pre}{txt}{post}</a>'
313
return match.group(0)
314
315
def process_html_files(self, build_html_dir: Path) -> bool:
316
"""Post-process HTML files with various transformations."""
317
try:
318
self.status('๐Ÿ”— Collecting generated API links...')
319
start_time = time.time()
320
321
# Collect generated links
322
links = self.collect_generated_links(build_html_dir)
323
324
# Copy custom CSS to _static directory
325
self.status('๐ŸŽจ Copying custom CSS...')
326
if not self.copy_custom_css():
327
return False
328
329
# Get list of files to process
330
self.status('๐Ÿ” Scanning files for post-processing...')
331
files_to_process: List[Path] = []
332
for ext in ['html', 'txt', 'svg', 'js', 'css', 'rst']:
333
files_to_process.extend(build_html_dir.rglob(f'*.{ext}'))
334
335
total_files = len(files_to_process)
336
self.status(f'โœ๏ธ Processing {total_files} files...')
337
338
# Process each file with progress indicator
339
for i, file_path in enumerate(sorted(files_to_process), 1):
340
try:
341
if i % 50 == 0 or i == total_files: # Show progress every 50 files or at end
342
print(f' ๐Ÿ“„ {i}/{total_files} files processed', file=sys.stderr)
343
344
with open(file_path, 'r', encoding='utf-8') as f:
345
content = f.read()
346
347
# Apply transformations
348
content = self.apply_content_transformations(content, links)
349
350
with open(file_path, 'w', encoding='utf-8') as f:
351
f.write(content)
352
353
except Exception as e:
354
self.log(f'Error processing file {file_path}: {e}', error=True)
355
# Continue processing other files
356
357
elapsed = time.time() - start_time
358
self.status(f'โœ… Post-processing completed in {elapsed:.1f}s')
359
return True
360
361
except Exception as e:
362
self.log(f'โŒ Error in HTML post-processing: {e}', error=True)
363
return False
364
365
def apply_content_transformations(self, content: str, links: Dict[str, str]) -> str:
366
"""Apply all content transformations to a text file."""
367
# Remove module names from hidden modules
368
content = re.sub(
369
r'(">)singlestoredb\.(connection|management)\.([\w\.]+</a>)',
370
r'\1\3',
371
content,
372
)
373
374
# Remove singleton representations
375
content = re.sub(
376
r'Organization\(name=.+?\)',
377
r'<em class="property"><span class="w"> </span><span class="p"></span><span class="w"> </span><span class="pre">&lt;singlestoredb.notebook._objects.Organization</span> <span class="pre">object&gt;</span></em>',
378
content,
379
)
380
381
# Change ShowAccessor to Connection.show
382
content = re.sub(r'>ShowAccessor\.', r'>Connection.show.', content)
383
384
# Change workspace.Stage to workspace.stage
385
content = re.sub(r'>workspace\.Stage\.', r'>workspace.stage.', content)
386
387
# Fix class/method links
388
content = re.sub(
389
r'(<a\s+[^>]+>)?(\s*<code[^>]*>\s*<span\s+class="pre">\s*)([\w\.]+)(\s*</span>\s*</code>)',
390
lambda m: self.check_link(m, links),
391
content,
392
)
393
394
# Trim trailing whitespace
395
content = re.sub(r'\s+\n', r'\n', content)
396
397
# Fix end-of-files
398
content = re.sub(r'\s*$', r'', content) + '\n'
399
400
return content
401
402
# File Management Functions
403
def cleanup_old_files(self) -> None:
404
"""Remove old documentation files from parent directory."""
405
parent_dir = self.docs_src.parent # docs directory
406
dirs_to_remove = ['_images', '_sources', '_static', 'generated']
407
408
for dir_name in dirs_to_remove:
409
dir_path = parent_dir / dir_name
410
if dir_path.exists():
411
self.log(f'Removing old directory: {dir_path}')
412
shutil.rmtree(dir_path)
413
414
def move_generated_files(self, build_html_dir: Path) -> bool:
415
"""Move generated files to parent directory."""
416
try:
417
self.status('๐Ÿ“ Moving documentation files to docs/ directory...')
418
start_time = time.time()
419
420
parent_dir = self.docs_src.parent # docs directory
421
422
# First cleanup old files
423
self.cleanup_old_files()
424
425
# Count items to move
426
items = list(build_html_dir.iterdir())
427
total_items = len(items)
428
429
# Move all files from build HTML directory to parent
430
for i, item in enumerate(items, 1):
431
dest_path = parent_dir / item.name
432
433
if item.is_dir():
434
if dest_path.exists():
435
shutil.rmtree(dest_path)
436
shutil.copytree(item, dest_path)
437
self.log(f'Moved directory: {item.name}')
438
else:
439
shutil.copy2(item, dest_path)
440
self.log(f'Moved file: {item.name}')
441
442
# Show progress
443
if i % 10 == 0 or i == total_items:
444
print(f' ๐Ÿ“ฆ {i}/{total_items} items moved', file=sys.stderr)
445
446
elapsed = time.time() - start_time
447
self.status(f'โœ… File movement completed in {elapsed:.1f}s')
448
return True
449
450
except Exception as e:
451
self.log(f'โŒ Error moving generated files: {e}', error=True)
452
return False
453
454
# Main Orchestration
455
def build_docs(self) -> int:
456
"""Main function to build documentation."""
457
try:
458
total_start_time = time.time()
459
460
# Determine total steps
461
total_steps = 4 if self.target == 'html' else 2
462
463
print(f'๐Ÿš€ Starting SingleStoreDB documentation build ({self.target})', file=sys.stderr)
464
print(f'๐Ÿ“ Working directory: {self.docs_src}', file=sys.stderr)
465
print('=' * 60, file=sys.stderr)
466
467
# Step 1: Start Docker container if needed
468
self.step(1, total_steps, 'Database setup')
469
if not self.start_docker_container():
470
return 1
471
472
# Step 2: Run Sphinx build
473
self.step(2, total_steps, f'Sphinx {self.target} build')
474
if not self.run_sphinx_build():
475
return 1
476
477
# For HTML builds, do post-processing and file movement
478
if self.target == 'html':
479
build_html_dir = self.build_dir / 'html'
480
481
# Step 3: Post-process HTML files
482
self.step(3, total_steps, 'HTML post-processing')
483
if not self.process_html_files(build_html_dir):
484
return 1
485
486
# Step 4: Move generated files to parent directory
487
self.step(4, total_steps, 'File deployment')
488
if not self.move_generated_files(build_html_dir):
489
return 1
490
491
total_elapsed = time.time() - total_start_time
492
print('=' * 60, file=sys.stderr)
493
print(f'๐ŸŽ‰ Documentation build completed successfully in {total_elapsed:.1f}s!', file=sys.stderr)
494
return 0
495
496
except KeyboardInterrupt:
497
print('\nโŒ Build interrupted by user', file=sys.stderr)
498
return 1
499
except Exception as e:
500
print(f'\nโŒ Unexpected error during build: {e}', file=sys.stderr)
501
return 1
502
finally:
503
# Always try to stop the container if we started it
504
if self.container_started:
505
self.stop_docker_container()
506
507
508
def main() -> int:
509
"""Main entry point for the script."""
510
parser = argparse.ArgumentParser(
511
description='Build SingleStoreDB Python SDK documentation',
512
formatter_class=argparse.RawDescriptionHelpFormatter,
513
epilog="""
514
Examples:
515
%(prog)s html Build HTML documentation
516
%(prog)s help Show Sphinx help
517
%(prog)s html --verbose Build with verbose output
518
%(prog)s html --no-docker Build without starting Docker
519
%(prog)s latexpdf Build PDF documentation
520
%(prog)s html --sphinx-opts "-W --keep-going" Pass options to Sphinx
521
""".strip(),
522
)
523
524
parser.add_argument(
525
'target',
526
nargs='?',
527
default='html',
528
help='Sphinx build target (default: html)',
529
)
530
531
parser.add_argument(
532
'-v', '--verbose',
533
action='store_true',
534
help='Enable verbose output',
535
)
536
537
parser.add_argument(
538
'--no-docker',
539
action='store_true',
540
help='Do not start/stop Docker container',
541
)
542
543
parser.add_argument(
544
'--sphinx-opts',
545
default='',
546
help='Additional options to pass to sphinx-build',
547
)
548
549
args = parser.parse_args()
550
551
# Create builder instance
552
builder = DocBuilder(
553
target=args.target,
554
verbose=args.verbose,
555
use_docker=not args.no_docker,
556
sphinx_opts=args.sphinx_opts,
557
)
558
559
# Build documentation
560
return builder.build_docs()
561
562
563
if __name__ == '__main__':
564
sys.exit(main())
565
566