devs-common 3.2.3__tar.gz → 3.3.0__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.
Files changed (42) hide show
  1. {devs_common-3.2.3 → devs_common-3.3.0}/PKG-INFO +1 -1
  2. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/core/container.py +61 -69
  3. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/config_hash.py +55 -6
  4. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/devcontainer.py +16 -4
  5. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common.egg-info/PKG-INFO +1 -1
  6. {devs_common-3.2.3 → devs_common-3.3.0}/pyproject.toml +1 -1
  7. {devs_common-3.2.3 → devs_common-3.3.0}/LICENSE +0 -0
  8. {devs_common-3.2.3 → devs_common-3.3.0}/README.md +0 -0
  9. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/__init__.py +0 -0
  10. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/config.py +0 -0
  11. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/core/__init__.py +0 -0
  12. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/core/project.py +0 -0
  13. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/core/workspace.py +0 -0
  14. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/devs_config.py +0 -0
  15. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/exceptions.py +0 -0
  16. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/Dockerfile +0 -0
  17. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/devcontainer.json +0 -0
  18. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/scripts/copy-ssh.sh +0 -0
  19. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/scripts/post-create-wrapper.sh +0 -0
  20. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/scripts/python-venv-integration.sh +0 -0
  21. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/scripts/screenshot-webapp.sh +0 -0
  22. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/scripts/setup-workspace.sh +0 -0
  23. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/scripts/start-tunnel.sh +0 -0
  24. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/scripts/tunnel-status.sh +0 -0
  25. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/scripts/webapp-screenshot.py +0 -0
  26. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/sudo-scripts/init-firewall.sh +0 -0
  27. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/sudo-scripts/setup-devs-env.sh +0 -0
  28. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/sudo-scripts/setup-vscode-cache.sh +0 -0
  29. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/sudo-scripts/start-services.sh +0 -0
  30. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/templates/vscode-settings-reference.md +0 -0
  31. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/__init__.py +0 -0
  32. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/console.py +0 -0
  33. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/devcontainer_template.py +0 -0
  34. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/docker_client.py +0 -0
  35. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/file_utils.py +0 -0
  36. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/git_utils.py +0 -0
  37. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common/utils/repo_cache.py +0 -0
  38. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common.egg-info/SOURCES.txt +0 -0
  39. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common.egg-info/dependency_links.txt +0 -0
  40. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common.egg-info/requires.txt +0 -0
  41. {devs_common-3.2.3 → devs_common-3.3.0}/devs_common.egg-info/top_level.txt +0 -0
  42. {devs_common-3.2.3 → devs_common-3.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-common
3
- Version: 3.2.3
3
+ Version: 3.3.0
4
4
  Summary: Shared utilities for devs package ecosystem
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -40,7 +40,7 @@ from ..utils.docker_client import DockerClient
40
40
  from ..utils.devcontainer import DevContainerCLI
41
41
  from ..utils.devcontainer_template import get_template_dir
42
42
  from ..utils.console import get_console
43
- from ..utils.config_hash import compute_env_config_hash, get_env_mount_path
43
+ from ..utils.config_hash import compute_env_config_hash, compute_devcontainer_hash, get_env_mount_path
44
44
  from .project import Project
45
45
 
46
46
  # Initialize console based on environment
@@ -138,100 +138,84 @@ class ContainerManager:
138
138
 
139
139
  return labels
140
140
 
141
- def should_rebuild_image(self, dev_name: str) -> Tuple[bool, str]:
142
- """Check if devcontainer image should be rebuilt.
143
-
141
+ def should_rebuild_image(self, dev_name: str, project_labels: dict) -> Tuple[bool, str]:
142
+ """Check if devcontainer image should be rebuilt based on content hash.
143
+
144
+ Compares the content hash of devcontainer files against the hash stored
145
+ in the existing container's label. This avoids false positives from fresh
146
+ checkouts, rebases, or other operations that update file timestamps without
147
+ changing the actual content.
148
+
144
149
  Args:
145
150
  dev_name: Development environment name
146
-
151
+ project_labels: Labels used to find the existing container
152
+
147
153
  Returns:
148
154
  Tuple of (should_rebuild, reason)
149
155
  """
150
156
  try:
151
- # Find existing images for this devcontainer configuration
152
- image_pattern = f"vsc-{self.project.get_workspace_name(dev_name)}-"
153
- existing_images = self.docker.find_images_by_pattern(image_pattern)
154
-
155
- if not existing_images:
156
- return False, "No existing image found"
157
-
158
- # Get the newest image creation time
159
- newest_image_time = None
160
- for image_name in existing_images:
161
- image_time = self.docker.get_image_creation_time(image_name)
162
- if image_time and (not newest_image_time or image_time > newest_image_time):
163
- newest_image_time = image_time
164
-
165
- if not newest_image_time:
166
- return False, "Could not determine image age"
167
-
168
- # Check if devcontainer-related files are newer than the image
169
- devcontainer_files = [
170
- self.project.project_dir / ".devcontainer",
171
- self.project.project_dir / "Dockerfile",
172
- self.project.project_dir / "docker-compose.yml",
173
- self.project.project_dir / "docker-compose.yaml",
174
- ]
175
-
176
- for file_path in devcontainer_files:
177
- if file_path.exists():
178
- if file_path.is_file():
179
- file_time = datetime.fromtimestamp(file_path.stat().st_mtime, tz=timezone.utc)
180
- if file_time > newest_image_time:
181
- return True, f"File newer than image: {file_path.name}"
182
- elif file_path.is_dir():
183
- # For directories, find the newest file
184
- newest_file_time = None
185
- newest_file = None
186
-
187
- for item in file_path.rglob('*'):
188
- if item.is_file():
189
- item_time = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc)
190
- if not newest_file_time or item_time > newest_file_time:
191
- newest_file_time = item_time
192
- newest_file = item
193
-
194
- if newest_file_time and newest_file_time > newest_image_time:
195
- return True, f"File newer than image: {newest_file}"
196
-
197
- return False, "Image is up to date"
198
-
157
+ current_hash = compute_devcontainer_hash(self.project.project_dir)
158
+
159
+ # Look for an existing container to compare against
160
+ existing_containers = self.docker.find_containers_by_labels(project_labels)
161
+ if not existing_containers:
162
+ return False, "No existing container to compare against"
163
+
164
+ existing_container = existing_containers[0]
165
+ existing_labels = existing_container.get('labels', {})
166
+ stored_hash = existing_labels.get('devs.devcontainer-hash', '')
167
+
168
+ if not stored_hash:
169
+ # No hash stored yet — don't trigger a rebuild speculatively
170
+ return False, "No devcontainer hash label on existing container"
171
+
172
+ if stored_hash != current_hash:
173
+ return True, f"Devcontainer files changed ({stored_hash} → {current_hash})"
174
+
175
+ return False, "Devcontainer files unchanged"
176
+
199
177
  except (DockerError, OSError) as e:
200
178
  console.print(f"[yellow]Warning: Could not check image rebuild status: {e}[/yellow]")
201
179
  return False, "Could not determine rebuild status"
202
180
 
203
181
  def ensure_container_running(
204
- self,
182
+ self,
205
183
  dev_name: str,
206
184
  workspace_dir: Path,
207
185
  force_rebuild: bool = False,
186
+ check_rebuild: bool = True,
208
187
  debug: bool = False,
209
188
  live: bool = False,
210
189
  extra_env: Optional[Dict[str, str]] = None
211
190
  ) -> bool:
212
191
  """Ensure a container is running for the specified dev environment.
213
-
192
+
214
193
  Args:
215
194
  dev_name: Development environment name
216
195
  workspace_dir: Workspace directory path
217
196
  force_rebuild: Force rebuild even if not needed
197
+ check_rebuild: Automatically rebuild if devcontainer files have changed.
198
+ Set to False (e.g. for the CLI) to only rebuild when force_rebuild=True.
218
199
  debug: Show debug output for devcontainer operations
219
200
  live: Whether to use live mode (mount current directory instead of copying)
220
201
  extra_env: Additional environment variables to pass to container
221
-
202
+
222
203
  Returns:
223
204
  True if container is running successfully
224
-
205
+
225
206
  Raises:
226
207
  ContainerError: If container operations fail
227
208
  """
228
209
  workspace_info = self._get_container_info(dev_name, live)
229
210
  container_name = workspace_info["container_name"]
230
211
  project_labels = self._get_project_labels(dev_name, live)
231
-
212
+
232
213
  try:
233
214
  # Check if we need to rebuild
234
- rebuild_needed, rebuild_reason = self.should_rebuild_image(dev_name)
215
+ if check_rebuild:
216
+ rebuild_needed, rebuild_reason = self.should_rebuild_image(dev_name, project_labels)
217
+ else:
218
+ rebuild_needed, rebuild_reason = False, "Auto-rebuild disabled"
235
219
  if rebuild_needed or force_rebuild:
236
220
  if force_rebuild:
237
221
  console.print(f" 🔄 Forcing image rebuild for {dev_name}...")
@@ -248,9 +232,10 @@ class ContainerManager:
248
232
  console.print(f"[dim]Removing container: {existing_container['name']}[/dim]")
249
233
  self.docker.remove_container(existing_container['name'])
250
234
 
251
- # Compute current config hash for comparison
235
+ # Compute current config hashes for comparison
252
236
  env_mount_path = get_env_mount_path(self.project.info.name)
253
237
  current_config_hash = compute_env_config_hash(self.project.info.name)
238
+ current_devcontainer_hash = compute_devcontainer_hash(self.project.project_dir)
254
239
  console.print(f" 📁 Env mount: {env_mount_path} (hash: {current_config_hash})")
255
240
 
256
241
  # Check if container is already running
@@ -267,13 +252,19 @@ class ContainerManager:
267
252
 
268
253
  console.print(f" 🔍 Found container: {existing_container['name']} (status: {existing_container['status']}, hash: {existing_config_hash or 'none'})")
269
254
 
270
- # Check if config hash has changed
255
+ # Check if config hash has changed (only when auto-rebuild checks are enabled)
271
256
  if existing_config_hash and existing_config_hash != current_config_hash:
272
- config_hash_changed = True
273
- console.print(f" 🔄 Config hash changed ({existing_config_hash} → {current_config_hash}), will restart container")
274
- elif not existing_config_hash:
275
- console.print(f" ⚠️ Container has no config hash label, will restart to add it")
276
- config_hash_changed = True
257
+ if check_rebuild:
258
+ config_hash_changed = True
259
+ console.print(f" 🔄 Config hash changed ({existing_config_hash} → {current_config_hash}), will restart container")
260
+ else:
261
+ console.print(f" ℹ️ Env config has changed since container was created — use --rebuild-if-changed to restart")
262
+
263
+ # Check devcontainer hash when not rebuilding, to inform user of changes
264
+ if not check_rebuild:
265
+ existing_devcontainer_hash = existing_labels.get('devs.devcontainer-hash', '')
266
+ if existing_devcontainer_hash and existing_devcontainer_hash != current_devcontainer_hash:
267
+ console.print(f" ℹ️ Devcontainer files have changed since image was built — use --rebuild-if-changed to rebuild")
277
268
 
278
269
  # Check if existing container matches the requested mode
279
270
  if existing_is_live != live:
@@ -335,7 +326,8 @@ class ContainerManager:
335
326
  config_path=config_path,
336
327
  live=live,
337
328
  extra_env=extra_env,
338
- config_hash=current_config_hash
329
+ config_hash=current_config_hash,
330
+ devcontainer_hash=current_devcontainer_hash
339
331
  )
340
332
 
341
333
  if not success:
@@ -628,8 +620,8 @@ class ContainerManager:
628
620
  console.print(f" ▶️ Starting stopped container: {container_id}")
629
621
  self.docker.start_container(container_id)
630
622
  else:
631
- # Ensure container is running (may create/restart as needed)
632
- if not self.ensure_container_running(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env):
623
+ # Ensure container is running (may create/restart as needed, but never auto-rebuild)
624
+ if not self.ensure_container_running(dev_name, workspace_dir, check_rebuild=False, debug=debug, live=live, extra_env=extra_env):
633
625
  raise ContainerError(f"Failed to start container for {dev_name}")
634
626
 
635
627
  return container_name, container_workspace_dir
@@ -39,11 +39,60 @@ def compute_env_config_hash(project_name: str) -> str:
39
39
  Short hash string (first 12 chars of SHA256)
40
40
  """
41
41
  env_path = get_env_mount_path(project_name)
42
- return _hash_directory_mtimes(env_path)
42
+ return _hash_directory_contents(env_path)
43
43
 
44
44
 
45
- def _hash_directory_mtimes(directory: Path) -> str:
46
- """Hash mtimes of all files in a directory.
45
+ def compute_devcontainer_hash(project_dir: Path) -> str:
46
+ """Compute a content hash of devcontainer-related files.
47
+
48
+ Uses file contents (not mtimes) so that fresh checkouts or rebases that
49
+ don't change the actual devcontainer files produce the same hash.
50
+
51
+ Args:
52
+ project_dir: Root directory of the project
53
+
54
+ Returns:
55
+ Short hash string (first 12 chars of SHA256)
56
+ """
57
+ hasher = hashlib.sha256()
58
+
59
+ devcontainer_paths = [
60
+ project_dir / ".devcontainer",
61
+ project_dir / "Dockerfile",
62
+ project_dir / "docker-compose.yml",
63
+ project_dir / "docker-compose.yaml",
64
+ ]
65
+
66
+ found_any = False
67
+ try:
68
+ for path in devcontainer_paths:
69
+ if not path.exists():
70
+ continue
71
+ found_any = True
72
+ if path.is_file():
73
+ rel = path.relative_to(project_dir)
74
+ hasher.update(str(rel).encode())
75
+ hasher.update(path.read_bytes())
76
+ elif path.is_dir():
77
+ for item in sorted(path.rglob("*")):
78
+ if item.is_file():
79
+ rel = item.relative_to(project_dir)
80
+ hasher.update(str(rel).encode())
81
+ hasher.update(item.read_bytes())
82
+ except (OSError, PermissionError):
83
+ hasher.update(b"error")
84
+
85
+ if not found_any:
86
+ hasher.update(b"no-devcontainer")
87
+
88
+ return hasher.hexdigest()[:12]
89
+
90
+
91
+ def _hash_directory_contents(directory: Path) -> str:
92
+ """Hash the contents of all files in a directory.
93
+
94
+ Uses file contents rather than mtimes so that fresh checkouts or rebases
95
+ that don't change actual file content produce the same hash.
47
96
 
48
97
  Args:
49
98
  directory: Directory to hash
@@ -65,10 +114,10 @@ def _hash_directory_mtimes(directory: Path) -> str:
65
114
  files = sorted(directory.rglob("*"))
66
115
  for file_path in files:
67
116
  if file_path.is_file():
68
- # Include relative path and mtime in hash
117
+ # Include relative path and file contents in hash
69
118
  rel_path = file_path.relative_to(directory)
70
- mtime = file_path.stat().st_mtime
71
- hasher.update(f"{rel_path}:{mtime}".encode())
119
+ hasher.update(str(rel_path).encode())
120
+ hasher.update(file_path.read_bytes())
72
121
  except (OSError, PermissionError):
73
122
  hasher.update(b"error")
74
123
 
@@ -66,7 +66,9 @@ def prepare_devcontainer_environment(
66
66
  # Create default .env file with GH_TOKEN if available
67
67
  env_content = ""
68
68
  if 'GH_TOKEN' in os.environ:
69
- env_content = f"GH_TOKEN={os.environ['GH_TOKEN']}\n"
69
+ env_content += f"GH_TOKEN={os.environ['GH_TOKEN']}\n"
70
+ if 'CLAUDE_CODE_OAUTH_TOKEN' in os.environ:
71
+ env_content += f"CLAUDE_CODE_OAUTH_TOKEN={os.environ['CLAUDE_CODE_OAUTH_TOKEN']}\n"
70
72
  env_file.write_text(env_content)
71
73
 
72
74
  env['DEVS_ENV_MOUNT_PATH'] = str(env_mount_path)
@@ -87,6 +89,10 @@ def prepare_devcontainer_environment(
87
89
  # Pass through GH_TOKEN if available (for GitHub authentication)
88
90
  if 'GH_TOKEN' in os.environ:
89
91
  env['GH_TOKEN'] = os.environ['GH_TOKEN']
92
+
93
+ # Pass through CLAUDE_CODE_OAUTH_TOKEN if available (for Claude authentication)
94
+ if 'CLAUDE_CODE_OAUTH_TOKEN' in os.environ:
95
+ env['CLAUDE_CODE_OAUTH_TOKEN'] = os.environ['CLAUDE_CODE_OAUTH_TOKEN']
90
96
 
91
97
  # Merge in any extra environment variables
92
98
  if extra_env:
@@ -232,10 +238,11 @@ class DevContainerCLI:
232
238
  config_path: Optional[Path] = None,
233
239
  live: bool = False,
234
240
  extra_env: Optional[dict] = None,
235
- config_hash: Optional[str] = None
241
+ config_hash: Optional[str] = None,
242
+ devcontainer_hash: Optional[str] = None
236
243
  ) -> bool:
237
244
  """Start a devcontainer.
238
-
245
+
239
246
  Args:
240
247
  workspace_folder: Path to workspace folder on host
241
248
  dev_name: Development environment name
@@ -248,7 +255,8 @@ class DevContainerCLI:
248
255
  config_path: Optional path to external devcontainer config directory
249
256
  live: Whether to use live mode (mount current directory)
250
257
  extra_env: Additional environment variables to pass to container
251
- config_hash: Optional hash of config files for detecting changes
258
+ config_hash: Optional hash of env config files for detecting changes
259
+ devcontainer_hash: Optional content hash of devcontainer files for rebuild detection
252
260
 
253
261
  Returns:
254
262
  True if successful
@@ -285,6 +293,10 @@ class DevContainerCLI:
285
293
  if config_hash:
286
294
  cmd.extend(['--id-label', f'devs.config-hash={config_hash}'])
287
295
 
296
+ # Add devcontainer content hash label for rebuild detection
297
+ if devcontainer_hash:
298
+ cmd.extend(['--id-label', f'devs.devcontainer-hash={devcontainer_hash}'])
299
+
288
300
  # Add extra container labels from config if provided
289
301
  if self.config and hasattr(self.config, 'container_labels'):
290
302
  for k, v in self.config.container_labels.items():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-common
3
- Version: 3.2.3
3
+ Version: 3.3.0
4
4
  Summary: Shared utilities for devs package ecosystem
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devs-common"
7
- version = "3.2.3"
7
+ version = "3.3.0"
8
8
  description = "Shared utilities for devs package ecosystem"
9
9
  authors = [
10
10
  {name = "Dan Lester"},
File without changes
File without changes
File without changes