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 +78 -22
- tasktree/docker.py +25 -0
- tasktree/executor.py +346 -34
- tasktree/graph.py +124 -26
- tasktree/hasher.py +73 -2
- tasktree/parser.py +1288 -35
- tasktree/substitution.py +198 -0
- tasktree/types.py +11 -2
- tasktree-0.0.9.dist-info/METADATA +1240 -0
- tasktree-0.0.9.dist-info/RECORD +15 -0
- tasktree-0.0.7.dist-info/METADATA +0 -654
- tasktree-0.0.7.dist-info/RECORD +0 -14
- {tasktree-0.0.7.dist-info → tasktree-0.0.9.dist-info}/WHEEL +0 -0
- {tasktree-0.0.7.dist-info → tasktree-0.0.9.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
parsed_specs.append(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|