tasktree 0.0.21__py3-none-any.whl → 0.0.23__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.
@@ -0,0 +1,66 @@
1
+ from tasktree.logging import Logger, LogLevel
2
+ from rich.console import Console
3
+
4
+
5
+ class ConsoleLogger(Logger):
6
+ """
7
+ Console-based logger implementation using Rich for formatting.
8
+
9
+ Filters log messages based on the current log level. Messages with severity
10
+ lower than the current level are suppressed. Supports a stack-based level
11
+ management system for temporary verbosity changes.
12
+ @athena: a892913fc11c
13
+ """
14
+
15
+ def __init__(self, console: Console, level: LogLevel = LogLevel.INFO) -> None:
16
+ """
17
+ Initialize the console logger.
18
+
19
+ Args:
20
+ console: Rich Console instance to use for output
21
+ level: Initial log level (default: INFO)
22
+ @athena: 388d9c273a6a
23
+ """
24
+ self._console = console
25
+ self._levels = [level]
26
+
27
+ def log(self, level: LogLevel = LogLevel.INFO, *args, **kwargs) -> None:
28
+ """
29
+ Log a message to the console if it meets the current level threshold.
30
+
31
+ Messages are only printed if their level is at or above the current level
32
+ (i.e., level.value <= current_level.value, since lower values = higher severity).
33
+
34
+ Args:
35
+ level: The severity level of this message (default: INFO)
36
+ *args: Positional arguments passed to Rich Console.print()
37
+ **kwargs: Keyword arguments passed to Rich Console.print()
38
+ @athena: efae38733da1
39
+ """
40
+ if self._levels[-1].value >= level.value:
41
+ self._console.print(*args, **kwargs)
42
+
43
+ def push_level(self, level: LogLevel) -> None:
44
+ """
45
+ Push a new log level onto the stack.
46
+
47
+ Args:
48
+ level: The new log level to activate
49
+ @athena: 07c7493570b1
50
+ """
51
+ self._levels.append(level)
52
+
53
+ def pop_level(self) -> LogLevel:
54
+ """
55
+ Pop the current log level and return to the previous level.
56
+
57
+ Returns:
58
+ The log level that was popped
59
+
60
+ Raises:
61
+ RuntimeError: If attempting to pop the base (initial) log level
62
+ @athena: 65ac19e00168
63
+ """
64
+ if len(self._levels) <= 1:
65
+ raise RuntimeError("Cannot pop the base log level")
66
+ return self._levels.pop()
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:
@@ -20,6 +21,7 @@ except ImportError:
20
21
 
21
22
  if TYPE_CHECKING:
22
23
  from tasktree.parser import Environment
24
+ from tasktree.process_runner import ProcessRunner
23
25
 
24
26
 
25
27
  class DockerError(Exception):
@@ -34,7 +36,7 @@ class DockerError(Exception):
34
36
  class DockerManager:
35
37
  """
36
38
  Manages Docker image building and container execution.
37
- @athena: ef3cc3d7bcbe
39
+ @athena: f8f5c2693d84
38
40
  """
39
41
 
40
42
  def __init__(self, project_root: Path):
@@ -46,9 +48,12 @@ class DockerManager:
46
48
  @athena: eb7d4c5a27aa
47
49
  """
48
50
  self._project_root = project_root
49
- self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
51
+ self._built_images: dict[
52
+ str, tuple[str, str]
53
+ ] = {} # env_name -> (image_tag, image_id) cache
50
54
 
51
- def _should_add_user_flag(self) -> bool:
55
+ @staticmethod
56
+ def _should_add_user_flag() -> bool:
52
57
  """
53
58
  Check if --user flag should be added to docker run.
54
59
 
@@ -57,7 +62,7 @@ class DockerManager:
57
62
 
58
63
  Returns:
59
64
  True if --user flag should be added, False otherwise
60
- @athena: 6a872eea6a10
65
+ @athena: c5932076dfda
61
66
  """
62
67
  # Skip on Windows - Docker Desktop handles UID mapping differently
63
68
  if platform.system() == "Windows":
@@ -66,12 +71,15 @@ class DockerManager:
66
71
  # Check if os.getuid() and os.getgid() are available (Linux/macOS)
67
72
  return hasattr(os, "getuid") and hasattr(os, "getgid")
68
73
 
69
- def ensure_image_built(self, env: Environment) -> tuple[str, str]:
74
+ def ensure_image_built(
75
+ self, env: Environment, process_runner: ProcessRunner
76
+ ) -> tuple[str, str]:
70
77
  """
71
78
  Build Docker image if not already built this invocation.
72
79
 
73
80
  Args:
74
81
  env: Environment definition with dockerfile and context
82
+ process_runner: ProcessRunner instance for subprocess execution
75
83
 
76
84
  Returns:
77
85
  Tuple of (image_tag, image_id)
@@ -80,7 +88,7 @@ class DockerManager:
80
88
 
81
89
  Raises:
82
90
  DockerError: If docker command not available or build fails
83
- @athena: 9b3c11c29fbb
91
+ @athena: 42b53d2685e0
84
92
  """
85
93
  # Check if already built this invocation
86
94
  if env.name in self._built_images:
@@ -115,7 +123,7 @@ class DockerManager:
115
123
 
116
124
  docker_build_cmd.append(str(context_path))
117
125
 
118
- subprocess.run(
126
+ process_runner.run(
119
127
  docker_build_cmd,
120
128
  check=True,
121
129
  capture_output=False, # Show build output to user
@@ -143,6 +151,7 @@ class DockerManager:
143
151
  cmd: str,
144
152
  working_dir: Path,
145
153
  container_working_dir: str,
154
+ process_runner: ProcessRunner,
146
155
  ) -> subprocess.CompletedProcess:
147
156
  """
148
157
  Execute command inside Docker container.
@@ -152,16 +161,17 @@ class DockerManager:
152
161
  cmd: Command to execute
153
162
  working_dir: Host working directory (for resolving relative volume paths)
154
163
  container_working_dir: Working directory inside container
164
+ process_runner: ProcessRunner instance to use for subprocess execution
155
165
 
156
166
  Returns:
157
167
  CompletedProcess from subprocess.run
158
168
 
159
169
  Raises:
160
170
  DockerError: If docker run fails
161
- @athena: 2c963babb5ca
171
+ @athena: ef024ea2c182
162
172
  """
163
173
  # Ensure image is built (returns tag and ID)
164
- image_tag, image_id = self.ensure_image_built(env)
174
+ image_tag, image_id = self.ensure_image_built(env, process_runner)
165
175
 
166
176
  # Build docker run command
167
177
  docker_cmd = ["docker", "run", "--rm"]
@@ -197,11 +207,14 @@ class DockerManager:
197
207
 
198
208
  # Add shell and command
199
209
  shell = env.shell or "sh"
200
- docker_cmd.extend([shell, "-c", cmd])
210
+ shell_args = (
211
+ env.args or [] if isinstance(env.args, list) else list(env.args.values())
212
+ )
213
+ docker_cmd.extend([shell, *shell_args, "-c", cmd])
201
214
 
202
215
  # Execute
203
216
  try:
204
- result = subprocess.run(
217
+ result = process_runner.run(
205
218
  docker_cmd,
206
219
  cwd=working_dir,
207
220
  check=True,
@@ -250,13 +263,14 @@ class DockerManager:
250
263
 
251
264
  return f"{resolved_host_path}:{container_path}"
252
265
 
253
- def _check_docker_available(self) -> None:
266
+ @staticmethod
267
+ def _check_docker_available() -> None:
254
268
  """
255
269
  Check if docker command is available.
256
270
 
257
271
  Raises:
258
272
  DockerError: If docker is not available
259
- @athena: 16ba713e3962
273
+ @athena: 8deaf8c5c05e
260
274
  """
261
275
  try:
262
276
  subprocess.run(
@@ -271,7 +285,8 @@ class DockerManager:
271
285
  "Visit https://docs.docker.com/get-docker/ for installation instructions."
272
286
  )
273
287
 
274
- def _get_image_id(self, image_tag: str) -> str:
288
+ @staticmethod
289
+ def _get_image_id(image_tag: str) -> str:
275
290
  """
276
291
  Get the full image ID for a given tag.
277
292
 
@@ -283,7 +298,7 @@ class DockerManager:
283
298
 
284
299
  Raises:
285
300
  DockerError: If cannot inspect image
286
- @athena: e4bc075fe857
301
+ @athena: 9e5aa77003ee
287
302
  """
288
303
  try:
289
304
  result = subprocess.run(
@@ -312,9 +327,7 @@ def is_docker_environment(env: Environment) -> bool:
312
327
  return bool(env.dockerfile)
313
328
 
314
329
 
315
- def resolve_container_working_dir(
316
- env_working_dir: str, task_working_dir: str
317
- ) -> str:
330
+ def resolve_container_working_dir(env_working_dir: str, task_working_dir: str) -> str:
318
331
  """
319
332
  Resolve working directory inside container.
320
333
 
@@ -345,7 +358,7 @@ def resolve_container_working_dir(
345
358
  return f"/{task_working_dir.lstrip('/')}"
346
359
 
347
360
 
348
- def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
361
+ def parse_dockerignore(dockerignore_path: Path) -> PathSpec | None:
349
362
  """
350
363
  Parse .dockerignore file into pathspec matcher.
351
364
 
@@ -354,7 +367,7 @@ def parse_dockerignore(dockerignore_path: Path) -> "pathspec.PathSpec | None":
354
367
 
355
368
  Returns:
356
369
  PathSpec object for matching, or None if file doesn't exist or pathspec not available
357
- @athena: 62bc07a3c6d0
370
+ @athena: 13fca9ee5a73
358
371
  """
359
372
  if pathspec is None:
360
373
  # pathspec library not available - can't parse .dockerignore
@@ -432,13 +445,13 @@ def extract_from_images(dockerfile_content: str) -> list[tuple[str, str | None]]
432
445
  Returns:
433
446
  List of (image_reference, digest) tuples where digest may be None for unpinned images
434
447
  Example: [("rust:1.75", None), ("rust", "sha256:abc123...")]
435
- @athena: ede7ed483bdd
448
+ @athena: de0d013fdd05
436
449
  """
437
450
  # Regex pattern to match FROM lines
438
451
  # Handles: FROM [--platform=...] image[:tag][@digest] [AS alias]
439
452
  from_pattern = re.compile(
440
453
  r"^\s*FROM\s+" # FROM keyword
441
- r"(?:--platform=[^\s]+\s+)?" # Optional platform flag
454
+ r"(?:--platform=\S+\s+)?" # Optional platform flag
442
455
  r"([^\s@]+)" # Image name (possibly with :tag)
443
456
  r"(?:@(sha256:[a-f0-9]+))?" # Optional @digest
444
457
  r"(?:\s+AS\s+\w+)?" # Optional AS alias