tasktree 0.0.20__py3-none-any.whl → 0.0.22__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.
tasktree/docker.py CHANGED
@@ -9,10 +9,11 @@ import os
9
9
  import platform
10
10
  import re
11
11
  import subprocess
12
- import time
13
12
  from pathlib import Path
14
13
  from typing import TYPE_CHECKING
15
14
 
15
+ from pathspec import PathSpec
16
+
16
17
  try:
17
18
  import pathspec
18
19
  except ImportError:
@@ -23,31 +24,44 @@ if TYPE_CHECKING:
23
24
 
24
25
 
25
26
  class DockerError(Exception):
26
- """Raised when Docker operations fail."""
27
+ """
28
+ Raised when Docker operations fail.
29
+ @athena: 876629e35765
30
+ """
27
31
 
28
32
  pass
29
33
 
30
34
 
31
35
  class DockerManager:
32
- """Manages Docker image building and container execution."""
36
+ """
37
+ Manages Docker image building and container execution.
38
+ @athena: 1a8a919eb05d
39
+ """
33
40
 
34
41
  def __init__(self, project_root: Path):
35
- """Initialize Docker manager.
42
+ """
43
+ Initialize Docker manager.
36
44
 
37
45
  Args:
38
- project_root: Root directory of the project (where tasktree.yaml is located)
46
+ project_root: Root directory of the project (where tasktree.yaml is located)
47
+ @athena: eb7d4c5a27aa
39
48
  """
40
49
  self._project_root = project_root
41
- self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
50
+ self._built_images: dict[
51
+ str, tuple[str, str]
52
+ ] = {} # env_name -> (image_tag, image_id) cache
42
53
 
43
- def _should_add_user_flag(self) -> bool:
44
- """Check if --user flag should be added to docker run.
54
+ @staticmethod
55
+ def _should_add_user_flag() -> bool:
56
+ """
57
+ Check if --user flag should be added to docker run.
45
58
 
46
59
  Returns False on Windows (where Docker Desktop handles UID mapping automatically).
47
60
  Returns True on Linux/macOS where os.getuid() and os.getgid() are available.
48
61
 
49
62
  Returns:
50
- True if --user flag should be added, False otherwise
63
+ True if --user flag should be added, False otherwise
64
+ @athena: c5932076dfda
51
65
  """
52
66
  # Skip on Windows - Docker Desktop handles UID mapping differently
53
67
  if platform.system() == "Windows":
@@ -57,18 +71,20 @@ class DockerManager:
57
71
  return hasattr(os, "getuid") and hasattr(os, "getgid")
58
72
 
59
73
  def ensure_image_built(self, env: Environment) -> tuple[str, str]:
60
- """Build Docker image if not already built this invocation.
74
+ """
75
+ Build Docker image if not already built this invocation.
61
76
 
62
77
  Args:
63
- env: Environment definition with dockerfile and context
78
+ env: Environment definition with dockerfile and context
64
79
 
65
80
  Returns:
66
- Tuple of (image_tag, image_id)
67
- - image_tag: Tag like "tt-env-builder"
68
- - image_id: Full image ID like "sha256:abc123..."
81
+ Tuple of (image_tag, image_id)
82
+ - image_tag: Tag like "tt-env-builder"
83
+ - image_id: Full image ID like "sha256:abc123..."
69
84
 
70
85
  Raises:
71
- DockerError: If docker command not available or build fails
86
+ DockerError: If docker command not available or build fails
87
+ @athena: 9b3c11c29fbb
72
88
  """
73
89
  # Check if already built this invocation
74
90
  if env.name in self._built_images:
@@ -132,19 +148,21 @@ class DockerManager:
132
148
  working_dir: Path,
133
149
  container_working_dir: str,
134
150
  ) -> subprocess.CompletedProcess:
135
- """Execute command inside Docker container.
151
+ """
152
+ Execute command inside Docker container.
136
153
 
137
154
  Args:
138
- env: Environment definition
139
- cmd: Command to execute
140
- working_dir: Host working directory (for resolving relative volume paths)
141
- container_working_dir: Working directory inside container
155
+ env: Environment definition
156
+ cmd: Command to execute
157
+ working_dir: Host working directory (for resolving relative volume paths)
158
+ container_working_dir: Working directory inside container
142
159
 
143
160
  Returns:
144
- CompletedProcess from subprocess.run
161
+ CompletedProcess from subprocess.run
145
162
 
146
163
  Raises:
147
- DockerError: If docker run fails
164
+ DockerError: If docker run fails
165
+ @athena: f24fc9c27f81
148
166
  """
149
167
  # Ensure image is built (returns tag and ID)
150
168
  image_tag, image_id = self.ensure_image_built(env)
@@ -183,7 +201,10 @@ class DockerManager:
183
201
 
184
202
  # Add shell and command
185
203
  shell = env.shell or "sh"
186
- docker_cmd.extend([shell, "-c", cmd])
204
+ shell_args = (
205
+ env.args or [] if isinstance(env.args, list) else list(env.args.values())
206
+ )
207
+ docker_cmd.extend([shell, *shell_args, "-c", cmd])
187
208
 
188
209
  # Execute
189
210
  try:
@@ -200,7 +221,8 @@ class DockerManager:
200
221
  ) from e
201
222
 
202
223
  def _resolve_volume_mount(self, volume: str) -> str:
203
- """Resolve volume mount specification.
224
+ """
225
+ Resolve volume mount specification.
204
226
 
205
227
  Handles:
206
228
  - Relative paths (resolved relative to project_root)
@@ -208,10 +230,11 @@ class DockerManager:
208
230
  - Absolute paths (used as-is)
209
231
 
210
232
  Args:
211
- volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
233
+ volume: Volume specification (e.g., "./src:/workspace/src" or "~/.cargo:/root/.cargo")
212
234
 
213
235
  Returns:
214
- Resolved volume specification with absolute host path
236
+ Resolved volume specification with absolute host path
237
+ @athena: c7661050443e
215
238
  """
216
239
  if ":" not in volume:
217
240
  raise ValueError(
@@ -234,11 +257,14 @@ class DockerManager:
234
257
 
235
258
  return f"{resolved_host_path}:{container_path}"
236
259
 
237
- def _check_docker_available(self) -> None:
238
- """Check if docker command is available.
260
+ @staticmethod
261
+ def _check_docker_available() -> None:
262
+ """
263
+ Check if docker command is available.
239
264
 
240
265
  Raises:
241
- DockerError: If docker is not available
266
+ DockerError: If docker is not available
267
+ @athena: 8deaf8c5c05e
242
268
  """
243
269
  try:
244
270
  subprocess.run(
@@ -253,17 +279,20 @@ class DockerManager:
253
279
  "Visit https://docs.docker.com/get-docker/ for installation instructions."
254
280
  )
255
281
 
256
- def _get_image_id(self, image_tag: str) -> str:
257
- """Get the full image ID for a given tag.
282
+ @staticmethod
283
+ def _get_image_id(image_tag: str) -> str:
284
+ """
285
+ Get the full image ID for a given tag.
258
286
 
259
287
  Args:
260
- image_tag: Docker image tag (e.g., "tt-env-builder")
288
+ image_tag: Docker image tag (e.g., "tt-env-builder")
261
289
 
262
290
  Returns:
263
- Full image ID (e.g., "sha256:abc123def456...")
291
+ Full image ID (e.g., "sha256:abc123def456...")
264
292
 
265
293
  Raises:
266
- DockerError: If cannot inspect image
294
+ DockerError: If cannot inspect image
295
+ @athena: 9e5aa77003ee
267
296
  """
268
297
  try:
269
298
  result = subprocess.run(
@@ -279,21 +308,22 @@ class DockerManager:
279
308
 
280
309
 
281
310
  def is_docker_environment(env: Environment) -> bool:
282
- """Check if environment is Docker-based.
311
+ """
312
+ Check if environment is Docker-based.
283
313
 
284
314
  Args:
285
- env: Environment to check
315
+ env: Environment to check
286
316
 
287
317
  Returns:
288
- True if environment has a dockerfile field, False otherwise
318
+ True if environment has a dockerfile field, False otherwise
319
+ @athena: 1ffd255a4e90
289
320
  """
290
321
  return bool(env.dockerfile)
291
322
 
292
323
 
293
- def resolve_container_working_dir(
294
- env_working_dir: str, task_working_dir: str
295
- ) -> str:
296
- """Resolve working directory inside container.
324
+ def resolve_container_working_dir(env_working_dir: str, task_working_dir: str) -> str:
325
+ """
326
+ Resolve working directory inside container.
297
327
 
298
328
  Combines environment's working_dir with task's working_dir:
299
329
  - If task specifies working_dir: container_dir = env_working_dir / task_working_dir
@@ -301,11 +331,12 @@ def resolve_container_working_dir(
301
331
  - If neither specify: container_dir = "/" (Docker default)
302
332
 
303
333
  Args:
304
- env_working_dir: Working directory from environment definition
305
- task_working_dir: Working directory from task definition
334
+ env_working_dir: Working directory from environment definition
335
+ task_working_dir: Working directory from task definition
306
336
 
307
337
  Returns:
308
- Resolved working directory path
338
+ Resolved working directory path
339
+ @athena: bb13d00dd07d
309
340
  """
310
341
  if not env_working_dir and not task_working_dir:
311
342
  return "/"
@@ -321,14 +352,16 @@ def resolve_container_working_dir(
321
352
  return f"/{task_working_dir.lstrip('/')}"
322
353
 
323
354
 
324
- def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
325
- """Parse .dockerignore file into pathspec matcher.
355
+ def parse_dockerignore(dockerignore_path: Path) -> PathSpec | None:
356
+ """
357
+ Parse .dockerignore file into pathspec matcher.
326
358
 
327
359
  Args:
328
- dockerignore_path: Path to .dockerignore file
360
+ dockerignore_path: Path to .dockerignore file
329
361
 
330
362
  Returns:
331
- PathSpec object for matching, or None if file doesn't exist or pathspec not available
363
+ PathSpec object for matching, or None if file doesn't exist or pathspec not available
364
+ @athena: 62bc07a3c6d0
332
365
  """
333
366
  if pathspec is None:
334
367
  # pathspec library not available - can't parse .dockerignore
@@ -351,17 +384,19 @@ def context_changed_since(
351
384
  dockerignore_path: Path | None,
352
385
  last_run_time: float,
353
386
  ) -> bool:
354
- """Check if any file in Docker build context has changed since last run.
387
+ """
388
+ Check if any file in Docker build context has changed since last run.
355
389
 
356
390
  Uses early-exit optimization: stops on first changed file found.
357
391
 
358
392
  Args:
359
- context_path: Path to Docker build context directory
360
- dockerignore_path: Optional path to .dockerignore file
361
- last_run_time: Unix timestamp of last task run
393
+ context_path: Path to Docker build context directory
394
+ dockerignore_path: Optional path to .dockerignore file
395
+ last_run_time: Unix timestamp of last task run
362
396
 
363
397
  Returns:
364
- True if any file changed, False otherwise
398
+ True if any file changed, False otherwise
399
+ @athena: 556acb1ed6ca
365
400
  """
366
401
  # Parse .dockerignore
367
402
  dockerignore_spec = None
@@ -395,20 +430,22 @@ def context_changed_since(
395
430
 
396
431
 
397
432
  def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]:
398
- """Extract image references from FROM lines in Dockerfile.
433
+ """
434
+ Extract image references from FROM lines in Dockerfile.
399
435
 
400
436
  Args:
401
- dockerfile_content: Content of Dockerfile
437
+ dockerfile_content: Content of Dockerfile
402
438
 
403
439
  Returns:
404
- List of (image_reference, digest) tuples where digest may be None for unpinned images
405
- Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
440
+ List of (image_reference, digest) tuples where digest may be None for unpinned images
441
+ Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
442
+ @athena: de0d013fdd05
406
443
  """
407
444
  # Regex pattern to match FROM lines
408
445
  # Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
409
446
  from_pattern = re.compile(
410
447
  r"^\s*FROM\s+" # FROM keyword
411
- r"(?:--platform=[^\s]+\s+)?" # Optional platform flag
448
+ r"(?:--platform=\S+\s+)?" # Optional platform flag
412
449
  r"([^\s@]+)" # Image name (possibly with :tag)
413
450
  r"(?:@(sha256:[a-f0-9]+))?" # Optional @digest
414
451
  r"(?:\s+AS\s+\w+)?" # Optional AS alias
@@ -421,26 +458,30 @@ def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]
421
458
 
422
459
 
423
460
  def check_unpinned_images(dockerfile_content: str) -> list[str]:
424
- """Check for unpinned base images in Dockerfile.
461
+ """
462
+ Check for unpinned base images in Dockerfile.
425
463
 
426
464
  Args:
427
- dockerfile_content: Content of Dockerfile
465
+ dockerfile_content: Content of Dockerfile
428
466
 
429
467
  Returns:
430
- List of unpinned image references (images without @sha256:... digests)
468
+ List of unpinned image references (images without @sha256:... digests)
469
+ @athena: 58cc6de8fc96
431
470
  """
432
471
  images = extract_from_images(dockerfile_content)
433
472
  return [image for image, digest in images if digest is None]
434
473
 
435
474
 
436
475
  def parse_base_image_digests(dockerfile_content: str) -> list[str]:
437
- """Parse pinned base image digests from Dockerfile.
476
+ """
477
+ Parse pinned base image digests from Dockerfile.
438
478
 
439
479
  Args:
440
- dockerfile_content: Content of Dockerfile
480
+ dockerfile_content: Content of Dockerfile
441
481
 
442
482
  Returns:
443
- List of digests (e.g., ["sha256:abc123...", "sha256:def456..."])
483
+ List of digests (e.g., ["sha256:abc123...", "sha256:def456..."])
484
+ @athena: c4d1da6b067c
444
485
  """
445
486
  images = extract_from_images(dockerfile_content)
446
487
  return [digest for _image, digest in images if digest is not None]