tasktree 0.0.7__py3-none-any.whl → 0.0.8__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
@@ -375,8 +375,8 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
375
375
 
376
376
  parsed_specs = []
377
377
  for spec in arg_specs:
378
- name, arg_type, default = parse_arg_spec(spec)
379
- parsed_specs.append((name, arg_type, default))
378
+ parsed = parse_arg_spec(spec)
379
+ parsed_specs.append(parsed)
380
380
 
381
381
  args_dict = {}
382
382
  positional_index = 0
@@ -386,41 +386,49 @@ def _parse_task_args(arg_specs: list[str], arg_values: list[str]) -> dict[str, A
386
386
  if "=" in value_str:
387
387
  arg_name, arg_value = value_str.split("=", 1)
388
388
  # Find the spec for this argument
389
- spec = next((s for s in parsed_specs if s[0] == arg_name), None)
389
+ spec = next((s for s in parsed_specs if s.name == arg_name), None)
390
390
  if spec is None:
391
391
  console.print(f"[red]Unknown argument: {arg_name}[/red]")
392
392
  raise typer.Exit(1)
393
- name, arg_type, default = spec
394
393
  else:
395
394
  # Positional argument
396
395
  if positional_index >= len(parsed_specs):
397
396
  console.print(f"[red]Too many arguments[/red]")
398
397
  raise typer.Exit(1)
399
- name, arg_type, default = parsed_specs[positional_index]
398
+ spec = parsed_specs[positional_index]
400
399
  arg_value = value_str
401
400
  positional_index += 1
402
401
 
403
- # Convert value to appropriate type
402
+ # Convert value to appropriate type (exported args are always strings)
404
403
  try:
405
- click_type = get_click_type(arg_type)
404
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
406
405
  converted_value = click_type.convert(arg_value, None, None)
407
- args_dict[name] = converted_value
406
+
407
+ # Validate choices after type conversion
408
+ if spec.choices is not None and converted_value not in spec.choices:
409
+ console.print(f"[red]Invalid value for {spec.name}: {converted_value!r}[/red]")
410
+ console.print(f"Valid choices: {', '.join(repr(c) for c in spec.choices)}")
411
+ raise typer.Exit(1)
412
+
413
+ args_dict[spec.name] = converted_value
414
+ except typer.Exit:
415
+ raise # Re-raise typer.Exit without wrapping
408
416
  except Exception as e:
409
- console.print(f"[red]Invalid value for {name}: {e}[/red]")
417
+ console.print(f"[red]Invalid value for {spec.name}: {e}[/red]")
410
418
  raise typer.Exit(1)
411
419
 
412
420
  # 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:
421
+ for spec in parsed_specs:
422
+ if spec.name not in args_dict:
423
+ if spec.default is not None:
416
424
  try:
417
- click_type = get_click_type(arg_type)
418
- args_dict[name] = click_type.convert(default, None, None)
425
+ click_type = get_click_type(spec.arg_type, min_val=spec.min_val, max_val=spec.max_val)
426
+ args_dict[spec.name] = click_type.convert(spec.default, None, None)
419
427
  except Exception as e:
420
- console.print(f"[red]Invalid default value for {name}: {e}[/red]")
428
+ console.print(f"[red]Invalid default value for {spec.name}: {e}[/red]")
421
429
  raise typer.Exit(1)
422
430
  else:
423
- console.print(f"[red]Missing required argument: {name}[/red]")
431
+ console.print(f"[red]Missing required argument: {spec.name}[/red]")
424
432
  raise typer.Exit(1)
425
433
 
426
434
  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
tasktree/executor.py CHANGED
@@ -9,14 +9,14 @@ import subprocess
9
9
  import tempfile
10
10
  import time
11
11
  from dataclasses import dataclass, field
12
- from datetime import datetime
12
+ from datetime import datetime, timezone
13
13
  from pathlib import Path
14
14
  from typing import Any
15
15
 
16
16
  from tasktree import docker as docker_module
17
17
  from tasktree.graph import get_implicit_inputs, resolve_execution_order
18
18
  from tasktree.hasher import hash_args, hash_task, make_cache_key
19
- from tasktree.parser import Recipe, Task
19
+ from tasktree.parser import Recipe, Task, Environment
20
20
  from tasktree.state import StateManager, TaskState
21
21
 
22
22
 
@@ -41,6 +41,18 @@ class ExecutionError(Exception):
41
41
  class Executor:
42
42
  """Executes tasks with incremental execution logic."""
43
43
 
44
+ # Protected environment variables that cannot be overridden by exported args
45
+ PROTECTED_ENV_VARS = {
46
+ 'PATH',
47
+ 'LD_LIBRARY_PATH',
48
+ 'LD_PRELOAD',
49
+ 'PYTHONPATH',
50
+ 'HOME',
51
+ 'SHELL',
52
+ 'USER',
53
+ 'LOGNAME',
54
+ }
55
+
44
56
  def __init__(self, recipe: Recipe, state_manager: StateManager):
45
57
  """Initialize executor.
46
58
 
@@ -52,6 +64,106 @@ class Executor:
52
64
  self.state = state_manager
53
65
  self.docker_manager = docker_module.DockerManager(recipe.project_root)
54
66
 
67
+ def _collect_early_builtin_variables(self, task: Task, timestamp: datetime) -> dict[str, str]:
68
+ """Collect built-in variables that don't depend on working_dir.
69
+
70
+ These variables can be used in the working_dir field itself.
71
+
72
+ Args:
73
+ task: Task being executed
74
+ timestamp: Timestamp when task started execution
75
+
76
+ Returns:
77
+ Dictionary mapping built-in variable names to their string values
78
+
79
+ Raises:
80
+ ExecutionError: If any built-in variable fails to resolve
81
+ """
82
+ import os
83
+
84
+ builtin_vars = {}
85
+
86
+ # {{ tt.project_root }} - Absolute path to project root
87
+ builtin_vars['project_root'] = str(self.recipe.project_root.resolve())
88
+
89
+ # {{ tt.recipe_dir }} - Absolute path to directory containing the recipe file
90
+ builtin_vars['recipe_dir'] = str(self.recipe.recipe_path.parent.resolve())
91
+
92
+ # {{ tt.task_name }} - Name of currently executing task
93
+ builtin_vars['task_name'] = task.name
94
+
95
+ # {{ tt.timestamp }} - ISO8601 timestamp when task started execution
96
+ builtin_vars['timestamp'] = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
97
+
98
+ # {{ tt.timestamp_unix }} - Unix epoch timestamp when task started
99
+ builtin_vars['timestamp_unix'] = str(int(timestamp.timestamp()))
100
+
101
+ # {{ tt.user_home }} - Current user's home directory (cross-platform)
102
+ try:
103
+ user_home = Path.home()
104
+ builtin_vars['user_home'] = str(user_home)
105
+ except Exception as e:
106
+ raise ExecutionError(
107
+ f"Failed to get user home directory for {{ tt.user_home }}: {e}"
108
+ )
109
+
110
+ # {{ tt.user_name }} - Current username (with fallback)
111
+ try:
112
+ user_name = os.getlogin()
113
+ except OSError:
114
+ # Fallback to environment variables if os.getlogin() fails
115
+ user_name = os.environ.get('USER') or os.environ.get('USERNAME') or 'unknown'
116
+ builtin_vars['user_name'] = user_name
117
+
118
+ return builtin_vars
119
+
120
+ def _collect_builtin_variables(self, task: Task, working_dir: Path, timestamp: datetime) -> dict[str, str]:
121
+ """Collect built-in variables for task execution.
122
+
123
+ Args:
124
+ task: Task being executed
125
+ working_dir: Resolved working directory for the task
126
+ timestamp: Timestamp when task started execution
127
+
128
+ Returns:
129
+ Dictionary mapping built-in variable names to their string values
130
+
131
+ Raises:
132
+ ExecutionError: If any built-in variable fails to resolve
133
+ """
134
+ # Get early builtin vars (those that don't depend on working_dir)
135
+ builtin_vars = self._collect_early_builtin_variables(task, timestamp)
136
+
137
+ # {{ tt.working_dir }} - Absolute path to task's effective working directory
138
+ # This is added after working_dir is resolved to avoid circular dependency
139
+ builtin_vars['working_dir'] = str(working_dir.resolve())
140
+
141
+ return builtin_vars
142
+
143
+ def _prepare_env_with_exports(self, exported_env_vars: dict[str, str] | None = None) -> dict[str, str]:
144
+ """Prepare environment with exported arguments.
145
+
146
+ Args:
147
+ exported_env_vars: Exported arguments to set as environment variables
148
+
149
+ Returns:
150
+ Environment dict with exported args merged
151
+
152
+ Raises:
153
+ ValueError: If an exported arg attempts to override a protected environment variable
154
+ """
155
+ env = os.environ.copy()
156
+ if exported_env_vars:
157
+ # Check for protected environment variable overrides
158
+ for key in exported_env_vars:
159
+ if key in self.PROTECTED_ENV_VARS:
160
+ raise ValueError(
161
+ f"Cannot override protected environment variable: {key}\n"
162
+ f"Protected variables are: {', '.join(sorted(self.PROTECTED_ENV_VARS))}"
163
+ )
164
+ env.update(exported_env_vars)
165
+ return env
166
+
55
167
  def _get_platform_default_environment(self) -> tuple[str, list[str]]:
56
168
  """Get default shell and args for current platform.
57
169
 
@@ -301,11 +413,47 @@ class Executor:
301
413
  Raises:
302
414
  ExecutionError: If task execution fails
303
415
  """
304
- # Substitute arguments in command
305
- cmd = self._substitute_args(task.cmd, args_dict)
416
+ # Capture timestamp at task start for consistency (in UTC)
417
+ task_start_time = datetime.now(timezone.utc)
418
+
419
+ # Parse task arguments to identify exported args
420
+ # Note: args_dict already has defaults applied by CLI (cli.py:413-424)
421
+ from tasktree.parser import parse_arg_spec
422
+ exported_args = set()
423
+ regular_args = {}
424
+ exported_env_vars = {}
425
+
426
+ for arg_spec in task.args:
427
+ parsed = parse_arg_spec(arg_spec)
428
+ if parsed.is_exported:
429
+ exported_args.add(parsed.name)
430
+ # Get value and convert to string for environment variable
431
+ # Value should always be in args_dict (CLI applies defaults)
432
+ if parsed.name in args_dict:
433
+ exported_env_vars[parsed.name] = str(args_dict[parsed.name])
434
+ else:
435
+ if parsed.name in args_dict:
436
+ regular_args[parsed.name] = args_dict[parsed.name]
437
+
438
+ # Collect early built-in variables (those that don't depend on working_dir)
439
+ # These can be used in the working_dir field itself
440
+ early_builtin_vars = self._collect_early_builtin_variables(task, task_start_time)
441
+
442
+ # Resolve working directory
443
+ # Validate that working_dir doesn't contain {{ tt.working_dir }} (circular dependency)
444
+ self._validate_no_working_dir_circular_ref(task.working_dir)
445
+ working_dir_str = self._substitute_builtin(task.working_dir, early_builtin_vars)
446
+ working_dir_str = self._substitute_args(working_dir_str, regular_args, exported_args)
447
+ working_dir_str = self._substitute_env(working_dir_str)
448
+ working_dir = self.recipe.project_root / working_dir_str
306
449
 
307
- # Determine working directory
308
- working_dir = self.recipe.project_root / task.working_dir
450
+ # Collect all built-in variables (including tt.working_dir now that it's resolved)
451
+ builtin_vars = self._collect_builtin_variables(task, working_dir, task_start_time)
452
+
453
+ # Substitute built-in variables, arguments, and environment variables in command
454
+ cmd = self._substitute_builtin(task.cmd, builtin_vars)
455
+ cmd = self._substitute_args(cmd, regular_args, exported_args)
456
+ cmd = self._substitute_env(cmd)
309
457
 
310
458
  # Check if task uses Docker environment
311
459
  env_name = self._get_effective_env_name(task)
@@ -319,22 +467,23 @@ class Executor:
319
467
  # Route to Docker execution or regular execution
320
468
  if env and env.dockerfile:
321
469
  # Docker execution path
322
- self._run_task_in_docker(task, env, cmd, working_dir)
470
+ self._run_task_in_docker(task, env, cmd, working_dir, exported_env_vars)
323
471
  else:
324
472
  # Regular execution path
325
473
  shell, shell_args, preamble = self._resolve_environment(task)
326
474
 
327
475
  # Detect multi-line commands (ignore trailing newlines from YAML folded blocks)
328
476
  if "\n" in cmd.rstrip():
329
- self._run_multiline_command(cmd, working_dir, task.name, shell, preamble)
477
+ self._run_multiline_command(cmd, working_dir, task.name, shell, preamble, exported_env_vars)
330
478
  else:
331
- self._run_single_line_command(cmd, working_dir, task.name, shell, shell_args)
479
+ self._run_single_line_command(cmd, working_dir, task.name, shell, shell_args, exported_env_vars)
332
480
 
333
481
  # Update state
334
482
  self._update_state(task, args_dict)
335
483
 
336
484
  def _run_single_line_command(
337
- self, cmd: str, working_dir: Path, task_name: str, shell: str, shell_args: list[str]
485
+ self, cmd: str, working_dir: Path, task_name: str, shell: str, shell_args: list[str],
486
+ exported_env_vars: dict[str, str] | None = None
338
487
  ) -> None:
339
488
  """Execute a single-line command via shell.
340
489
 
@@ -344,10 +493,14 @@ class Executor:
344
493
  task_name: Task name (for error messages)
345
494
  shell: Shell executable to use
346
495
  shell_args: Arguments to pass to shell
496
+ exported_env_vars: Exported arguments to set as environment variables
347
497
 
348
498
  Raises:
349
499
  ExecutionError: If command execution fails
350
500
  """
501
+ # Prepare environment with exported args
502
+ env = self._prepare_env_with_exports(exported_env_vars)
503
+
351
504
  try:
352
505
  # Build command: shell + args + cmd
353
506
  full_cmd = [shell] + shell_args + [cmd]
@@ -356,6 +509,7 @@ class Executor:
356
509
  cwd=working_dir,
357
510
  check=True,
358
511
  capture_output=False,
512
+ env=env,
359
513
  )
360
514
  except subprocess.CalledProcessError as e:
361
515
  raise ExecutionError(
@@ -363,7 +517,8 @@ class Executor:
363
517
  )
364
518
 
365
519
  def _run_multiline_command(
366
- self, cmd: str, working_dir: Path, task_name: str, shell: str, preamble: str
520
+ self, cmd: str, working_dir: Path, task_name: str, shell: str, preamble: str,
521
+ exported_env_vars: dict[str, str] | None = None
367
522
  ) -> None:
368
523
  """Execute a multi-line command via temporary script file.
369
524
 
@@ -373,10 +528,14 @@ class Executor:
373
528
  task_name: Task name (for error messages)
374
529
  shell: Shell to use for script execution
375
530
  preamble: Preamble text to prepend to script
531
+ exported_env_vars: Exported arguments to set as environment variables
376
532
 
377
533
  Raises:
378
534
  ExecutionError: If command execution fails
379
535
  """
536
+ # Prepare environment with exported args
537
+ env = self._prepare_env_with_exports(exported_env_vars)
538
+
380
539
  # Determine file extension based on platform
381
540
  is_windows = platform.system() == "Windows"
382
541
  script_ext = ".bat" if is_windows else ".sh"
@@ -417,6 +576,7 @@ class Executor:
417
576
  cwd=working_dir,
418
577
  check=True,
419
578
  capture_output=False,
579
+ env=env,
420
580
  )
421
581
  except subprocess.CalledProcessError as e:
422
582
  raise ExecutionError(
@@ -430,7 +590,8 @@ class Executor:
430
590
  pass # Ignore cleanup errors
431
591
 
432
592
  def _run_task_in_docker(
433
- self, task: Task, env: Any, cmd: str, working_dir: Path
593
+ self, task: Task, env: Any, cmd: str, working_dir: Path,
594
+ exported_env_vars: dict[str, str] | None = None
434
595
  ) -> None:
435
596
  """Execute task inside Docker container.
436
597
 
@@ -439,6 +600,7 @@ class Executor:
439
600
  env: Docker environment configuration
440
601
  cmd: Command to execute
441
602
  working_dir: Host working directory
603
+ exported_env_vars: Exported arguments to set as environment variables
442
604
 
443
605
  Raises:
444
606
  ExecutionError: If Docker execution fails
@@ -448,10 +610,26 @@ class Executor:
448
610
  env.working_dir, task.working_dir
449
611
  )
450
612
 
613
+ # Validate and merge exported args with env vars (exported args take precedence)
614
+ docker_env_vars = env.env_vars.copy() if env.env_vars else {}
615
+ if exported_env_vars:
616
+ # Check for protected environment variable overrides
617
+ for key in exported_env_vars:
618
+ if key in self.PROTECTED_ENV_VARS:
619
+ raise ValueError(
620
+ f"Cannot override protected environment variable: {key}\n"
621
+ f"Protected variables are: {', '.join(sorted(self.PROTECTED_ENV_VARS))}"
622
+ )
623
+ docker_env_vars.update(exported_env_vars)
624
+
625
+ # Create modified environment with merged env vars using dataclass replace
626
+ from dataclasses import replace
627
+ modified_env = replace(env, env_vars=docker_env_vars)
628
+
451
629
  # Execute in container
452
630
  try:
453
631
  self.docker_manager.run_in_container(
454
- env=env,
632
+ env=modified_env,
455
633
  cmd=cmd,
456
634
  working_dir=working_dir,
457
635
  container_working_dir=container_working_dir,
@@ -459,21 +637,82 @@ class Executor:
459
637
  except docker_module.DockerError as e:
460
638
  raise ExecutionError(str(e)) from e
461
639
 
462
- def _substitute_args(self, cmd: str, args_dict: dict[str, Any]) -> str:
463
- """Substitute arguments in command string.
640
+ def _validate_no_working_dir_circular_ref(self, text: str) -> None:
641
+ """Validate that working_dir field does not contain {{ tt.working_dir }}.
642
+
643
+ Using {{ tt.working_dir }} in the working_dir field creates a circular dependency.
644
+
645
+ Args:
646
+ text: The working_dir field value to validate
647
+
648
+ Raises:
649
+ ExecutionError: If {{ tt.working_dir }} placeholder is found
650
+ """
651
+ import re
652
+ # Pattern to match {{ tt.working_dir }} specifically
653
+ pattern = re.compile(r'\{\{\s*tt\s*\.\s*working_dir\s*\}\}')
654
+
655
+ if pattern.search(text):
656
+ raise ExecutionError(
657
+ f"Cannot use {{{{ tt.working_dir }}}} in the 'working_dir' field.\n\n"
658
+ f"This creates a circular dependency (working_dir cannot reference itself).\n"
659
+ f"Other built-in variables like {{{{ tt.task_name }}}} or {{{{ tt.timestamp }}}} are allowed."
660
+ )
661
+
662
+ def _substitute_builtin(self, text: str, builtin_vars: dict[str, str]) -> str:
663
+ """Substitute {{ tt.name }} placeholders in text.
664
+
665
+ Built-in variables are resolved at execution time.
464
666
 
465
667
  Args:
466
- cmd: Command template with {{arg}} placeholders
467
- args_dict: Arguments to substitute
668
+ text: Text with {{ tt.name }} placeholders
669
+ builtin_vars: Built-in variable values
670
+
671
+ Returns:
672
+ Text with built-in variables substituted
673
+
674
+ Raises:
675
+ ValueError: If built-in variable is not defined
676
+ """
677
+ from tasktree.substitution import substitute_builtin_variables
678
+ return substitute_builtin_variables(text, builtin_vars)
679
+
680
+ def _substitute_args(self, cmd: str, args_dict: dict[str, Any], exported_args: set[str] | None = None) -> str:
681
+ """Substitute {{ arg.name }} placeholders in command string.
682
+
683
+ Variables are already substituted at parse time by the parser.
684
+ This only handles runtime argument substitution.
685
+
686
+ Args:
687
+ cmd: Command with {{ arg.name }} placeholders
688
+ args_dict: Argument values to substitute (only regular args)
689
+ exported_args: Set of argument names that are exported (not available for substitution)
468
690
 
469
691
  Returns:
470
692
  Command with arguments substituted
693
+
694
+ Raises:
695
+ ValueError: If an exported argument is used in template substitution
696
+ """
697
+ from tasktree.substitution import substitute_arguments
698
+ return substitute_arguments(cmd, args_dict, exported_args)
699
+
700
+ def _substitute_env(self, text: str) -> str:
701
+ """Substitute {{ env.NAME }} placeholders in text.
702
+
703
+ Environment variables are resolved at execution time from os.environ.
704
+
705
+ Args:
706
+ text: Text with {{ env.NAME }} placeholders
707
+
708
+ Returns:
709
+ Text with environment variables substituted
710
+
711
+ Raises:
712
+ ValueError: If environment variable is not set
471
713
  """
472
- result = cmd
473
- for key, value in args_dict.items():
474
- placeholder = f"{{{{{key}}}}}"
475
- result = result.replace(placeholder, str(value))
476
- return result
714
+ from tasktree.substitution import substitute_environment
715
+ return substitute_environment(text)
477
716
 
478
717
  def _get_all_inputs(self, task: Task) -> list[str]:
479
718
  """Get all inputs for a task (explicit + implicit from dependencies).
tasktree/hasher.py CHANGED
@@ -3,12 +3,46 @@ import json
3
3
  from typing import Any, Optional
4
4
 
5
5
 
6
- def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str], env: str = "") -> str:
6
+ def _arg_sort_key(arg: str | dict[str, Any]) -> str:
7
+ """Extract the sort key from an arg for deterministic hashing.
8
+
9
+ Args:
10
+ arg: Either a string arg or dict arg specification
11
+
12
+ Returns:
13
+ The argument name to use as a sort key
14
+ """
15
+ if isinstance(arg, dict):
16
+ # Dict args have exactly one key - the argument name
17
+ # This is validated by parse_arg_spec in parser.py
18
+ return next(iter(arg.keys()))
19
+ return arg
20
+
21
+
22
+ def _normalize_choices_lists(args: list[str | dict[str, Any]]) -> list[str | dict[str, Any]]:
23
+ normalized_args = []
24
+ for arg in args:
25
+ if isinstance(arg, dict):
26
+ # Deep copy and sort choices if present
27
+ normalized = {}
28
+ for key, value in arg.items():
29
+ if isinstance(value, dict) and 'choices' in value:
30
+ normalized[key] = {**value, 'choices': sorted(value['choices'], key=str)}
31
+ else:
32
+ normalized[key] = value
33
+ normalized_args.append(normalized)
34
+ else:
35
+ normalized_args.append(arg)
36
+
37
+ return normalized_args
38
+
39
+
40
+ def hash_task(cmd: str, outputs: list[str], working_dir: str, args: list[str | dict[str, Any]], env: str = "") -> str:
7
41
  data = {
8
42
  "cmd": cmd,
9
43
  "outputs": sorted(outputs),
10
44
  "working_dir": working_dir,
11
- "args": sorted(args),
45
+ "args": sorted(_normalize_choices_lists(args), key=_arg_sort_key),
12
46
  "env": env,
13
47
  }
14
48