tasktree 0.0.7__py3-none-any.whl → 0.0.9__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/cli.py CHANGED
@@ -26,6 +26,41 @@ app = typer.Typer(
26
26
  console = Console()
27
27
 
28
28
 
29
+ def _format_task_arguments(arg_specs: list[str | dict]) -> str:
30
+ """Format task arguments for display in list output.
31
+
32
+ Args:
33
+ arg_specs: List of argument specifications from task definition (strings or dicts)
34
+
35
+ Returns:
36
+ Formatted string showing arguments with types and defaults
37
+
38
+ Examples:
39
+ ["mode", "target"] -> "mode:str target:str"
40
+ ["mode=debug", "target=x86_64"] -> "mode:str [=debug] target:str [=x86_64]"
41
+ ["port:int", "debug:bool=false"] -> "port:int debug:bool [=false]"
42
+ [{"timeout": {"type": "int", "default": 30}}] -> "timeout:int [=30]"
43
+ """
44
+ if not arg_specs:
45
+ return ""
46
+
47
+ formatted_parts = []
48
+ for spec_str in arg_specs:
49
+ parsed = parse_arg_spec(spec_str)
50
+
51
+ # Format: name:type or name:type [=default]
52
+ # Argument names in normal intensity, types and defaults in dim
53
+ arg_part = f"{parsed.name}[dim]:{parsed.arg_type}[/dim]"
54
+
55
+ if parsed.default is not None:
56
+ # Use dim styling for the default value part
57
+ arg_part += f" [dim]\\[={parsed.default}][/dim]"
58
+
59
+ formatted_parts.append(arg_part)
60
+
61
+ return " ".join(formatted_parts)
62
+
63
+
29
64
  def _list_tasks(tasks_file: Optional[str] = None):
30
65
  """List all available tasks with descriptions."""
31
66
  recipe = _get_recipe(tasks_file)
@@ -33,14 +68,27 @@ def _list_tasks(tasks_file: Optional[str] = None):
33
68
  console.print("[red]No recipe file found (tasktree.yaml, tasktree.yml, tt.yaml, or *.tasks)[/red]")
34
69
  raise typer.Exit(1)
35
70
 
36
- table = Table(title="Available Tasks")
37
- table.add_column("Task", style="cyan", no_wrap=True)
38
- table.add_column("Description", style="white")
71
+ # Calculate maximum task name length for fixed-width column
72
+ max_task_name_len = max(len(name) for name in recipe.task_names()) if recipe.task_names() else 0
73
+
74
+ # Create borderless table with three columns
75
+ table = Table(show_edge=False, show_header=False, box=None, padding=(0, 2))
76
+
77
+ # Command column: fixed width to accommodate longest task name
78
+ table.add_column("Command", style="bold cyan", no_wrap=True, width=max_task_name_len)
79
+
80
+ # Arguments column: allow wrapping with sensible max width
81
+ table.add_column("Arguments", style="white", max_width=60)
82
+
83
+ # Description column: allow wrapping with sensible max width
84
+ table.add_column("Description", style="white", max_width=80)
39
85
 
40
86
  for task_name in sorted(recipe.task_names()):
41
87
  task = recipe.get_task(task_name)
42
88
  desc = task.desc if task else ""
43
- table.add_row(task_name, desc)
89
+ args_formatted = _format_task_arguments(task.args) if task else ""
90
+
91
+ table.add_row(task_name, args_formatted, desc)
44
92
 
45
93
  console.print(table)
46
94
 
@@ -351,9 +399,9 @@ def _execute_dynamic_task(args: list[str], force: bool = False, only: bool = Fal
351
399
  state.load()
352
400
  executor = Executor(recipe, state)
353
401
 
354
- # Prune state before execution (compute hashes with effective environment)
402
+ # Prune state before execution (compute hashes with effective environment and dependencies)
355
403
  valid_hashes = {
356
- hash_task(t.cmd, t.outputs, t.working_dir, t.args, executor._get_effective_env_name(t))
404
+ hash_task(t.cmd, t.outputs, t.working_dir, t.args, executor._get_effective_env_name(t), t.deps)
357
405
  for t in recipe.tasks.values()
358
406
  }
359
407
  state.prune(valid_hashes)
@@ -375,8 +423,8 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
375
423
 
376
424
  parsed_specs = []
377
425
  for spec in arg_specs:
378
- name, arg_type, default = parse_arg_spec(spec)
379
- parsed_specs.append((name, arg_type, default))
426
+ parsed = parse_arg_spec(spec)
427
+ parsed_specs.append(parsed)
380
428
 
381
429
  args_dict = {}
382
430
  positional_index = 0
@@ -386,41 +434,49 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
386
434
  if "=" in value_str:
387
435
  arg_name, arg_value = value_str.split("=", 1)
388
436
  # Find the spec for this argument
389
- spec = next((s for s in parsed_specs if s[0] == arg_name), None)
437
+ spec = next((s for s in parsed_specs if s.name == arg_name), None)
390
438
  if spec is None:
391
439
  console.print(f"[red]Unknown argument: {arg_name}[/red]")
392
440
  raise typer.Exit(1)
393
- name, arg_type, default = spec
394
441
  else:
395
442
  # Positional argument
396
443
  if positional_index >= len(parsed_specs):
397
444
  console.print(f"[red]Too many arguments[/red]")
398
445
  raise typer.Exit(1)
399
- name, arg_type, default = parsed_specs[positional_index]
446
+ spec = parsed_specs[positional_index]
400
447
  arg_value = value_str
401
448
  positional_index += 1
402
449
 
403
- # Convert value to appropriate type
450
+ # Convert value to appropriate type (exported args are always strings)
404
451
  try:
405
- click_type = get_click_type(arg_type)
452
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
406
453
  converted_value = click_type.convert(arg_value, None, None)
407
- args_dict[name] = converted_value
454
+
455
+ # Validate choices after type conversion
456
+ if spec.choices is not None and converted_value not in spec.choices:
457
+ console.print(f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]")
458
+ console.print(f"Valid choices: {', '.join(repr(c) for c in spec.choices)}")
459
+ raise typer.Exit(1)
460
+
461
+ args_dict[spec.name] = converted_value
462
+ except typer.Exit:
463
+ raise # Re-raise typer.Exit without wrapping
408
464
  except Exception as e:
409
- console.print(f"[red]Invalid value for {name}: {e}[/red]")
465
+ console.print(f"[red]Invalid value for {spec.name}: {e}[/red]")
410
466
  raise typer.Exit(1)
411
467
 
412
468
  # Fill in defaults for missing arguments
413
- for name, arg_type, default in parsed_specs:
414
- if name not in args_dict:
415
- if default is not None:
469
+ for spec in parsed_specs:
470
+ if spec.name not in args_dict:
471
+ if spec.default is not None:
416
472
  try:
417
- click_type = get_click_type(arg_type)
418
- args_dict[name] = click_type.convert(default, None, None)
473
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
474
+ args_dict[spec.name] = click_type.convert(spec.default, None, None)
419
475
  except Exception as e:
420
- console.print(f"[red]Invalid default value for {name}: {e}[/red]")
476
+ console.print(f"[red]Invalid default value for {spec.name}: {e}[/red]")
421
477
  raise typer.Exit(1)
422
478
  else:
423
- console.print(f"[red]Missing required argument: {name}[/red]")
479
+ console.print(f"[red]Missing required argument: {spec.name}[/red]")
424
480
  raise typer.Exit(1)
425
481
 
426
482
  return args_dict
tasktree/docker.py CHANGED
@@ -6,6 +6,7 @@ Provides Docker image building and container execution capabilities.
6
6
  from __future__ import annotations
7
7
 
8
8
  import os
9
+ import platform
9
10
  import re
10
11
  import subprocess
11
12
  import time
@@ -39,6 +40,22 @@ class DockerManager:
39
40
  self._project_root = project_root
40
41
  self._built_images: dict[str, tuple[str, str]] = {} # env_name -> (image_tag, image_id) cache
41
42
 
43
+ def _should_add_user_flag(self) -> bool:
44
+ """Check if --user flag should be added to docker run.
45
+
46
+ Returns False on Windows (where Docker Desktop handles UID mapping automatically).
47
+ Returns True on Linux/macOS where os.getuid() and os.getgid() are available.
48
+
49
+ Returns:
50
+ True if --user flag should be added, False otherwise
51
+ """
52
+ # Skip on Windows - Docker Desktop handles UID mapping differently
53
+ if platform.system() == "Windows":
54
+ return False
55
+
56
+ # Check if os.getuid() and os.getgid() are available (Linux/macOS)
57
+ return hasattr(os, "getuid") and hasattr(os, "getgid")
58
+
42
59
  def ensure_image_built(self, env: Environment) -> tuple[str, str]:
43
60
  """Build Docker image if not already built this invocation.
44
61
 
@@ -127,6 +144,14 @@ class DockerManager:
127
144
  # Build docker run command
128
145
  docker_cmd = ["docker", "run", "--rm"]
129
146
 
147
+ # Add user mapping (run as current host user) unless explicitly disabled or on Windows
148
+ if not env.run_as_root and self._should_add_user_flag():
149
+ uid = os.getuid()
150
+ gid = os.getgid()
151
+ docker_cmd.extend(["--user", f"{uid}:{gid}"])
152
+
153
+ docker_cmd.extend(env.extra_args)
154
+
130
155
  # Add volume mounts
131
156
  for volume in env.volumes:
132
157
  # Resolve volume paths