-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdevcontainer_sync.py
More file actions
executable file
·306 lines (241 loc) · 10.5 KB
/
Copy pathdevcontainer_sync.py
File metadata and controls
executable file
·306 lines (241 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""
devcontainer_sync.py - Development Configuration Synchronization Tool
PURPOSE:
Automatically syncs development configuration files from a master location
(dynamo-utils) to all Dynamo project directories, ensuring consistent
development environments across multiple working copies using Jinja2 templates.
HOW IT WORKS:
1. Reads devcontainer.json.j2 Jinja2 template
2. Scans for directories matching 'dynamo[0-9]+' pattern in parent directory
3. Generates framework-specific devcontainer configs (VLLM, SGLANG, TRTLLM)
4. Customizes each config with directory-specific names and tokens
5. Tracks changes via MD5 hashes to avoid unnecessary syncs
USAGE:
./devcontainer_sync.py # Normal sync operation
./devcontainer_sync.py --dryrun # Preview changes without applying
./devcontainer_sync.py --force # Force sync even if no changes detected
./devcontainer_sync.py --silent # No output (for cron jobs)
"""
import argparse
import getpass
import hashlib
import os
import re
import sys
from datetime import datetime
from pathlib import Path
try:
from jinja2 import Environment, FileSystemLoader
except ImportError:
print("ERROR: jinja2 module not found. Install with: pip install jinja2")
sys.exit(1)
class DevContainerSync:
"""Handles synchronization of devcontainer configurations."""
def __init__(self, args):
self.dryrun = args.dryrun
self.force = args.force
self.silent = args.silent
# Get script directory and setup paths
self.script_dir = Path(__file__).parent.resolve()
self.template_file = self.script_dir / "devcontainer.json.j2"
# Destination directory pattern
dest_base = os.environ.get("DEVCONTAINER_SRC_DIR", f"{os.path.expanduser('~')}/dynamo/dynamo")
self.dest_pattern = Path(dest_base).parent
self.dest_prefix = Path(dest_base).name
# Frameworks to generate
self.frameworks = ["vllm", "sglang", "trtllm"]
# Get username for customization (USER may not be set in cron / Cursor server context)
self.username = os.environ.get("USER") or getpass.getuser()
# Hash tracking
self.temp_sha_file = Path("/tmp/.sync_devcontainer.sha")
def log(self, message, prefix="INFO"):
"""Log a message unless in silent mode."""
if self.silent:
return
if self.dryrun and prefix != "ERROR":
print(f"[DRYRUN] {message}")
else:
print(f"{prefix}: {message}")
def get_template_hash(self):
"""Calculate MD5 hash of the template file."""
if not self.template_file.exists():
self.log(f"ERROR: Template file not found at {self.template_file}", "ERROR")
sys.exit(1)
with open(self.template_file, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
def get_previous_hash(self):
"""Get the previously stored hash."""
if self.temp_sha_file.exists():
return self.temp_sha_file.read_text().strip()
return None
def save_hash(self, hash_value):
"""Save the current hash."""
if not self.dryrun:
self.temp_sha_file.write_text(hash_value)
def find_target_directories(self):
"""Find all numbered Dynamo directories (dynamoN) to sync."""
directories = []
# Find matching directories with exact `dynamo[number]` naming.
# For example: dynamo1, dynamo2, ...
pattern = re.compile(rf"^{re.escape(self.dest_prefix)}\d+$")
for item in self.dest_pattern.glob(f"{self.dest_prefix}*"):
if item.is_dir() and pattern.match(item.name) and item != self.script_dir:
directories.append(item)
return sorted(directories)
def remove_comments(self, content):
"""Remove // comments from JSON content while preserving strings."""
lines = []
for line in content.split('\n'):
# Skip lines that are entirely commented out (including those with leading //)
stripped = line.lstrip()
if stripped.startswith('//'):
continue
# Remove inline comments (// at end of line)
# But preserve // inside strings by only removing // after the last quote pair
# Simple approach: remove // and everything after it if not inside a string
in_string = False
escape = False
comment_start = -1
for i, char in enumerate(line):
if escape:
escape = False
continue
if char == '\\':
escape = True
continue
if char == '"':
in_string = not in_string
continue
if not in_string and char == '/' and i + 1 < len(line) and line[i + 1] == '/':
comment_start = i
break
if comment_start >= 0:
line = line[:comment_start].rstrip()
# Only add non-empty lines or lines that are just whitespace but preserve structure
if line or not stripped:
lines.append(line)
return '\n'.join(lines)
def render_template(self, framework, dirname, tokens=None):
"""Render the Jinja2 template for a specific framework and directory."""
# Setup Jinja2 environment
env = Environment(loader=FileSystemLoader(self.script_dir))
template = env.get_template(self.template_file.name)
# Get current year
current_year = datetime.now().year
# Prepare template variables
template_vars = {
"framework": framework,
"current_year": current_year,
"dirname": dirname,
"username": self.username,
}
# Render the template
rendered = template.render(**template_vars)
# Remove comments from rendered output
rendered = self.remove_comments(rendered)
# Customize the rendered output
# 1. Update the name field to include directory and username
display_name = f"[{dirname}-{self.username}] {framework.upper()}"
rendered = rendered.replace(
f'"name": "Dynamo {framework.upper()} Dev Container"',
f'"name": "{display_name}"'
)
# 2. Replace token placeholders if provided
if tokens:
github_token = tokens.get("GITHUB_TOKEN", "")
hf_token = tokens.get("HF_TOKEN", "")
rendered = rendered.replace("__GITHUB_TOKEN__", github_token)
rendered = rendered.replace("__HF_TOKEN__", hf_token)
return rendered
def sync_directory(self, target_dir):
"""Sync devcontainer configs to a single directory."""
dirname = target_dir.name
self.log(f"Updating {target_dir}/...")
# Get tokens from environment
tokens = {
"GITHUB_TOKEN": os.environ.get("GITHUB_TOKEN", ""),
"HF_TOKEN": os.environ.get("HF_TOKEN", ""),
}
# Generate configs for each framework
for framework in self.frameworks:
# Create target directory path
framework_dir = target_dir / ".devcontainer" / f"{self.username}_{framework}"
target_file = framework_dir / "devcontainer.json"
# Create directory if it doesn't exist
if not self.dryrun:
framework_dir.mkdir(parents=True, exist_ok=True)
else:
self.log(f"mkdir -p {framework_dir}")
# Render template
rendered_content = self.render_template(framework, dirname, tokens)
# Write the file
if not self.dryrun:
target_file.write_text(rendered_content)
if not self.silent:
if self.dryrun:
self.log(f"+ cp (rendered template) {target_file}")
else:
print(f"+ mkdir -p {framework_dir}")
print(f"+ cp (rendered template) {target_file}")
def run(self):
"""Main synchronization logic."""
# Check if template file exists
if not self.template_file.exists():
self.log(f"ERROR: Template file not found at {self.template_file}", "ERROR")
return 1
# Calculate current hash
current_hash = self.get_template_hash()
previous_hash = self.get_previous_hash()
# Check if sync is needed
if current_hash == previous_hash and not self.force:
self.log("Development config files unchanged, no sync needed.", "DEBUG")
return 0
if self.force:
self.log("Force flag detected, syncing regardless of changes...")
self.log("Development config files have changed, syncing to subdirectories...")
# Find target directories
target_dirs = self.find_target_directories()
if not target_dirs:
self.log(f"No directories found matching {self.dest_pattern}/{self.dest_prefix}[0-9]+")
return 0
# Skip the source directory
if self.script_dir in target_dirs:
self.log(f"Skipping source directory {self.script_dir}")
target_dirs.remove(self.script_dir)
# Sync each directory
sync_count = 0
for target_dir in target_dirs:
self.sync_directory(target_dir)
sync_count += 1
# Save the new hash
self.save_hash(current_hash)
self.log(f"Sync completed. Updated {sync_count} directories.")
return 0
def main():
"""Entry point."""
parser = argparse.ArgumentParser(
description="Sync devcontainer configurations using Jinja2 templates"
)
parser.add_argument(
"--dryrun", "--dry-run",
action="store_true",
help="Preview changes without applying them"
)
parser.add_argument(
"--force",
action="store_true",
help="Force sync even if no changes detected"
)
parser.add_argument(
"--silent",
action="store_true",
help="No output (for cron jobs)"
)
args = parser.parse_args()
syncer = DevContainerSync(args)
return syncer.run()
if __name__ == "__main__":
sys.exit(main())